tailwindlabs/tailwindcss · critical

Failed to read file

Error message

Failed to read file

What it means

Panics (via .expect) in the napi binding get_candidates_with_positions when ChangedContent.file is set but std::fs::read_to_string fails to read it (file missing, unreadable, or a permission error). This is the Node-facing wrapper around the Rust scanner; when content is None it falls back to reading the file path and unwinds on any IO error.

Source

Thrown at crates/node/src/lib.rs:141

  #[napi]
  pub fn scan(&mut self) -> Vec<String> {
    self.scanner.scan()
  }

  #[napi]
  pub fn scan_files(&mut self, input: Vec<ChangedContent>) -> Vec<String> {
    self
      .scanner
      .scan_content(input.into_iter().map(Into::into).collect())
  }

  #[napi]
  pub fn get_candidates_with_positions(
    &mut self,
    input: ChangedContent,
  ) -> Vec<CandidateWithPosition> {
    let content = input.content.unwrap_or_else(|| {
      std::fs::read_to_string(input.file.unwrap()).expect("Failed to read file")
    });

    let input = ChangedContent {
      file: None,
      content: Some(content.clone()),
      extension: input.extension,
    };

    let mut utf16_idx = IndexConverter::new(&content[..]);

    self
      .scanner
      .get_candidates_with_positions(input.into())
      .into_iter()
      .map(|(candidate, position)| CandidateWithPosition {
        candidate,
        position: utf16_idx.get(position),
      })

View on GitHub (pinned to 16e94cbf7f)

Solutions

  1. Ensure the file path exists and is readable before calling: use fs.existsSync / fs.accessSync.
  2. Pass content directly in ChangedContent.content (with extension set) instead of a file path, reading it on the JS side with error handling.
  3. Validate paths and filter out missing files before submitting to the native scanner.

Example fix

// before — panics if file is gone
scanner.getCandidatesWithPositions({ file: path })

// after
const content = fs.readFileSync(path, 'utf8')
scanner.getCandidatesWithPositions({ content, extension: 'html' })
Defensive patterns

Strategy: validation

Validate before calling

// JS side: verify file exists and is readable before the native call
const fs = require('fs')
if (input.file && (!fs.existsSync(input.file) || !fs.statSync(input.file).isFile())) {
  throw new Error(`Cannot read candidate source: ${input.file}`)
}
scanner.getCandidatesWithPositions(input)

Type guard

function isReadableFile(file: string): boolean {
  try {
    return fs.statSync(file).isFile()
  } catch {
    return false
  }
}

Try / catch

// Prefer passing content to avoid the native panic
let content
try {
  content = fs.readFileSync(input.file, 'utf8')
} catch (e) {
  console.warn(`skipping unreadable file: ${input.file}`)
  return []
}
scanner.getCandidatesWithPositions({ content, extension: input.extension })

Prevention

When it happens

Trigger: Calling the native get_candidates_with_positions with a ChangedContent whose file path does not exist, is not readable, or is a directory. The .expect() turns the IO error into a panic that crosses the napi boundary.

Common situations: Passing a stale or deleted file path to the scanner. Path resolution mismatch (relative vs absolute) between the JS caller and the working directory. Permission errors in restricted environments. Race where the file is deleted between scan scheduling and read.

Related errors


AI-assisted analysis of tailwindlabs/tailwindcss@16e94cbf7f (2026-08-12). Data as JSON: /api/errors/ad292345c10b8a97. Report an issue: GitHub.