WindowsでKernel#spawnで作った子プロセスを親プロセスが死ぬときに同時に死んでもらう

WindowsだとRubyのspawnメソッドでプロセスを作成すると親プロセスからは切り離されて、親プロセスが終了しても残り続けます。

これが便利な時もありますが、私のユースケースでは不便に働くことがありました。なので、今回は親子共々死んでもらいます。

以前に、「アプリケーションが終了するときに、子プロセスも終了させる方法」ということで、Go言語で実装した記事を投稿しました。

bamch0h.hatenablog.com

こちらの焼き増し記事となります。ご了承を。

Ruby コード

module Kernel32
    require 'fiddle'
    require 'fiddle/import'
    require 'fiddle/types'
    
    extend Fiddle::Importer
    dlload 'kernel32.dll'
    include Fiddle::Win32Types

    typealias 'ULONG_PTR', 'ULONG*'
    typealias 'LONGLONG', 'double'
    typealias 'SIZE_T', 'ULONG_PTR'
    typealias 'ULONGLONG', 'unsigned long long'
    typealias 'LPVOID', '*void'

    SecurityAttributes = ([
        "DWORD nLength",
        "LPVOID lpSecurityDescriptor",
        "BOOL bInheritHandle",
    ])

    typealias 'LPSECURITY_ATTRIBUTES', '*SecurityAttributes'

    LARGE_INTEGER = struct([
        "LONGLONG QuadPart"
    ])

    IoCounters = struct([
        "ULONGLONG ReadOperationCount",
        "ULONGLONG WriteOperationCount",
        "ULONGLONG OtherOperationCount",
        "ULONGLONG ReadTransferCount",
        "ULONGLONG WriteTransferCount",
        "ULONGLONG OtherTransferCount",
    ])

    JobObjectBasicLimitInformation = struct([
        { PerProcessUserTimeLimit: LARGE_INTEGER},
        { PerJobUserTimeLimit: LARGE_INTEGER},
        'DWORD LimitFlags',
        'SIZE_T MinimumWorkingSetSize',
        'SIZE_T MaximumWorkingSetSize',
        'DWORD ActiveProcessLimit',
        'ULONG_PTR Affinity',
        'DWORD PriorityClass',
        'DWORD SchedulingClass'
    ])

    JobObjectExtendedLimitInformation = struct([
        { BasicLimitInformation: JobObjectBasicLimitInformation},
        { IoInfo: IoCounters },
        'SIZE_T ProcessMemoryLimit',
        'SIZE_T JobMemoryLimit',
        'SIZE_T PeakProcessMemoryUsed',
        'SIZE_T PeakJobMemoryUsed',
    ])

    JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000

    extern 'HANDLE CreateJobObjectA(LPSECURITY_ATTRIBUTES, LPCSTR)'
    extern 'HANDLE OpenProcess(DWORD, BOOL, DWORD)'
    extern 'BOOL SetInformationJobObject(HANDLE, int, LPVOID, DWORD)'
    extern 'BOOL AssignProcessToJobObject(HANDLE, HANDLE)'
    extern 'BOOL CloseHandle(HANDLE)'
end

class JobObject
    PROCESS_SET_QUOTA = 0x0100
    PROCESS_TERMINATE = 0x0001
    JobObjectExtendedLimitInformation = 9
    
    def initialize
        @job = Kernel32.CreateJobObjectA(0, 0)

        info = Kernel32::JobObjectExtendedLimitInformation.malloc
        
        info.BasicLimitInformation.LimitFlags = Kernel32::JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
        
        Kernel32.SetInformationJobObject(
            @job,
            JobObjectExtendedLimitInformation,
            info,
            Fiddle::Importer.sizeof(Kernel32::JobObjectExtendedLimitInformation)
        )
    end

    def register(pid)
        hProcess = Kernel32.OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, 0, pid)
        Kernel32.AssignProcessToJobObject(@job, hProcess)    
        Kernel32.CloseHandle(hProcess)
    end
end

job_obj = JobObject.new
pids = []
3.times do |i|
    pid = spawn("ruby.exe -e 'loop { p #{i}; sleep 1}'")
    job_obj.register(pid)
end

sleep 5

上記コードを実行すると、rubyのプロセスが3つ起動します。これらプロセスは無限ループで動作しているので通常はCTRL-Cで殺すか、taskkill等で殺すかしないと終了しません。ですが今回は親プロセスが5秒後に終了すると同時に終了します。

苦労した点

Goの場合は、すでにWin32APIのライブラリがあり、それを使えば比較的簡単に実装できましたが、Rubyの場合はそのようなライブラリがなくAPIの定義からする必要がありました。

Rubyには fiddle という ffi を行うための標準ライブラリがあり、それを用いてAPI定義を作成していくのですが、如何せん fiddle の情報が少なく「こういった場合はどうやって定義するんだろう?」という状況が結構起こった感覚があります。

例えば、Windowsでは LONGLONGという型がありますが、定義は以下のようになっているようです。

#if !defined(_M_IX86)
 typedef __int64 LONGLONG; 
#else
 typedef double LONGLONG;
#endif

なので、今動かしているプラットフォームに依存しているということなのですが、これを Ruby で表現する方法がわかりませんでした。おそらく私の環境では double で定義しておけば問題ないだろう。ということで今回のケースでは LONGLONG は double で定義しました。

また、入れ子になっている構造体の定義もどのように定義すればいいか、公式のドキュメントからはわかりませんでした。(ちゃんと読み込んだら書いてあるのかもですが、私は見つけられませんでした)

じゃぁ、どうやって定義したかというと、GithubのPullRequestに例が載っていたのでその通りに実装して動くかどうか試して、動いたのでこれが正解かな?という感じで定義しました。

github.com

これはドキュメントにコントリビュートするチャンスかもしれませんね!!

制限事項

上記のコード例ははじめは notepad.exe で書いていたのですが、なぜか一つだけプロセスが残るという問題がありました。これははじめのメモ帳だけプロセスが二つ起動するため、pidが一つだけJobObjectに登録されないことが問題と考えていますが、それをどう解決できるのかがわからず今回での対応は保留とさせていただきました。どなたかわかる方いらっしゃったら教えてください!!

まとめ

親プロセスが死んだときに子プロセスも死ぬようにJobObjectに登録するRubyプログラムを書いてみました。Fiddle を使ったことがなかったので使い方等含めて勉強になったかなと思います。できればもう書きたくないですが、もし書くことがあればこの記事を参考にできると思うで、未来の自分へのメモ書きということでここに記しておきます。

以上!!