A Rails app I work on accepts file uploads from public-facing websites. Uploaded files may contain sensitive information. We offer this so people don’t have to email their sensitive documents to our customers.

When a file is uploaded, our app generates a new asymmetric encryption key pair and uses the public key to encrypt the uploaded file. The encrypted file is then stored, and the private key is stored separately.

The app then makes the original file available to our customers to download. When a download request is initiated, the app retrieves the encrypted file and the private key, decrypts the file, streams it to the client, and then deletes the decrypted file, so that no sensitive info is left lying around on our server.

Here’s a simplified version of the code I’m talking about, showing a Rails controller action (download) that streams a file, and a private method the action depends on (with_decrypted_file) that decrypts a file, yields it to a block, and then deletes it.

def download
  form_post = FormPost.for(current_user).find(params[:id])
  with_decrypted_file(form_post) do |file| # Pathname
    send_file_headers!(
      disposition: 'attachment',
      filename: file.basename)
    response.delete_header('Content-Length')
    self.response_body = Enumerator.new do |chunks|
      file.open('r') do |f|
        chunks << f.read(8192) while !f.eof?
      end
    end
  end
end

private def with_decrypted_file(form_post)
  encrypted_file = form_post.fetch_decrypted_file
  decrypted_file = nil
  begin
    decrypted_file = EncryptionHandler.decrypt(
      encrypted_file, form_post.private_key)
    fail if !decrypted_file.exist?
    yield(decrypted_file)
  ensure
    encrypted_file.delete if encrypted_file&.exist?
    decrypted_file.delete if decrypted_file&.exist?
  end
end

Here is the same code as above, simplified even further to make it easier to see the meat of what’s going on.

def download
  with_decrypted_file do |file|
    self.response_body = Enumerator.new do |chunks|
      file.open('r') do |f|
        chunks << f.read(8192) while !f.eof?
      end
    end
  end
end

private def with_decrypted_file
  decrypted_file = fetch_and_decrypt_file
  yield(decrypted_file)
ensure
  decrypted_file.delete
end

I didn’t have much experience about Rails response streaming when I wrote this code, but it seemed fairly straightforward and worked fine. Encrypted files were fetched, decrypted, streamed, and deleted. Everyone was happy.

Until we started seeing errors, that is. The errors were only happening in some cases, and even then they were sometimes not reproducible. We would get a user report that a download was failing, but we’d try it ourselves and it would work. We’d have the user try it again and it would work. 🤷‍♂️

Stranger still, there were two different errors that were occurring, but only one would occur at a time, the choice seemingly random:

  • Errno::ENOENT: No such file or directory @ rb_sysopen (at file.open)
  • IOError: closed stream (at f.eof?)

At first this didn’t make any sense. There’s an explicit fail if the decrypted file doesn’t exist (a sanity check to make sure decryption didn’t fail silently), but it wasn’t being tripped. The file definitely existed, and it wasn’t being deleted (cleaned up) until after the yield block had finished.

Right? Wrong. A look under the hood revealed that Rails streaming happens in a separate thread. So we had a race condition, which explained why this issue only happens sometimes and is difficult to reproduce. Turns out it was happening less often on smaller files (maybe under 250K), when reading the file in one thread could finish before the file was deleted in another thread.

I couldn’t find much in the way of how to solve this, such as a flush method on the response stream, but I found my way to the Response#await_sent method, which is exactly what was needed. (There’s also Response#await_commit, but that wasn’t enough for this case.)

Adding response.await_sent as the last line of the with_decrypted_file block fixed the issue by eliminating the race condition. The decrypted file is now guaranteed to finish being streamed before it gets deleted.

Here’s the updated download action method:

def download
  form_post = FormPost.for(current_user).find(params[:id])
  with_decrypted_file(form_post) do |file| # Pathname
    send_file_headers!(
      disposition: 'attachment',
      filename: file.basename)
    response.delete_header('Content-Length')
    self.response_body = Enumerator.new do |chunks|
      file.open('r') do |f|
        chunks << f.read(8192) while !f.eof?
      end
    end
    # wait to exit the block until the file has been
    # streamed so it doesn't get deleted beforehand
    response.await_sent
  end
end

I hope this post saves someone else some time if they are greeted with intermittent IOError and Errno::ENOENT errors while using a response_body Enumerator to stream files in Rails.