Skip to content

bug(build): tryNativeOrchestrator's catch swallows an unreadable-build-state error from the Rust pipeline #2540

Description

@carlos-alm

Summary

Found while fixing #2418 (native load_file_hashes collapsed a read failure into "no prior build state", silently wiping a healthy graph on a transient SQLITE_BUSY/schema fault). #2418 fixes the Rust loader itself to correctly return an error instead of None in that case, and that error now propagates through NativeDatabase::build_graph's #[napi] wrapper as a thrown napi::Error — up through session.runBuildGraph() and tryNativeOrchestrator(ctx) in src/domain/graph/builder/stages/native-orchestrator.ts, neither of which catch anything.

But pipeline.ts's own call site does:

try {
  const nativeResult = await tryNativeOrchestrator(ctx);
  if (nativeResult === 'early-exit') return;
  if (nativeResult) return nativeResult;
} catch (err) {
  warn(`Native build orchestrator failed, falling back to JS pipeline: ${toErrorMessage(err)}`);
  // ...falls through to the JS pipeline, does NOT re-throw...
}

This unconditionally swallows any error from the native orchestrator — including the one #2418 makes it throw for unreadable prior state — and falls back to the JS pipeline instead of surfacing it. Contrast with the earlier JS-side fast-skip pre-flight a few lines above in the same function, which explicitly checks isUnreadableBuildStateError(err) before deciding whether to re-throw or fall through, specifically because "falling through here would... produce exactly the wipe this error prevents."

Why this isn't just #2418 again

isUnreadableBuildStateError checks e instanceof DbError — a TS class. A Rust-thrown error surfaces at this boundary as a plain napi-generated Error, never a DbError instance, so the existing check can't recognize it even if added to this catch block unchanged. Correctly closing this gap needs either:

  • matching on the error message content (fragile — ties TS logic to Rust's exact wording), or
  • a structured signal from the Rust side (e.g. a distinguishable error code/prefix NativeDatabase::build_graph could set, that TS parses), or
  • some other explicit contract between the two error-reporting layers.

That's a real design decision, not a one-line fix, which is why it's being filed separately rather than folded into #2418's precise, narrow ask (fix the Rust loader itself).

Impact

In practice, whether this matters depends on what the JS-pipeline fallback does next: if it re-runs its own detectChanges/loadFileHashes (already fixed by #2414) against the same corrupted file_hashes table, it may independently throw the correctly-tagged error anyway — meaning the user-visible outcome could still be correct via a different path. This hasn't been verified either way. Worth confirming with a real end-to-end reproduction (a corrupted/locked file_hashes table on a --engine native incremental build going through the full pipeline.ts buildGraph() flow, not just the Rust unit-test level) before scoping a fix.

Suggested approach

Not investigated/scoped here — options include the ones listed above, or restructuring so the native orchestrator's own error surfaces a stable, checkable signal (e.g. a specific Error subclass or .code property set at the napi boundary or immediately after catching nativeDb.buildGraph()'s throw) that both this catch site and the pre-flight's can share.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions