diff --git a/.config/nextest.toml b/.config/nextest.toml index 2a1b5157a2..21c9fa8c05 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -84,7 +84,7 @@ platform = { host = 'cfg(windows)' } test-group = 'windows-session-sqlite' [[profile.ci.overrides]] -filter = '(binary(=core_cli_suite) & test(/^tool_first_touch_test::/)) | (binary(=mcp_suite) & test(/^mcp_dashboard_tool_test::/)) | (binary(=hooks_lsp_suite) & (test(/^lsp_code_diagnostics_test::/) | test(/^extract_worker_test::/)))' +filter = '(binary(=core_cli_suite) & test(/^tool_first_touch_test::/)) | (binary(=mcp_suite) & test(/^mcp_dashboard_tool_test::/)) | (binary(=hooks_lsp_suite) & test(/^lsp_code_diagnostics_test::/))' platform = { host = 'cfg(windows)' } test-group = 'windows-process-heavy' diff --git a/Cargo.lock b/Cargo.lock index 6bf80fb900..5c9d63cd1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -441,15 +441,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "bincode" -version = "1.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" -dependencies = [ - "serde", -] - [[package]] name = "bincode" version = "2.0.1" @@ -2674,7 +2665,7 @@ version = "0.5.42" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38cf1373a739aeaa070430588c4026b3c1aa11cd0ad6e6673bda22a390aacda4" dependencies = [ - "bincode 2.0.1", + "bincode", "grafeo-common", "grafeo-core", "hashbrown 0.17.1", @@ -2691,7 +2682,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5f446c25eeedab9cccaabc85060dfa08dac2d1041974244864f36436b81ca5a" dependencies = [ "arcstr", - "bincode 2.0.1", + "bincode", "bumpalo", "byteorder", "bytes", @@ -2712,7 +2703,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e185dd750843637a2d99e56100c08ef4cc34ce4a7b5f9cb9566f873bbf54c90" dependencies = [ "arcstr", - "bincode 2.0.1", + "bincode", "byteorder", "bytes", "crc32fast", @@ -2737,7 +2728,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ede967e6b0a16396c91752febdf037ab04ca69b851f6fb43565ee5f30586ee0d" dependencies = [ "arcstr", - "bincode 2.0.1", + "bincode", "bytes", "crc32fast", "grafeo-adapters", @@ -2758,7 +2749,7 @@ version = "0.5.42" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6857029da63e10209ae622ba85a87593ea08646ea17c02d5aa14461c7ff296f7" dependencies = [ - "bincode 2.0.1", + "bincode", "byteorder", "bytes", "crc32fast", @@ -5567,7 +5558,6 @@ version = "0.1.0-beta.1" dependencies = [ "axum", "base64 0.22.1", - "bincode 1.3.3", "cap-fs-ext", "cap-std", "clap", diff --git a/Cargo.toml b/Cargo.toml index 53666408e5..9df5c43b90 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -343,7 +343,6 @@ gix = { version = "=0.86.0", default-features = false, features = ["revision", " dirs = "6" hex = "0.4" rayon = "1" -bincode = "1.3" getrandom = "0.2" self-replace = "1" memmap2 = "0.9" diff --git a/SECURITY.md b/SECURITY.md index eeec62eab9..4df28011c7 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -136,10 +136,6 @@ recursive-write primitive. tracedecay installs **no background daemon, system service, or autostart process by default**. Users can explicitly opt in with `tracedecay daemon install-service`, which installs a per-user systemd service on Linux or a per-user LaunchAgent on macOS. The daemon runs with **standard user privileges** and never requests elevation. Index freshness still relies on on-demand staleness checks, catch-up syncs when MCP clients connect, and bounded hook notifications; the daemon provides shared MCP process/socket reuse and scheduled automation for projects that connect to it. -### Subprocess-isolated extraction - -Tree-sitter grammars are compiled C/C++ and can crash the process in ways Rust cannot catch. Each file is parsed inside a short-lived worker subprocess (the hidden `extract-worker` subcommand). The worker authenticates against its parent with a 256-bit per-spawn token supplied via the `TRACEDECAY_WORKER_TOKEN` environment variable; a user invoking `tracedecay extract-worker` directly fails immediately. Opt out with `TRACEDECAY_DISABLE_SUBPROCESS=1`. - ### Unsafe code The codebase contains minimal `unsafe`, used in two cross-platform places: diff --git a/crates/tracedecay-code-extraction/src/ts_provider.rs b/crates/tracedecay-code-extraction/src/ts_provider.rs index 8cbaefda0e..8fe14301a9 100644 --- a/crates/tracedecay-code-extraction/src/ts_provider.rs +++ b/crates/tracedecay-code-extraction/src/ts_provider.rs @@ -7,7 +7,7 @@ use std::collections::HashMap; use std::sync::LazyLock; use tree_sitter::Language; -/// Package-owned patched Rust grammar and its generated query assets. +/// Package-owned patched Rust grammar. pub mod rust_grammar { use tree_sitter_language::LanguageFn; @@ -17,16 +17,6 @@ pub mod rust_grammar { /// The patched Rust grammar compiled from `vendor/tree-sitter-rust`. pub const LANGUAGE: LanguageFn = unsafe { LanguageFn::from_raw(tracedecay_tree_sitter_rust) }; - /// Generated node type metadata for the patched grammar. - pub const NODE_TYPES: &str = include_str!("../vendor/tree-sitter-rust/src/node-types.json"); - /// Syntax highlighting query for the patched grammar. - pub const HIGHLIGHTS_QUERY: &str = - include_str!("../vendor/tree-sitter-rust/queries/highlights.scm"); - /// Injection query for the patched grammar. - pub const INJECTIONS_QUERY: &str = - include_str!("../vendor/tree-sitter-rust/queries/injections.scm"); - /// Symbol tagging query for the patched grammar. - pub const TAGS_QUERY: &str = include_str!("../vendor/tree-sitter-rust/queries/tags.scm"); } // tree-sitter-wgsl 0.0.6 was built against tree-sitter 0.20, whose Language diff --git a/crates/tracedecay-hooks/src/lib.rs b/crates/tracedecay-hooks/src/lib.rs index 9fb6bc26a7..290e318c29 100644 --- a/crates/tracedecay-hooks/src/lib.rs +++ b/crates/tracedecay-hooks/src/lib.rs @@ -18,50 +18,42 @@ pub mod runtime; pub mod spool; pub use admission_ledger::{ - HookAdmissionDecisionV1, HookAdmissionLedgerError, HookAdmissionLedgerLimitsV1, - HookAdmissionLedgerOpenReportV1, HookAdmissionLedgerReceiptV1, HookAdmissionLedgerV1, - hook_admission_digest, + HookAdmissionDecisionV1, HookAdmissionLedgerLimitsV1, HookAdmissionLedgerReceiptV1, + HookAdmissionLedgerV1, }; pub use capture::{ NativeHookCaptureOutcomeV1, NativeHookCaptureSourceV1, capture_native_event_for_replay, }; pub use config::{ HOOK_CONFIGURATION_SCHEMA_VERSION, HookConfigurationFileReaderV1, - HookConfigurationFileWriterV1, HookConfigurationPublicationError, - HookConfigurationPublicationOutcomeV1, HookConfigurationPublicationStoreV1, - HookConfigurationPublisherV1, HookConfigurationReadOutcomeV1, HookConfigurationReadStoreV1, - HookConfigurationSnapshotV1, HookConfigurationSubscriberV1, MAX_HOOK_CONFIGURATION_BYTES, - hook_configuration_path, + HookConfigurationFileWriterV1, HookConfigurationPublisherV1, HookConfigurationReadOutcomeV1, + HookConfigurationSnapshotV1, HookConfigurationSubscriberV1, hook_configuration_path, }; pub use core_events::{ DaemonHookEvent, HOOK_EVENT_METHOD, HookAgent, HookEventNotifyOutcomeV1, HookRouteMetadata, HookTerminalReceipt, }; pub use delivery_spool::{ - HookDeliveryReceiptSpoolV1, HookDeliverySourceReceiptV1, HookDeliverySpoolError, - hook_delivery_receipt_spool_root, + HookDeliveryReceiptSpoolV1, HookDeliverySourceReceiptV1, hook_delivery_receipt_spool_root, }; pub use native::{ - DecodedNativeHookEventV1, DecodedOpenCodeLspEventV1, NativeEnvelopeMaterialV1, - NativeHookDecodeError, NativeHookSignalV1, OpenCodePluginSurfaceV1, - ProfileScopedNativeHookAdmissionV1, decode_bound_native_hook_event, decode_native_hook_event, - decode_opencode_lsp_event, decode_opencode_plugin_event, + DecodedNativeHookEventV1, NativeEnvelopeMaterialV1, NativeHookDecodeError, + OpenCodePluginSurfaceV1, ProfileScopedNativeHookAdmissionV1, decode_bound_native_hook_event, + decode_native_hook_event, decode_opencode_lsp_event, decode_opencode_plugin_event, }; pub use runtime::{ - AsyncHookAdmissionPortV1, AsyncHookFeedbackDeliveryPortV1, HOOK_SYNCHRONOUS_BUDGET_MICROS, - HookAdmissionFutureV1, HookAdmissionReceiptV1, HookDeliveryFutureV1, - HookFeedbackDeliveryOutcomeV1, HookFeedbackDeliveryPortV1, HookFeedbackDeliveryRouteV1, - HookFeedbackDeliveryV1, HookFeedbackRollbackSwitchV1, HookGuidanceDispositionV1, - HookGuidanceStateV1, HookImmediateAdmissionStateV1, HookImmediateAdmissionV1, - HookReadyGuidanceV1, HookRuntimeControlV1, HookRuntimeErrorV1, HookScopedFeedbackV1, - HookSynchronousDeadlineV1, HookSynchronousResultV1, admit_async_exact_scope, - deliver_feedback_with_rollback, deliver_feedback_with_rollback_async, deliver_hook_feedback, + AsyncHookAdmissionPortV1, AsyncHookFeedbackDeliveryPortV1, HookAdmissionFutureV1, + HookAdmissionReceiptV1, HookDeliveryFutureV1, HookFeedbackDeliveryOutcomeV1, + HookFeedbackDeliveryPortV1, HookFeedbackDeliveryRouteV1, HookFeedbackDeliveryV1, + HookFeedbackRollbackSwitchV1, HookGuidanceDispositionV1, HookGuidanceStateV1, + HookImmediateAdmissionStateV1, HookImmediateAdmissionV1, HookReadyGuidanceV1, + HookRuntimeControlV1, HookRuntimeErrorV1, HookScopedFeedbackV1, HookSynchronousDeadlineV1, + admit_async_exact_scope, deliver_feedback_with_rollback, deliver_hook_feedback, finish_synchronous_hook, }; pub use spool::{ - HookReplayBatchV1, HookSpoolAckDispositionV1, HookSpoolAckV1, HookSpoolConfigV1, - HookSpoolError, HookSpoolLimitsV1, HookSpoolOpenReportV1, HookSpoolRecordV1, - HookSpoolResetReasonV1, HookSpoolV1, HookSpoolWriterLeaseV1, hook_spool_checksum, + HookSpoolAckDispositionV1, HookSpoolAckV1, HookSpoolConfigV1, HookSpoolError, + HookSpoolRecordV1, HookSpoolV1, }; use serde::{Deserialize, Serialize}; diff --git a/crates/tracedecay-runtime-core/src/redundancy.rs b/crates/tracedecay-runtime-core/src/redundancy.rs index 795a284267..1ab9b77bae 100644 --- a/crates/tracedecay-runtime-core/src/redundancy.rs +++ b/crates/tracedecay-runtime-core/src/redundancy.rs @@ -28,7 +28,6 @@ use std::collections::HashSet; use std::fmt::Write as _; use sha2::{Digest, Sha256}; -use tracedecay_domain::code_intelligence::Node as CodeNode; use tree_sitter::{Node, Parser, Tree}; /// Length of an n-gram shingle, in tokens. @@ -601,162 +600,9 @@ fn short_sha256(s: &str) -> String { } // --------------------------------------------------------------------------- -// Pairwise redundancy scan +// Pairwise scan bucketing // --------------------------------------------------------------------------- -/// One scored redundant pair: the [`RedundancyMatchScore`] verdict plus -/// borrows of the two graph nodes and their fingerprints. Orientation is -/// canonicalized by [`redundant_pair`] so the same logical pair always -/// presents the same `a`/`b` sides regardless of input order. -pub struct RedundantPair<'a> { - pub score: RedundancyMatchScore, - pub node_a: &'a CodeNode, - pub node_b: &'a CodeNode, - pub fp_a: &'a Fingerprint, - pub fp_b: &'a Fingerprint, -} - -/// Scan a set of `(node, fingerprint)` candidates for redundant pairs. -/// -/// Candidates are sorted by `body_tokens` (ties broken on node id so the -/// enumeration order never depends on DB row order), then each is compared -/// only against the following candidates whose token count falls inside its -/// ±25 % [`body_token_window`] — a linear window over the sorted slice that -/// keeps the pairwise comparison sub-quadratic. Surviving pairs are ranked by -/// `ranking_score` (a total order: ties fall through similarity, cosine, then -/// names and node ids) and truncated to `max_pairs`. -pub fn find_redundant_pairs<'a>( - scoped: Vec<(&'a CodeNode, &'a Fingerprint)>, - threshold: f64, - include_naming: bool, - max_pairs: usize, -) -> Vec> { - let mut scan = RedundancyPairScan::new(scoped, threshold, include_naming, max_pairs); - while scan.advance(usize::MAX) {} - scan.finish() -} - -/// The same scan as [`find_redundant_pairs`], resumable in bounded slices. -/// -/// The scan is a long, uninterrupted CPU loop: run whole inside an async task -/// it pins a runtime worker for its full duration and starves whatever else -/// that worker was serving. This type exposes the identical enumeration as a -/// cursor so an async caller can yield between slices. Enumeration order, -/// scoring, ranking and truncation are unchanged — the cursor only decides -/// *when* the loop pauses, never which pairs it visits or in what order — so -/// a sliced run and a single-shot run return byte-identical results. -pub struct RedundancyPairScan<'a> { - scoped: Vec<(&'a CodeNode, &'a Fingerprint)>, - threshold: f64, - include_naming: bool, - max_pairs: usize, - /// Index of the candidate whose window is being scanned. - outer: usize, - /// Next partner index inside that window. `0` means "window not started", - /// which is unambiguous because a live partner index is always `outer + 1` - /// or greater. - inner: usize, - found: Vec>, -} - -impl<'a> RedundancyPairScan<'a> { - pub fn new( - mut scoped: Vec<(&'a CodeNode, &'a Fingerprint)>, - threshold: f64, - include_naming: bool, - max_pairs: usize, - ) -> Self { - // Sort by body_tokens so the size-window check is a linear scan; break - // ties on node id so candidate enumeration never depends on DB row order. - scoped.sort_by(|(na, fa), (nb, fb)| { - fa.body_tokens - .cmp(&fb.body_tokens) - .then_with(|| na.id.cmp(&nb.id)) - }); - Self { - scoped, - threshold, - include_naming, - max_pairs, - outer: 0, - inner: 0, - found: Vec::new(), - } - } - - /// Score at most `budget` further candidate pairs. - /// - /// Returns `true` while the scan has more work, `false` once every pair has - /// been visited. The budget counts scored comparisons rather than - /// candidates so a cluster of same-sized bodies — where one candidate's - /// window spans thousands of partners — still pauses on schedule. - pub fn advance(&mut self, budget: usize) -> bool { - let mut spent = 0usize; - while self.outer < self.scoped.len() { - let (node_a, fp_a) = self.scoped[self.outer]; - let (lo, hi) = body_token_window(fp_a.body_tokens); - if self.inner == 0 { - self.inner = self.outer + 1; - } - while self.inner < self.scoped.len() { - let (node_b, fp_b) = self.scoped[self.inner]; - if fp_b.body_tokens > hi { - break; // sorted, no need to scan further - } - if fp_b.body_tokens >= lo - && let Some(pair) = redundant_pair( - node_a, - fp_a, - node_b, - fp_b, - self.threshold, - self.include_naming, - ) - { - self.found.push(pair); - } - self.inner += 1; - spent += 1; - if spent >= budget { - return true; - } - } - self.outer += 1; - self.inner = 0; - } - false - } - - /// Rank the collected pairs and truncate to `max_pairs`. - pub fn finish(self) -> Vec> { - let mut found = self.found; - found.sort_by(|a: &RedundantPair<'_>, b: &RedundantPair<'_>| { - b.score - .ranking_score - .partial_cmp(&a.score.ranking_score) - .unwrap_or(std::cmp::Ordering::Equal) - .then_with(|| { - b.score - .similarity - .partial_cmp(&a.score.similarity) - .unwrap_or(std::cmp::Ordering::Equal) - }) - .then_with(|| { - b.score - .vector_cosine - .partial_cmp(&a.score.vector_cosine) - .unwrap_or(std::cmp::Ordering::Equal) - }) - .then_with(|| a.node_a.name.cmp(&b.node_a.name)) - .then_with(|| a.node_b.name.cmp(&b.node_b.name)) - .then_with(|| a.node_a.id.cmp(&b.node_a.id)) - .then_with(|| a.node_b.id.cmp(&b.node_b.id)) - }); - found.truncate(self.max_pairs); - found - } -} - /// The ±25 % `body_tokens` window used to bucket candidates before scoring. /// Returns the inclusive `(low, high)` token bounds for a body of the given /// size. @@ -767,90 +613,6 @@ pub fn body_token_window(body_tokens: usize) -> (usize, usize) { ) } -/// Score one candidate pair, returning a canonically-oriented -/// [`RedundantPair`] or `None` when [`redundancy_match_score`] rejects it. -/// -/// Orientation is fixed by `(file_path, start_line, id)` so the same logical -/// pair always presents the same `a`/`b` sides regardless of input order -/// (scoring is symmetric). -pub(crate) fn redundant_pair<'a>( - node_a: &'a CodeNode, - fp_a: &'a Fingerprint, - node_b: &'a CodeNode, - fp_b: &'a Fingerprint, - threshold: f64, - include_naming: bool, -) -> Option> { - let score = redundancy_match_score( - &node_a.name, - fp_a, - &node_b.name, - fp_b, - threshold, - include_naming, - )?; - // Canonicalize orientation so the same logical pair always presents the - // same a/b sides regardless of DB row order (scoring is symmetric). - let a_key = (&node_a.file_path, node_a.start_line, &node_a.id); - let b_key = (&node_b.file_path, node_b.start_line, &node_b.id); - let (node_a, fp_a, node_b, fp_b) = if a_key <= b_key { - (node_a, fp_a, node_b, fp_b) - } else { - (node_b, fp_b, node_a, fp_a) - }; - Some(RedundantPair { - score, - node_a, - node_b, - fp_a, - fp_b, - }) -} - -/// Connected components over the returned pairs — the shared source of truth -/// for both the JSON `groups` array and the markdown Groups section, so the -/// two views cannot drift on membership. -pub fn connected_node_groups<'a>(pairs: &'a [RedundantPair<'a>]) -> Vec> { - let mut groups: Vec> = Vec::new(); - for pair in pairs { - let mut matching_groups = Vec::new(); - for (idx, group) in groups.iter().enumerate() { - if group - .iter() - .any(|node| node.id == pair.node_a.id || node.id == pair.node_b.id) - { - matching_groups.push(idx); - } - } - - let nodes = [pair.node_a, pair.node_b]; - if matching_groups.is_empty() { - groups.push(Vec::from(nodes)); - continue; - } - - let first = matching_groups[0]; - for node in nodes { - push_unique_node(&mut groups[first], node); - } - for idx in matching_groups.into_iter().skip(1).rev() { - let merged = groups.remove(idx); - for node in merged { - push_unique_node(&mut groups[first], node); - } - } - } - - groups -} - -fn push_unique_node<'a>(nodes: &mut Vec<&'a CodeNode>, node: &'a CodeNode) { - if nodes.iter().any(|existing| existing.id == node.id) { - return; - } - nodes.push(node); -} - // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- diff --git a/src/cli.rs b/src/cli.rs index 677606362b..d3a3904504 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -394,9 +394,6 @@ pub enum Commands { #[command(subcommand)] action: HostBundleAction, }, - /// Extraction worker (spawned by tracedecay itself; not for direct use). - #[command(name = "extract-worker", hide = true)] - ExtractWorker, /// PreToolUse hook handler (called by Claude Code, not by users directly) #[command(name = "hook-pre-tool-use", hide = true)] HookPreToolUse, diff --git a/src/doctor.rs b/src/doctor.rs index 594182c798..804f6e8f94 100644 --- a/src/doctor.rs +++ b/src/doctor.rs @@ -342,384 +342,6 @@ async fn daemon_project_status(project_path: &Path) -> crate::errors::Result crate::errors::Result { - let handshake = crate::daemon::DaemonHandshake::for_current_client( - Some(project_path.to_path_buf()), - None, - false, - false, - )?; - // Startup validation must observe the routed project's terminal open - // failure. The ordinary Doctor helper intentionally falls back to a cold - // snapshot on daemon errors, which is useful for diagnostics but would - // conceal a cached non-retryable warm-up failure here. - // Cold-open admission under heavy load can exceed a tight 10s bound; keep it - // generous (still capped by the outer startup deadline) so warm-up isn't - // misreported as a terminal admission failure. - let admission_deadline = - (tokio::time::Instant::now() + std::time::Duration::from_secs(90)).min(startup_deadline); - let admission = crate::daemon::call_default_tool_within( - &handshake, - "tracedecay_status", - daemon_admission_args(), - admission_deadline, - ) - .await; - let admitted = match admission { - Ok(_) => true, - Err(error) if crate::daemon::error_message_is_project_warming(&error.to_string()) => false, - Err(error) => return Err(error), - }; - if report_admission && admitted { - eprintln!( - "Daemon project admitted; waiting for runtime integrity telemetry within the startup deadline." - ); - } - let result = crate::daemon::call_default_tool_within( - &handshake, - "tracedecay_runtime", - if startup_health_only { - daemon_startup_runtime_args() - } else { - daemon_doctor_runtime_args() - }, - startup_deadline, - ) - .await?; - daemon_runtime_status(&result) -} - -pub async fn wait_for_daemon_startup_health( - timeout: std::time::Duration, -) -> crate::errors::Result<()> { - let project_path = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - let startup_deadline = tokio::time::Instant::now() + timeout; - wait_for_daemon_startup_health_with( - timeout, - std::time::Duration::from_millis(500), - || daemon_project_status_with_deadline(&project_path, startup_deadline, true, true), - |progress| { - eprintln!( - "Waiting for daemon startup health convergence: elapsed={}s waiting_on={} change={}", - progress.elapsed.as_secs(), - progress.detail, - progress.change, - ); - }, - ) - .await -} - -#[derive(Debug)] -struct DaemonStartupHealthProgress { - elapsed: std::time::Duration, - detail: String, - change: String, -} - -#[derive(Debug)] -enum DaemonStartupHealthOutcome { - Ready, - Retryable { - detail: String, - }, - Terminal { - error: crate::errors::TraceDecayError, - }, - DeadlineExceeded { - timeout: std::time::Duration, - last_detail: String, - }, -} - -async fn wait_for_daemon_startup_health_with( - timeout: std::time::Duration, - poll_interval: std::time::Duration, - mut probe: Probe, - mut progress: Progress, -) -> crate::errors::Result<()> -where - Probe: FnMut() -> ProbeFuture, - ProbeFuture: std::future::Future>, - Progress: FnMut(DaemonStartupHealthProgress), -{ - let started = std::time::Instant::now(); - let deadline = started + timeout; - let mut last_detail = None; - let mut last_report = started - .checked_sub(std::time::Duration::from_secs(20)) - .unwrap_or(started); - loop { - let detail = match classify_daemon_startup_health_result(probe().await) { - DaemonStartupHealthOutcome::Ready => return Ok(()), - DaemonStartupHealthOutcome::Retryable { detail } => detail, - DaemonStartupHealthOutcome::Terminal { error } => return Err(error), - deadline @ DaemonStartupHealthOutcome::DeadlineExceeded { .. } => { - return Err(daemon_startup_health_failure(deadline)); - } - }; - let now = std::time::Instant::now(); - let changed = last_detail.as_deref() != Some(detail.as_str()); - if changed || now.duration_since(last_report) >= std::time::Duration::from_secs(20) { - let change = match last_detail.as_deref() { - None => "initial observation".to_string(), - Some(previous) if previous != detail => format!("changed from {previous}"), - Some(_) => "no change since previous poll".to_string(), - }; - progress(DaemonStartupHealthProgress { - elapsed: now.duration_since(started), - detail: detail.clone(), - change, - }); - last_report = now; - } - last_detail = Some(detail); - if now >= deadline { - let outcome = DaemonStartupHealthOutcome::DeadlineExceeded { - timeout, - last_detail: last_detail.unwrap_or_else(|| "no health response".to_string()), - }; - return Err(daemon_startup_health_failure(outcome)); - } - tokio::time::sleep(poll_interval.min(deadline.saturating_duration_since(now))).await; - } -} - -fn classify_daemon_startup_health_result( - result: crate::errors::Result, -) -> DaemonStartupHealthOutcome { - match result { - Ok(status) if daemon_startup_health_ready(&status) => DaemonStartupHealthOutcome::Ready, - Ok(status) => match daemon_startup_terminal_status_error(&status) { - Some(error) => DaemonStartupHealthOutcome::Terminal { error }, - None => DaemonStartupHealthOutcome::Retryable { - detail: daemon_startup_health_detail(&status), - }, - }, - Err(error) if daemon_startup_error_is_retryable(&error) => { - DaemonStartupHealthOutcome::Retryable { - detail: error.to_string(), - } - } - Err(error) => { - let detail = error.to_string(); - let error = if daemon_health_reports_sqlite_corruption(&detail) { - daemon_startup_corruption_error(&detail, None) - } else { - error - }; - DaemonStartupHealthOutcome::Terminal { error } - } - } -} - -fn daemon_startup_health_failure( - outcome: DaemonStartupHealthOutcome, -) -> crate::errors::TraceDecayError { - match outcome { - DaemonStartupHealthOutcome::Terminal { error } => error, - DaemonStartupHealthOutcome::DeadlineExceeded { - timeout, - last_detail, - } => crate::errors::TraceDecayError::Config { - message: format!( - "daemon startup health deadline-exceeded after {}s before Doctor validation; last retryable state: {last_detail}", - timeout.as_secs(), - ), - }, - DaemonStartupHealthOutcome::Ready | DaemonStartupHealthOutcome::Retryable { .. } => { - crate::errors::TraceDecayError::Config { - message: "daemon startup health failure was not terminal".to_string(), - } - } - } -} - -fn daemon_startup_terminal_status_error( - status: &serde_json::Value, -) -> Option { - let storage = status.get("storage_health")?; - let quick_check_ok = storage - .get("quick_check_ok") - .and_then(serde_json::Value::as_bool); - let quick_check_error = storage - .get("quick_check_error") - .and_then(serde_json::Value::as_str); - if quick_check_ok == Some(false) - || quick_check_error.is_some_and(daemon_health_reports_sqlite_corruption) - { - let problem = quick_check_error.unwrap_or("SQLite quick_check failed without detail"); - let db_path = storage - .get("canonical_db_path") - .or_else(|| storage.get("db_path")) - .and_then(serde_json::Value::as_str) - .map(Path::new); - return Some(daemon_startup_corruption_error(problem, db_path)); - } - - if storage - .get("authority_audit_ok") - .and_then(serde_json::Value::as_bool) - == Some(false) - { - let reason = storage - .get("authority_audit_reason") - .and_then(serde_json::Value::as_str) - .unwrap_or("authority invariant failed without detail"); - return Some(crate::errors::TraceDecayError::Config { - message: format!( - "terminal daemon startup health failure: observation database authority audit failed: {reason}. Preserve daemon logs and run `tracedecay doctor` with the retained or a newer compatible binary before retrying; do not run an older binary." - ), - }); - } - - None -} - -fn daemon_startup_corruption_error( - problem: &str, - db_path: Option<&Path>, -) -> crate::errors::TraceDecayError { - let remediation = match db_path { - Some(db_path) => database_recovery_guidance_for_problem(db_path, problem), - None if crate::tracedecay::is_fts_only_corruption(problem) => { - "Run `tracedecay daemon restart` with the retained or a newer compatible binary so the sole-writer open path can rebuild `nodes_fts`; then run `tracedecay tool runtime` and `tracedecay doctor`. Do not run an older binary or delete the database.".to_string() - } - None => "Stop all TraceDecay processes and preserve the database, WAL, and SHM together before attempting repair. Do not run an older binary, `tracedecay init`, `tracedecay sync --force`, or `tracedecay wipe`.".to_string(), - }; - crate::errors::TraceDecayError::Config { - message: format!( - "terminal daemon startup health failure: {problem}\nRemediation: {remediation}" - ), - } -} - -fn daemon_health_reports_sqlite_corruption(detail: &str) -> bool { - let detail = detail.to_ascii_lowercase(); - detail.contains("sqlite_corrupt") - || detail.contains("database disk image is malformed") - || detail.contains("malformed database image") - || detail.contains("file is not a database") - || detail.contains("fts5: corruption found") - || detail.contains("malformed inverted index for fts5") - || detail.contains("database corruption") - || detail.contains("database is corrupt") -} - -fn daemon_startup_error_is_retryable(error: &crate::errors::TraceDecayError) -> bool { - match error { - crate::errors::TraceDecayError::Io(error) => matches!( - error.kind(), - std::io::ErrorKind::NotFound - | std::io::ErrorKind::ConnectionRefused - | std::io::ErrorKind::ConnectionReset - | std::io::ErrorKind::BrokenPipe - | std::io::ErrorKind::TimedOut - | std::io::ErrorKind::WouldBlock - ), - crate::errors::TraceDecayError::Config { message } => { - (message.contains("daemon socket") && message.contains("not available")) - || message.contains("still warming up") - || crate::daemon::error_message_is_project_warming(message) - || message.contains("restart grace") - || crate::daemon::error_message_is_read_deadline(message) - || message.contains(RUNTIME_TELEMETRY_PENDING) - } - crate::errors::TraceDecayError::ProjectRoute { retryable, .. } => *retryable, - crate::errors::TraceDecayError::Automation(error) => { - tracedecay_automation::backend::classify_agent_task_error_message(&error.to_string()) - .is_retryable() - } - crate::errors::TraceDecayError::ResetRequired { .. } - | crate::errors::TraceDecayError::File { .. } - | crate::errors::TraceDecayError::Parse { .. } - | crate::errors::TraceDecayError::Database { .. } - | crate::errors::TraceDecayError::Search { .. } - | crate::errors::TraceDecayError::HostCliUnavailable { .. } - | crate::errors::TraceDecayError::ProfileResetRequired { .. } - | crate::errors::TraceDecayError::SyncLock { .. } - | crate::errors::TraceDecayError::Sqlite(_) - | crate::errors::TraceDecayError::Json(_) => false, - } -} - -fn daemon_startup_health_detail(status: &serde_json::Value) -> String { - let storage = status.get("storage_health"); - let quick = if storage - .and_then(|storage| storage.get("quick_check_ok")) - .and_then(serde_json::Value::as_bool) - == Some(true) - { - "ok" - } else { - storage - .and_then(|storage| storage.get("quick_check_error")) - .and_then(serde_json::Value::as_str) - .unwrap_or("quick_check_pending") - }; - format!("storage={quick}") -} - -fn daemon_startup_health_ready(status: &serde_json::Value) -> bool { - let Some(storage) = status - .get("storage_health") - .and_then(serde_json::Value::as_object) - else { - return false; - }; - let mounted = storage - .get("canonical_db_path") - .and_then(serde_json::Value::as_str) - .is_some() - && storage - .get("daemon_owner_pid") - .and_then(serde_json::Value::as_u64) - .is_some() - && storage - .get("daemon_version") - .and_then(serde_json::Value::as_str) - .is_some(); - let integrity_failed = storage - .get("quick_check_ok") - .and_then(serde_json::Value::as_bool) - == Some(false) - || storage - .get("quick_check_error") - .and_then(serde_json::Value::as_str) - .is_some() - || storage - .get("authority_audit_ok") - .and_then(serde_json::Value::as_bool) - == Some(false); - mounted && !integrity_failed -} - -fn daemon_admission_args() -> serde_json::Value { - serde_json::json!({ - "format": "json", - "admission_only": true, - "include_branch_diagnostics": false, - "include_storage_health": false, - "include_session_ingest": false, - "include_staleness": false, - }) -} - -fn daemon_startup_runtime_args() -> serde_json::Value { - serde_json::json!({ - "format": "json", - "startup_health": true, - "authority_audit": false, - "doctor_report": false, - "session_ingest_health": false, - }) -} - fn daemon_doctor_runtime_args() -> serde_json::Value { serde_json::json!({ "format": "json", @@ -861,20 +483,6 @@ fn database_recovery_guidance(db_path: &Path) -> String { ) } -fn database_recovery_guidance_for_problem(db_path: &Path, problem: &str) -> String { - if !crate::tracedecay::is_fts_only_corruption(problem) { - return database_recovery_guidance(db_path); - } - - format!( - "The failure is confined to the derived `nodes_fts` index at {}; the authoritative `nodes` table and graph-resident facts must be preserved.\n\ - Do not run `tracedecay init`, `tracedecay sync --force`, or `tracedecay wipe`, and do not delete the database.\n\ - Once no sync is active, run `tracedecay daemon restart` with the retained or a newer compatible binary. Its sole-writer open path will rebuild it from the authoritative `nodes` table before serving requests.\n\ - Then rerun `tracedecay tool runtime` and `tracedecay doctor`; if quick_check still fails, preserve the DB/WAL/SHM/dirty recovery set and follow the offline recovery guidance.", - db_path.display(), - ) -} - fn print_database_recovery_guidance(dc: &DoctorCounters, db_path: &Path) { for line in database_recovery_guidance(db_path).lines() { dc.info(line); diff --git a/src/doctor/tests.rs b/src/doctor/tests.rs index 2dc7d9fdff..b8e6a677a2 100644 --- a/src/doctor/tests.rs +++ b/src/doctor/tests.rs @@ -187,20 +187,6 @@ fn daemon_runtime_parser_extracts_storage_health_and_owner() { ); } -#[test] -fn daemon_runtime_request_keeps_startup_probe_bounded() { - assert_eq!( - super::daemon_startup_runtime_args(), - serde_json::json!({ - "format": "json", - "startup_health": true, - "authority_audit": false, - "doctor_report": false, - "session_ingest_health": false, - }) - ); -} - #[test] fn daemon_doctor_request_uses_comprehensive_ready_owner() { assert_eq!( @@ -489,445 +475,3 @@ fn doctor_result_treats_unavailable_canonical_report_as_unknown() { ) .unwrap(); } - -#[test] -fn daemon_startup_health_gates_only_current_project_storage() { - let healthy = serde_json::json!({ - "storage_health": { - "canonical_db_path": "/profile/project.db", - "daemon_owner_pid": 1234, - "daemon_version": "0.0.67+test", - "quick_check_ok": true, - "quick_check_error": null - }, - "session_temporal_health": { - "status": "unavailable", - "reason": "compatibility_drift", - "findings": [{ - "kind": "compatibility_drift", - "count": 1 - }] - } - }); - assert!( - super::daemon_startup_health_ready(&healthy), - "unrelated session-temporal findings must remain Doctor findings, not block current-project admission" - ); - assert_eq!(super::daemon_startup_health_detail(&healthy), "storage=ok"); - - let bounded_probe = serde_json::json!({ - "storage_health": { - "canonical_db_path": "/profile/project.db", - "daemon_owner_pid": 1234, - "daemon_version": "0.0.67+test", - "quick_check_ok": null, - "quick_check_error": null, - "authority_audit_ok": null - } - }); - assert!( - super::daemon_startup_health_ready(&bounded_probe), - "mounted daemon telemetry is operationally ready while exhaustive integrity audits remain pending" - ); - assert_eq!( - super::daemon_startup_health_detail(&bounded_probe), - "storage=quick_check_pending" - ); - - let migrating = serde_json::json!({ - "storage_health": { - "canonical_db_path": "/profile/project.db", - "daemon_owner_pid": 1234, - "daemon_version": "0.0.67+test", - "quick_check_error": "project_store_schema_unsupported" - } - }); - assert!(!super::daemon_startup_health_ready(&migrating)); -} - -#[test] -fn daemon_startup_health_requires_complete_mounted_daemon_identity() { - let ready = serde_json::json!({ - "storage_health": { - "canonical_db_path": "/profile/project.db", - "daemon_owner_pid": 1234, - "daemon_version": "0.0.67+test", - "quick_check_ok": true - } - }); - assert!(super::daemon_startup_health_ready(&ready)); - - for required_field in ["canonical_db_path", "daemon_owner_pid", "daemon_version"] { - let mut incomplete = ready.clone(); - incomplete["storage_health"] - .as_object_mut() - .expect("storage health object") - .remove(required_field); - assert!( - !super::daemon_startup_health_ready(&incomplete), - "startup health must remain pending without {required_field}" - ); - } -} - -#[test] -fn daemon_startup_probe_skips_all_expensive_status_reads() { - assert_eq!( - super::daemon_admission_args(), - serde_json::json!({ - "format": "json", - "admission_only": true, - "include_branch_diagnostics": false, - "include_storage_health": false, - "include_session_ingest": false, - "include_staleness": false, - }) - ); -} - -#[test] -fn daemon_startup_pending_runtime_telemetry_is_retryable() { - let error = super::daemon_runtime_status(&serde_json::json!({ - "content": [{"type": "text", "text": r#"{"process":{"pid":1234}}"#}] - })) - .unwrap_err(); - assert!( - super::daemon_startup_error_is_retryable(&error), - "an admitted project that has not published telemetry yet must be polled, not failed: {error}" - ); - assert!(matches!( - super::classify_daemon_startup_health_result(Err(error)), - super::DaemonStartupHealthOutcome::Retryable { .. } - )); -} - -#[test] -fn daemon_startup_malformed_runtime_telemetry_stays_terminal() { - let error = super::daemon_runtime_status(&serde_json::json!({ - "content": [{"type": "text", "text": r#"{"database":"not-an-object"}"#}] - })) - .unwrap_err(); - assert!( - !super::daemon_startup_error_is_retryable(&error), - "telemetry that is present but malformed is a contract violation: {error}" - ); -} - -#[test] -fn daemon_startup_reset_requirement_is_terminal() { - let error = crate::errors::TraceDecayError::reset_required( - "session relation authority", - "legacy session relation authority requires explicit reset", - ); - - assert!(!super::daemon_startup_error_is_retryable(&error)); -} - -#[test] -fn daemon_startup_host_cli_requirement_is_terminal() { - let error = crate::errors::TraceDecayError::HostCliUnavailable { - program: "kiro-cli".to_string(), - lifecycle: "kiro MCP registry lifecycle".to_string(), - }; - - assert!(!super::daemon_startup_error_is_retryable(&error)); - assert!(matches!( - super::classify_daemon_startup_health_result(Err(error)), - super::DaemonStartupHealthOutcome::Terminal { - error: crate::errors::TraceDecayError::HostCliUnavailable { program, lifecycle }, - } if program == "kiro-cli" && lifecycle == "kiro MCP registry lifecycle" - )); -} - -#[tokio::test] -async fn daemon_startup_health_converges_after_runtime_telemetry_appears() { - let attempts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let probe_attempts = std::sync::Arc::clone(&attempts); - super::wait_for_daemon_startup_health_with( - std::time::Duration::from_secs(30), - std::time::Duration::from_millis(1), - move || { - let attempt = probe_attempts.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - async move { - if attempt < 3 { - return super::daemon_runtime_status(&serde_json::json!({ - "content": [{"type": "text", "text": r#"{"process":{"pid":1234}}"#}] - })); - } - Ok(serde_json::json!({ - "storage_health": { - "canonical_db_path": "/profile/project.db", - "daemon_owner_pid": 1234, - "daemon_version": "0.0.67+test", - "quick_check_ok": true - } - })) - } - }, - |_| {}, - ) - .await - .expect("startup health must converge once the warming project publishes telemetry"); - assert!( - attempts.load(std::sync::atomic::Ordering::Relaxed) >= 4, - "the warming responses must have been polled before convergence" - ); -} - -#[test] -fn daemon_startup_background_warmup_is_retryable() { - let error = crate::errors::TraceDecayError::Config { - message: "TraceDecay project '/fast/projects/tracedecay' is warming in the background; retry the same tool shortly".to_owned(), - }; - assert!(super::daemon_startup_error_is_retryable(&error)); -} - -#[test] -fn daemon_startup_project_route_uses_typed_retryability() { - let retryable = crate::errors::TraceDecayError::project_route( - "project_route_unavailable", - true, - "project registry is warming", - ); - assert!(super::daemon_startup_error_is_retryable(&retryable)); - assert!(matches!( - super::classify_daemon_startup_health_result(Err(retryable)), - super::DaemonStartupHealthOutcome::Retryable { detail } - if detail.contains("project_route_unavailable") - && detail.contains("project registry is warming") - )); - - let terminal = crate::errors::TraceDecayError::project_route( - "project_route_not_authorized", - false, - "project route is outside the admitted profile", - ); - assert!(!super::daemon_startup_error_is_retryable(&terminal)); - assert!(matches!( - super::classify_daemon_startup_health_result(Err(terminal)), - super::DaemonStartupHealthOutcome::Terminal { - error: crate::errors::TraceDecayError::ProjectRoute { - reason_code, - retryable: false, - detail, - }, - } if reason_code == "project_route_not_authorized" - && detail == "project route is outside the admitted profile" - )); -} - -#[tokio::test] -async fn daemon_startup_health_surfaces_terminal_project_open_failure_immediately() { - let attempts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let probe_attempts = std::sync::Arc::clone(&attempts); - let error = super::wait_for_daemon_startup_health_with( - std::time::Duration::from_secs(30), - std::time::Duration::from_millis(1), - move || { - probe_attempts.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - async { - Err(crate::errors::TraceDecayError::Config { - message: "project-open source access denied: project-open source binding authority is inconsistent with the application contract".to_owned(), - }) - } - }, - |_| {}, - ) - .await - .expect_err("terminal project-open error must fail the health wait"); - - assert!( - error - .to_string() - .contains("project-open source binding authority"), - "underlying terminal error must be preserved: {error}" - ); - assert_eq!( - attempts.load(std::sync::atomic::Ordering::Relaxed), - 1, - "terminal failure must not be polled until the deadline" - ); - assert!(super::daemon_startup_error_is_retryable( - &crate::errors::TraceDecayError::Config { - message: "daemon tracedecay_runtime timed out during read before deadline".to_owned(), - } - )); -} - -#[tokio::test] -async fn daemon_startup_health_surfaces_terminal_corruption_immediately() { - let attempts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let probe_attempts = std::sync::Arc::clone(&attempts); - let corrupt = serde_json::json!({ - "storage_health": { - "quick_check_ok": false, - "quick_check_error": - "fts5: corruption found reading blob 412316860480 from table \"nodes_fts\"", - "authority_audit_ok": true, - "canonical_db_path": "/isolated/profile/projects/proj_test/tracedecay.db" - } - }); - let wait = super::wait_for_daemon_startup_health_with( - std::time::Duration::from_secs(30), - std::time::Duration::from_millis(1), - move || { - probe_attempts.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let status = corrupt.clone(); - async move { Ok(status) } - }, - |_| {}, - ); - let error = tokio::time::timeout(std::time::Duration::from_millis(100), wait) - .await - .expect("terminal corruption must not keep polling") - .expect_err("terminal corruption must fail startup health validation"); - let message = error.to_string(); - - assert!(message.contains("terminal daemon startup health failure")); - assert!( - message - .contains("fts5: corruption found reading blob 412316860480 from table \"nodes_fts\"") - ); - assert!(message.contains("tracedecay daemon restart")); - assert!(message.contains("tracedecay tool runtime")); - assert!(message.contains("tracedecay doctor")); - assert_eq!( - attempts.load(std::sync::atomic::Ordering::Relaxed), - 1, - "terminal corruption must not be retried" - ); -} - -#[test] -fn daemon_startup_health_classifies_sqlite_corruption_spellings_as_terminal() { - for problem in [ - "SQLITE_CORRUPT: database page failed validation", - "database disk image is malformed", - "malformed database image", - "file is not a database", - ] { - let outcome = super::classify_daemon_startup_health_result(Ok(serde_json::json!({ - "storage_health": { - "quick_check_error": problem, - "authority_audit_reason": "authority_audit_not_run" - } - }))); - assert!( - matches!(outcome, super::DaemonStartupHealthOutcome::Terminal { .. }), - "{problem:?} must be terminal" - ); - } -} - -#[test] -fn startup_runtime_probe_defers_exhaustive_audits() { - let args = super::daemon_startup_runtime_args(); - - assert_eq!(args["startup_health"], serde_json::json!(true)); - assert_eq!(args["authority_audit"], serde_json::json!(false)); - assert_eq!(args["doctor_report"], serde_json::json!(false)); - assert_eq!(args["session_ingest_health"], serde_json::json!(false)); -} - -#[test] -fn daemon_startup_health_preserves_corruption_error_and_adds_remediation() { - let problem = "fts5: corruption found reading blob 412316860480 from table \"nodes_fts\""; - let outcome = - super::classify_daemon_startup_health_result(Err(crate::errors::TraceDecayError::Config { - message: problem.to_string(), - })); - let super::DaemonStartupHealthOutcome::Terminal { error } = outcome else { - panic!("corruption error must be terminal"); - }; - let message = error.to_string(); - assert!(message.contains(problem)); - assert!(message.contains("terminal daemon startup health failure")); - assert!(message.contains("tracedecay daemon restart")); -} - -#[tokio::test] -async fn daemon_startup_health_retryable_progress_changes_then_converges() { - let attempts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let probe_attempts = std::sync::Arc::clone(&attempts); - let reports = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); - let progress_reports = std::sync::Arc::clone(&reports); - super::wait_for_daemon_startup_health_with( - std::time::Duration::from_secs(1), - std::time::Duration::from_millis(1), - move || { - let attempt = probe_attempts.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - async move { - Ok(match attempt { - 0 => serde_json::json!({ - "storage_health": { - "quick_check_error": "project_store_schema_unsupported", - "authority_audit_reason": "authority_audit_not_run" - } - }), - 1 => serde_json::json!({ - "storage_health": { - "quick_check_error": "project_store_migration_in_progress" - } - }), - _ => serde_json::json!({ - "storage_health": { - "canonical_db_path": "/profile/project.db", - "daemon_owner_pid": 1234, - "daemon_version": "0.0.67+test", - "quick_check_ok": true - } - }), - }) - } - }, - move |progress| { - progress_reports.lock().unwrap().push(progress); - }, - ) - .await - .expect("retryable startup health must converge"); - - assert_eq!( - attempts.load(std::sync::atomic::Ordering::Relaxed), - 3, - "retryable health must continue polling until ready" - ); - let reports = reports.lock().unwrap(); - assert_eq!(reports.len(), 2); - assert_eq!(reports[0].change, "initial observation"); - assert!(reports[1].change.starts_with("changed from ")); - assert!( - reports[0] - .detail - .contains("project_store_schema_unsupported") - ); - assert!( - reports[1] - .detail - .contains("project_store_migration_in_progress") - ); -} - -#[tokio::test] -async fn daemon_startup_health_deadline_is_distinct_from_terminal_failure() { - let error = super::wait_for_daemon_startup_health_with( - std::time::Duration::ZERO, - std::time::Duration::from_millis(1), - || async { - Ok(serde_json::json!({ - "storage_health": { - "quick_check_error": "project_store_schema_unsupported", - "authority_audit_reason": "authority_audit_not_run" - } - })) - }, - |_| {}, - ) - .await - .expect_err("retryable health must fail when its deadline expires"); - - let message = error.to_string(); - assert!(message.contains("deadline-exceeded")); - assert!(message.contains("project_store_schema_unsupported")); - assert!(!message.contains("terminal daemon startup health failure")); -} diff --git a/src/extraction_worker.rs b/src/extraction_worker.rs deleted file mode 100644 index 6b2a5ea089..0000000000 --- a/src/extraction_worker.rs +++ /dev/null @@ -1,566 +0,0 @@ -//! Subprocess-isolated extraction. -//! -//! Tree-sitter grammars compiled from C/C++ can `abort()` on internal -//! assertions, segfault, or otherwise terminate the process by paths that -//! `catch_unwind` cannot intercept. To keep `tracedecay sync` resilient, -//! extraction is delegated to short-lived worker subprocesses; if a worker -//! dies, only the in-flight file is lost and the pool respawns the worker. -//! -//! ## Trust boundary -//! -//! The worker entry point is a hidden subcommand (`tracedecay extract-worker`) -//! that authenticates via two facts the parent controls: -//! -//! 1. A 32-byte token, freshly generated per `WorkerPool`, passed via the -//! `TRACEDECAY_WORKER_TOKEN` env var (hex-encoded). The worker scrubs the -//! var immediately after reading. -//! 2. The first 32 bytes received on stdin must equal the same token. -//! -//! A user invoking `tracedecay extract-worker` directly hits the missing-env -//! check and exits non-zero. A user who guesses or extracts the env value -//! still cannot reproduce the stdin handshake without being inside the -//! parent's address space — at which point the trust boundary is moot. - -use std::collections::VecDeque; -use std::io::{self, BufReader, BufWriter, Read, Write}; -use std::path::{Path, PathBuf}; -use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; -use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; -use std::sync::{Arc, Mutex}; -use std::time::Duration; - -use serde::{Deserialize, Serialize}; - -use crate::sync; -use crate::types::ExtractionResult; -use tracedecay_code_extraction::LanguageRegistry; - -const TOKEN_LEN: usize = 32; -const TOKEN_ENV_VAR: &str = "TRACEDECAY_WORKER_TOKEN"; - -/// Hidden subcommand name. Kept here (not in main.rs) so the constant is -/// shared between the spawn-side and the dispatch-side. -pub const WORKER_SUBCOMMAND: &str = "extract-worker"; - -#[derive(Serialize, Deserialize)] -struct ExtractRequest { - project_root: PathBuf, - file_path: String, -} - -#[derive(Serialize, Deserialize)] -struct ExtractResponse { - file_path: String, - /// `Some` on success. `None` means the file was unreadable or had no - /// matching extractor — both legitimate outcomes that aren't worth a - /// crash. Extractor panics kill the worker entirely; the pool sees the - /// pipe close and respawns. - data: Option, -} - -#[derive(Serialize, Deserialize)] -struct ExtractData { - result: ExtractionResult, - content_hash: String, - size: u64, - mtime: i64, -} - -fn generate_token() -> io::Result<[u8; TOKEN_LEN]> { - let mut buf = [0u8; TOKEN_LEN]; - getrandom::getrandom(&mut buf) - .map_err(|e| io::Error::other(format!("getrandom failed: {e}")))?; - Ok(buf) -} - -// ============================================================================= -// Worker side — runs inside the spawned child -// ============================================================================= - -/// Worker entry point. Never returns; calls `process::exit`. -pub fn run_worker() -> ! { - let code = match worker_main() { - Ok(()) => 0, - Err(e) => { - eprintln!("[tracedecay-worker] {e}"); - 1 - } - }; - std::process::exit(code); -} - -fn worker_main() -> io::Result<()> { - let token_hex = std::env::var(TOKEN_ENV_VAR).map_err(|_| { - io::Error::other("worker token not set; cannot run extract-worker directly") - })?; - // Scrub immediately so a child of a child cannot inherit it. - unsafe { - std::env::remove_var(TOKEN_ENV_VAR); - } - let expected = - hex::decode(token_hex.trim()).map_err(|_| io::Error::other("worker token malformed"))?; - if expected.len() != TOKEN_LEN { - return Err(io::Error::other("worker token wrong length")); - } - - let stdin = io::stdin(); - let stdout = io::stdout(); - let mut reader = BufReader::new(stdin.lock()); - let mut writer = BufWriter::new(stdout.lock()); - - let mut received = [0u8; TOKEN_LEN]; - reader.read_exact(&mut received)?; - // Constant-time-ish comparison; the token isn't a long-term secret but - // there's no reason to leak timing. - if !slices_eq(&received, &expected) { - return Err(io::Error::other("worker token mismatch")); - } - - let registry = LanguageRegistry::new(); - loop { - let req: ExtractRequest = match read_message(&mut reader) { - Ok(req) => req, - Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(()), - Err(e) => return Err(e), - }; - let resp = process_request(®istry, &req); - write_message(&mut writer, &resp)?; - writer.flush()?; - } -} - -fn process_request(registry: &LanguageRegistry, req: &ExtractRequest) -> ExtractResponse { - let abs_path = req.project_root.join(&req.file_path); - let Ok(source) = sync::read_source_file(&abs_path) else { - return ExtractResponse { - file_path: req.file_path.clone(), - data: None, - }; - }; - let Some(extractor) = registry.extractor_for_file(&req.file_path) else { - return ExtractResponse { - file_path: req.file_path.clone(), - data: None, - }; - }; - - let mut result = extractor.extract(&req.file_path, &source); - result.sanitize(); - let content_hash = sync::content_hash(&source); - let size = source.len() as u64; - let mtime = - sync::file_stat(&abs_path).map_or_else(crate::tracedecay::current_timestamp, |(m, _)| m); - - ExtractResponse { - file_path: req.file_path.clone(), - data: Some(ExtractData { - result, - content_hash, - size, - mtime, - }), - } -} - -// ============================================================================= -// Pool side — runs inside the parent -// ============================================================================= - -/// One result tuple. Matches the shape the existing extraction sites in -/// `tracedecay.rs` expect from their rayon closures. -pub type ExtractTuple = (String, ExtractionResult, String, u64, i64); - -pub struct WorkerPool { - workers: Vec, - self_path: PathBuf, - project_root: PathBuf, - token: [u8; TOKEN_LEN], -} - -struct WorkerHandle { - /// `None` once the handle is being dropped: closing the pipe is what - /// signals the worker to exit, and we have to do it before `wait()`. - stdin: Option>, - stdout: BufReader, - child: Child, -} - -impl Drop for WorkerHandle { - fn drop(&mut self) { - // Close the worker's stdin first; otherwise it sits in `read_exact` - // forever and `wait()` deadlocks waiting for it to exit. - drop(self.stdin.take()); - let _ = self.child.wait(); - } -} - -impl WorkerPool { - /// Spawn `num_workers` worker processes. Each gets the same token; the - /// token is generated once per pool. - pub fn new(num_workers: usize, project_root: PathBuf) -> io::Result { - let self_path = std::env::current_exe()?; - let token = generate_token()?; - let mut workers = Vec::with_capacity(num_workers); - for _ in 0..num_workers { - workers.push(spawn_worker(&self_path, &token)?); - } - Ok(Self { - workers, - self_path, - project_root, - token, - }) - } - - /// Process every entry in `files`, calling `on_progress(n, total, path)` - /// once per file. Returns one tuple per successfully-processed file; - /// files whose worker crashed or that had no extractor / read error are - /// silently skipped (logged to stderr). - pub fn extract_files( - self, - files: Vec, - on_progress: F, - per_file_timeout: Duration, - ) -> ExtractFilesOutcome - where - F: Fn(usize, usize, &str) + Send + Sync + 'static, - { - let total = files.len(); - let queue: Arc>> = Arc::new(Mutex::new(files.into_iter().collect())); - let results: Arc>> = - Arc::new(Mutex::new(Vec::with_capacity(total))); - let skipped: Arc>> = Arc::new(Mutex::new(Vec::new())); - let progress_count = Arc::new(AtomicUsize::new(0)); - let on_progress = Arc::new(on_progress); - - let handles: Vec<_> = self - .workers - .into_iter() - .map(|worker| { - let queue = queue.clone(); - let results = results.clone(); - let skipped = skipped.clone(); - let progress_count = progress_count.clone(); - let on_progress = on_progress.clone(); - let project_root = self.project_root.clone(); - let self_path = self.self_path.clone(); - let token = self.token; - - std::thread::spawn(move || { - worker_thread( - worker, - WorkerThreadContext { - queue, - results, - skipped, - progress_count, - on_progress, - project_root, - self_path, - token, - total, - per_file_timeout, - }, - ); - }) - }) - .collect(); - - for h in handles { - let _ = h.join(); - } - - // All worker threads have joined, so we hold the only Arc strong - // reference. `into_inner` returns `Some` in that case; if it ever - // returns `None` (concurrent leak), prefer an empty result over a - // panic — the sync continues and the user just sees zero changes. - let results = Arc::into_inner(results) - .and_then(|m| m.into_inner().ok()) - .unwrap_or_default(); - let skipped = Arc::into_inner(skipped) - .and_then(|m| m.into_inner().ok()) - .unwrap_or_default(); - ExtractFilesOutcome { results, skipped } - } -} - -/// Result of [`WorkerPool::extract_files`]. -#[derive(Debug, Default)] -pub struct ExtractFilesOutcome { - /// Successfully-extracted files. - pub results: Vec, - /// Files where extraction timed out or repeatedly crashed. Reported as - /// `(path, reason)` so callers can surface them in `SyncResult.skipped_paths`. - pub skipped: Vec<(String, String)>, -} - -struct WorkerThreadContext { - queue: Arc>>, - results: Arc>>, - skipped: Arc>>, - progress_count: Arc, - on_progress: Arc, - project_root: PathBuf, - self_path: PathBuf, - token: [u8; TOKEN_LEN], - total: usize, - per_file_timeout: Duration, -} - -fn worker_thread(mut worker: WorkerHandle, context: WorkerThreadContext) -where - F: Fn(usize, usize, &str) + Send + Sync, -{ - let WorkerThreadContext { - queue, - results, - skipped, - progress_count, - on_progress, - project_root, - self_path, - token, - total, - per_file_timeout, - } = context; - loop { - let next = queue - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .pop_front(); - let Some(file_path) = next else { - break; - }; - - let req = ExtractRequest { - project_root: project_root.clone(), - file_path: file_path.clone(), - }; - - let outcome = round_trip_with_timeout(&mut worker, &req, per_file_timeout); - let n = progress_count.fetch_add(1, Ordering::Relaxed) + 1; - on_progress(n, total, &file_path); - - match outcome { - RoundTripOutcome::Ok(resp) => { - if let Some(data) = resp.data { - results - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .push(( - resp.file_path, - data.result, - data.content_hash, - data.size, - data.mtime, - )); - } - } - RoundTripOutcome::Timeout => { - eprintln!( - "[tracedecay] extractor timed out on {file_path} after {}s; skipping", - per_file_timeout.as_secs() - ); - skipped - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .push(( - file_path, - format!("extractor timed out (>{}s)", per_file_timeout.as_secs()), - )); - // The worker subprocess was killed by the watchdog. Respawn so - // the next file gets a fresh process. - match spawn_worker(&self_path, &token) { - Ok(new_worker) => worker = new_worker, - Err(e) => { - eprintln!( - "[tracedecay] failed to respawn worker after timeout: {e}; \ - this thread is giving up, remaining workers continue" - ); - return; - } - } - } - RoundTripOutcome::Err(e) => { - eprintln!("[tracedecay] extraction worker crashed on {file_path}: {e}, respawning"); - skipped - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .push((file_path, format!("extractor crashed ({e})"))); - // Old `worker` is dropped here, reaping the dead child. - match spawn_worker(&self_path, &token) { - Ok(new_worker) => worker = new_worker, - Err(e) => { - eprintln!( - "[tracedecay] failed to respawn worker after crash: {e}; \ - this thread is giving up, remaining workers continue" - ); - return; - } - } - } - } - } -} - -/// Outcome of a single round trip. Distinguishes graceful timeout from a -/// worker crash so the caller can surface the right reason in `skipped_paths`. -enum RoundTripOutcome { - Ok(ExtractResponse), - Timeout, - Err(io::Error), -} - -/// Sends one extract request and reads the response, killing the worker -/// process and returning [`RoundTripOutcome::Timeout`] if the read takes -/// longer than `timeout`. The worker's child handle is `kill()`ed in place -/// to unblock the read; the caller is expected to `spawn_worker` a fresh -/// subprocess after either a timeout or a crash. -fn round_trip_with_timeout( - worker: &mut WorkerHandle, - req: &ExtractRequest, - timeout: Duration, -) -> RoundTripOutcome { - let Some(stdin) = worker.stdin.as_mut() else { - return RoundTripOutcome::Err(io::Error::other("worker stdin already closed")); - }; - if let Err(e) = write_message(stdin, req).and_then(|()| stdin.flush()) { - return RoundTripOutcome::Err(e); - } - - // Split-borrow `stdout` (owned by the read thread) and `child` (owned by - // the watchdog thread). Rust allows this because they're disjoint fields - // of `*worker`. - let WorkerHandle { - ref mut stdout, - ref mut child, - .. - } = *worker; - - let timed_out = AtomicBool::new(false); - let (cancel_tx, cancel_rx) = std::sync::mpsc::channel::<()>(); - - let read_result: io::Result = std::thread::scope(|s| { - // `move` the Receiver into the watchdog so it owns it (Receiver - // is `Send` but not `Sync`). `&timed_out` and `&mut *child` are - // borrowed from the outer scope under `'scope`. - let timed_out = &timed_out; - s.spawn(move || { - // Watchdog: if the read doesn't finish in `timeout`, kill the - // child so the read returns EOF and unblocks. The kill failing - // (child already exited) is fine — we just won't have a clean - // way to distinguish "crashed at exactly the wrong moment" from - // "timed out", and that's OK; both cases get respawned. - if cancel_rx.recv_timeout(timeout).is_err() { - timed_out.store(true, Ordering::SeqCst); - let _ = child.kill(); - } - }); - let r = read_message(stdout); - let _ = cancel_tx.send(()); - r - }); - - if timed_out.load(Ordering::SeqCst) { - RoundTripOutcome::Timeout - } else { - match read_result { - Ok(resp) => RoundTripOutcome::Ok(resp), - Err(e) => RoundTripOutcome::Err(e), - } - } -} - -fn spawn_worker(self_path: &Path, token: &[u8; TOKEN_LEN]) -> io::Result { - let token_hex = hex::encode(token); - let mut child = Command::new(self_path) - .arg(WORKER_SUBCOMMAND) - .env(TOKEN_ENV_VAR, token_hex) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::inherit()) - .spawn()?; - let stdin = child - .stdin - .take() - .ok_or_else(|| io::Error::other("stdin unexpectedly None despite Stdio::piped"))?; - let stdout = child - .stdout - .take() - .ok_or_else(|| io::Error::other("stdout unexpectedly None despite Stdio::piped"))?; - let mut stdin = BufWriter::new(stdin); - let stdout = BufReader::new(stdout); - - stdin.write_all(token)?; - stdin.flush()?; - - Ok(WorkerHandle { - stdin: Some(stdin), - stdout, - child, - }) -} - -// ============================================================================= -// Wire format: 4-byte LE length prefix + bincode payload -// ============================================================================= - -fn read_message Deserialize<'de>>(reader: &mut R) -> io::Result { - let mut len_buf = [0u8; 4]; - reader.read_exact(&mut len_buf)?; - let len = u32::from_le_bytes(len_buf) as usize; - let mut buf = vec![0u8; len]; - reader.read_exact(&mut buf)?; - bincode::deserialize(&buf).map_err(io::Error::other) -} - -fn write_message(writer: &mut W, msg: &T) -> io::Result<()> { - let bytes = bincode::serialize(msg).map_err(io::Error::other)?; - let len = - u32::try_from(bytes.len()).map_err(|_| io::Error::other("ipc message exceeds 4 GiB"))?; - writer.write_all(&len.to_le_bytes())?; - writer.write_all(&bytes)?; - Ok(()) -} - -fn slices_eq(a: &[u8], b: &[u8]) -> bool { - if a.len() != b.len() { - return false; - } - let mut acc = 0u8; - for (x, y) in a.iter().zip(b.iter()) { - acc |= x ^ y; - } - acc == 0 -} - -// ============================================================================= -// Tests -// ============================================================================= - -#[cfg(test)] -#[allow(clippy::unwrap_used, clippy::expect_used)] -mod tests { - use super::*; - - #[test] - fn message_round_trips() { - let req = ExtractRequest { - project_root: PathBuf::from("/tmp/x"), - file_path: "src/main.rs".into(), - }; - let mut buf = Vec::new(); - write_message(&mut buf, &req).unwrap(); - let mut cursor = std::io::Cursor::new(buf); - let decoded: ExtractRequest = read_message(&mut cursor).unwrap(); - assert_eq!(decoded.file_path, req.file_path); - assert_eq!(decoded.project_root, req.project_root); - } - - #[test] - fn slices_eq_matches() { - assert!(slices_eq(b"abc", b"abc")); - assert!(!slices_eq(b"abc", b"abd")); - assert!(!slices_eq(b"abc", b"ab")); - } -} diff --git a/src/lib.rs b/src/lib.rs index 72e2e46e91..fec987c79d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -78,7 +78,6 @@ pub mod display; pub mod doctor; pub mod errors; pub mod external_tools; -pub mod extraction_worker; pub mod git; mod git_index_transactions; pub mod git_intelligence; diff --git a/src/main.rs b/src/main.rs index 79c026db7d..b8a8c7aed1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -214,10 +214,6 @@ fn install_daemon_cpu_pool(command: Option<&Commands>) -> tracedecay::errors::Re }) } -fn is_extract_worker(command: Option<&Commands>) -> bool { - matches!(command, Some(Commands::ExtractWorker)) -} - fn main() { let args = std::env::args_os().collect::>(); if let Some(code) = hook_capture_cmd::try_run(&args) { @@ -280,13 +276,6 @@ fn async_main() -> tracedecay::errors::Result<()> { // the host, so which command is running has to be known before anything // is allowed to write there. tracedecay::daemon::install_stderr_tracing(stderr_tracing_default(cli.command.as_ref())); - // Extraction workers are synchronous subprocesses. Dispatch them before - // constructing Tokio: a full sync can spawn many workers, and giving each - // one its own async/blocking pools multiplies thread stacks and allocator - // arenas without doing any async work. - if is_extract_worker(cli.command.as_ref()) { - tracedecay::extraction_worker::run_worker(); - } // Rayon otherwise creates one worker per logical CPU on first use. On // large hosts that pool competes with Tokio, SQLite, and per-index pools, // amplifying worktree warmup into CPU and memory contention. The daemon @@ -573,7 +562,6 @@ impl CommandFamily { | Commands::Workflow { .. } | Commands::Lsp { .. } | Commands::Remote { .. } - | Commands::ExtractWorker | Commands::Dashboard { .. } | Commands::Serve { .. } | Commands::Daemon { .. } => Self::Runtime, @@ -822,7 +810,6 @@ async fn dispatch_runtime_command(command: Commands) -> tracedecay::errors::Resu Commands::Lsp { action } => { lsp_cmd::handle_lsp_action(action).await?; } - Commands::ExtractWorker => unreachable!("extract-worker handled by early dispatch"), Commands::Dashboard { path, host, diff --git a/src/startup_tests.rs b/src/startup_tests.rs index 602db70e5f..08e1d8d54c 100644 --- a/src/startup_tests.rs +++ b/src/startup_tests.rs @@ -4,7 +4,7 @@ use super::{ HostBundleComponentArg, MAX_ASYNC_WORKER_THREADS, MAX_BLOCKING_THREADS, PackageHookAction, ProfileStorageAction, RAYON_NUM_THREADS_ENV, ScoopPackageHookAction, SilentReinstallAction, StderrTracingDefault, async_worker_threads, daemon_cpu_threads_from, is_daemon_run, - is_extract_worker, is_local_install_command, should_skip_agent_install_maintenance, + is_local_install_command, should_skip_agent_install_maintenance, should_skip_startup_maintenance, silent_reinstall_action, stderr_tracing_default, validate_host_bundle_options, }; @@ -217,12 +217,6 @@ fn only_foreground_daemon_installs_the_global_cpu_pool() { assert!(!is_daemon_run(None)); } -#[test] -fn extraction_workers_bypass_the_async_runtime() { - assert!(is_extract_worker(Some(&Commands::ExtractWorker))); - assert!(!is_extract_worker(None)); -} - #[test] fn representative_commands_route_to_their_dispatch_family() { let cases = [ diff --git a/src/tracedecay.rs b/src/tracedecay.rs index c8b445d608..5a7f9fcfca 100644 --- a/src/tracedecay.rs +++ b/src/tracedecay.rs @@ -27,7 +27,7 @@ pub(crate) mod queries; pub use diagnostics::{BranchDiagnostics, TrackedBranchDiagnostic}; pub use lifecycle::MovedStoreAdoption; -pub(crate) use lifecycle::{git_remote_url, is_fts_only_corruption}; +pub(crate) use lifecycle::git_remote_url; /// Central orchestrator that coordinates all subsystems of the code graph. /// diff --git a/src/tracedecay/lifecycle/mod.rs b/src/tracedecay/lifecycle/mod.rs index 8d48625b9e..56bf81e8d5 100644 --- a/src/tracedecay/lifecycle/mod.rs +++ b/src/tracedecay/lifecycle/mod.rs @@ -31,11 +31,9 @@ use super::{TraceDecay, TraceDecayOpenOptions}; mod adoption; mod branches; mod identity; -mod recovery; mod registry; pub use adoption::MovedStoreAdoption; -pub(crate) use recovery::is_fts_only_corruption; pub(crate) use registry::git_remote_url; #[cfg(not(any(test, feature = "test-transport")))] diff --git a/src/tracedecay/lifecycle/recovery.rs b/src/tracedecay/lifecycle/recovery.rs deleted file mode 100644 index 000e61ba35..0000000000 --- a/src/tracedecay/lifecycle/recovery.rs +++ /dev/null @@ -1,10 +0,0 @@ -//! Corruption classification used by Doctor. - -/// Whether a `PRAGMA quick_check` problem row describes damage confined to -/// the retired `SQLite` FTS index. Doctor uses this only to classify persisted -/// damage; project open never repairs or rebuilds the index inline. -pub(crate) fn is_fts_only_corruption(problem: &str) -> bool { - problem.contains("malformed inverted index for FTS5 table main.nodes_fts") - || problem.contains("malformed inverted index for FTS5 table nodes_fts") - || (problem.contains("fts5: corruption found") && problem.contains("nodes_fts")) -} diff --git a/tests/hooks_lsp_suite/extract_worker_test.rs b/tests/hooks_lsp_suite/extract_worker_test.rs deleted file mode 100644 index 00cba84453..0000000000 --- a/tests/hooks_lsp_suite/extract_worker_test.rs +++ /dev/null @@ -1,97 +0,0 @@ -//! Tests that the hidden `extract-worker` subcommand cannot be invoked -//! by users in a way that would let them turn the tracedecay binary into -//! an arbitrary code-execution vector. - -use std::io::Write; -use std::process::{Command, Stdio}; - -fn worker_bin() -> &'static str { - env!("CARGO_BIN_EXE_tracedecay") -} - -#[test] -fn worker_without_token_env_var_exits_nonzero() { - let mut child = Command::new(worker_bin()) - .arg("extract-worker") - .env_remove("TRACEDECAY_WORKER_TOKEN") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .expect("spawn worker"); - drop(child.stdin.take()); - let status = child.wait().expect("wait"); - assert!( - !status.success(), - "worker must reject invocation without TRACEDECAY_WORKER_TOKEN env var" - ); -} - -#[test] -fn worker_with_malformed_token_env_var_exits_nonzero() { - let mut child = Command::new(worker_bin()) - .arg("extract-worker") - .env("TRACEDECAY_WORKER_TOKEN", "not-hex-at-all") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .expect("spawn worker"); - drop(child.stdin.take()); - let status = child.wait().expect("wait"); - assert!(!status.success(), "malformed token must be rejected"); -} - -#[test] -fn worker_with_wrong_length_token_exits_nonzero() { - // 16 hex chars = 8 bytes, but TOKEN_LEN is 32. - let mut child = Command::new(worker_bin()) - .arg("extract-worker") - .env("TRACEDECAY_WORKER_TOKEN", "deadbeefdeadbeef") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .expect("spawn worker"); - drop(child.stdin.take()); - let status = child.wait().expect("wait"); - assert!(!status.success(), "short token must be rejected"); -} - -#[test] -fn worker_with_correct_env_but_wrong_stdin_token_exits_nonzero() { - // Set a valid-format env var, then send wrong bytes on stdin. - // Worker should detect the mismatch and exit non-zero. - let token_hex = "0".repeat(64); // 32 bytes of zeros, hex-encoded - let mut child = Command::new(worker_bin()) - .arg("extract-worker") - .env("TRACEDECAY_WORKER_TOKEN", &token_hex) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .expect("spawn worker"); - { - let stdin = child.stdin.as_mut().expect("piped"); - // 32 bytes of 0xFF — definitely not the expected zeros. - stdin.write_all(&[0xFFu8; 32]).expect("write stdin"); - } - drop(child.stdin.take()); - let status = child.wait().expect("wait"); - assert!(!status.success(), "wrong stdin token must be rejected"); -} - -#[test] -fn extract_worker_subcommand_is_hidden_from_help() { - let output = Command::new(worker_bin()) - .arg("--help") - .output() - .expect("run --help"); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - let combined = format!("{stdout}{stderr}"); - assert!( - !combined.contains("extract-worker"), - "extract-worker must be hidden from --help output, got:\n{combined}" - ); -} diff --git a/tests/hooks_lsp_suite/main.rs b/tests/hooks_lsp_suite/main.rs index f3aa596d64..1637d64392 100644 --- a/tests/hooks_lsp_suite/main.rs +++ b/tests/hooks_lsp_suite/main.rs @@ -1,5 +1,5 @@ -//! Consolidated test suite for hook evaluation, hook branch routing, LSP -//! diagnostics, and extract-worker hardening tests. +//! Consolidated test suite for hook evaluation, hook branch routing, and LSP +//! diagnostics tests. //! //! These tests spawn subprocesses (fake LSP servers, git, the tracedecay //! binary) or mutate process-wide environment variables, so they live in a @@ -12,7 +12,6 @@ #[path = "../common/mod.rs"] mod common; -mod extract_worker_test; #[cfg(feature = "test-transport")] mod hook_branch_routing_test; mod hook_lifecycle_lease_test; diff --git a/vendor/tree-sitter-rust/bindings/rust/lib.rs b/vendor/tree-sitter-rust/bindings/rust/lib.rs index 75ec8d94d9..978f83ea1e 100644 --- a/vendor/tree-sitter-rust/bindings/rust/lib.rs +++ b/vendor/tree-sitter-rust/bindings/rust/lib.rs @@ -14,23 +14,6 @@ unsafe extern "C" { /// The tree-sitter [`LanguageFn`] for the patched Rust grammar. pub const LANGUAGE: LanguageFn = unsafe { LanguageFn::from_raw(tree_sitter_rust) }; -/// Generated node type metadata for the patched grammar. -pub const NODE_TYPES: &str = include_str!( - "../../../../crates/tracedecay-code-extraction/vendor/tree-sitter-rust/src/node-types.json" -); -/// Syntax highlighting query for the patched grammar. -pub const HIGHLIGHTS_QUERY: &str = include_str!( - "../../../../crates/tracedecay-code-extraction/vendor/tree-sitter-rust/queries/highlights.scm" -); -/// Injection query for the patched grammar. -pub const INJECTIONS_QUERY: &str = include_str!( - "../../../../crates/tracedecay-code-extraction/vendor/tree-sitter-rust/queries/injections.scm" -); -/// Symbol tagging query for the patched grammar. -pub const TAGS_QUERY: &str = include_str!( - "../../../../crates/tracedecay-code-extraction/vendor/tree-sitter-rust/queries/tags.scm" -); - #[cfg(test)] mod tests { #[test]