Techioz Blog

exifr の何が原因でこの Tempfile が閉じられるのでしょうか?

概要

exifr を使用して、UploadedFile を処理する Ruby コードのこの部分

f = uploaded_file.tempfile
p "1 #{f.closed?} #{f.instance_variable_get(:'@unlinked')}"
#1 EXIFR::JPEG.new(StringIO.new(f.read))
#2 EXIFR::JPEG.new(f)
p "2 #{f.closed?} #{f.instance_variable_get(:'@unlinked')}"
GC.start
sleep 0.01
p "3 #{f.closed?} #{f.instance_variable_get(:'@unlinked')}"
p "4 #{f.size}"

注: GC.start/sleep は、問題を確実に再現するためにあります。

#1 のコメントを解除すると、すべて問題ありません。

"1 false false"
"2 false false"
"3 false false"
"4 3822528"

ただし、#1 ではなく #2 のコメントを解除した結果は次のようになります。

"1 false false"
"2 false false"
"3 true false"
[c4b7ce6b-5492-43db-8c64-726cafaccce0] [Thread: 24800] Errno::ENOENT (No such file or directory @ rb_file_s_size - /var/folders/vx/v0rn818s0257_3l491_v48bm0000gn/T/RackMultipart20240221-71765-acbi7v.JPG):

exifr が行っていることは次のとおりです。

    def initialize(file, load_thumbnails: true)
...
        examine(file.dup, load_thumbnails: load_thumbnails)
...
      end
    end

    class Reader < SimpleDelegator
      def readbyte; readchar; end unless File.method_defined?(:readbyte)
      def readint; (readbyte << 8) + readbyte; end
      def readframe; read(readint - 2); end
      def readsof; [readint, readbyte, readint, readint, readbyte]; end
      def next
        c = readbyte while c != 0xFF
        c = readbyte while c == 0xFF
        c
      end
    end

    def examine(io, load_thumbnails: true)
      io = Reader.new(io)
...

ioからの読み取りも少しあるので、ファイルが閉じられる原因がわかりません。

これは、puma 上で実行されている Rails アプリで発生します。

#2 は、ファイルをメモリに完全にロードする必要がないため、推奨されます (私の場合は最大 50 MB です)。

解決策

この問題が発生する理由は、Tempfile ドキュメントの次の説明による可能性が高いです。

EXIFR は、examine を呼び出すときにファイル オブジェクトに対して file.dup を実行するようになりました。 EXIFR によるファイルの読み取りが完了すると、この複製されたオブジェクトはガベージ コレクションされ、このガベージ コレクションの副作用として、一時ファイルは削除されます。

この問題を Tempfile 競合状態と呼びましょう。 Tempfile はおそらく「dup-safe」ではなく、EXIFR はおそらく File オブジェクトを期待してプログラムされており、その場合にはこの問題は発生しません。

したがって、後の処理のために一時ファイルを保持したい場合、解決策は EXIFR::JPEG.new(f) を使用しないことです。

もう 1 つの解決策は、File オブジェクトを使用して一時ファイルを自分で開き、代わりにそのオブジェクトを EXIFR::JPEG.new に渡すことです。

この方法では、f への参照をどこかに保持している限り、ファイルをメモリに読み取る必要がなく、ガベージ コレクションによって一時ファイルが削除されることもありません。

そして、おそらく最も簡単な最後の解決策は、EXIFR が初期化メソッドへのファイル パス文字列も受け入れることに気づくことです。したがって、これはおそらく最も簡単な修正になります。

EXIFR::JPEG.new(f.path)