Node.jsのsourceを上書き・削除してしまった。まだ動いているprocessから取り戻せるか確かめるEmber
編集中のファイルを誤って上書き、あるいはgit checkoutやgit restoreで意図せず元に戻してしまった経験は少なくないはずだ。ただ多くの場合、そのファイルを読み込んでいたNode processはまだ動いている。ディスク上のsourceは失われたが、processの中にはまだ古いsourceが残っているかもしれない——この記事はその可能性を実際に確かめる。
なぜ救える可能性があるのか
Node.jsはV8エンジンの上で動いている。V8はスクリプトを実行するとき、そのソーステキストをコンパイル済みコードと一緒に保持し続けることがある。これはNodeの一般的な仕様として明文化されているものではないが、Node自身のInspectorプロトコル(Chrome DevTools Protocolのサブセット)が Debugger.getScriptSource というメソッドを提供している。これはロードされたスクリプトのソーステキストをそのまま返すAPIで、Chrome DevToolsやVS Codeのデバッガが Nodeのソースを表示するときにも使われている、既存の仕組みだ。
つまり、processがまだ動いていて、Inspectorに接続できるなら、そのprocessが今実行しているソーステキストを外から読み出せる可能性がある。
Ember
Emberは、この可能性を一つの手順にまとめたWindows向けのローカルCLIツールだ。動いているNode processのInspectorに接続してソーステキストを読み出し、それを今のディスク・Git HEAD・indexの内容と比較する。ディスクと違う——上書きされた、削除された、まだcommitしていない——ソースだけを、元のファイルを一切上書きせずに別の場所へ保存する。複数のprocessが同じパスの違うバージョンを持っていた場合も、それぞれ別ファイルとして分けて保存する。
already-inspected processの場合
対象のNode processが最初から –inspect 付きで起動していた場合、EmberはそのInspectorエンドポイントに接続するだけでよい。
node path\to\ember.cjs rescue --pid 12345
–pid は繰り返し渡せ、Emberがどのprocessを事前に絞り込むかを制限する。–pid を省略すると、Emberは loopback(127.0.0.1 / ::1)でInspectorが開いている –inspect* 付きのNodeプロセスを自動的に探す。まず何もせず状況を確認したいときは scan が使える。
plain Node processの場合
–inspect なしで起動した、いわば「普通の」Node processは、Emberから見えない。デフォルトでは何も起きない。それでも救出したい場合は、明示的にオプトインする必要がある。
node path\to\ember.cjs rescue --pid 12345 --activate-inspector
–pid / –activate-inspector
–pid と –activate-inspector の両方を渡したときだけ、Emberは process._debugProcess を使ってそのprocessのInspectorを後付けで有効化する。Emberはリポジトリのパス文字列がコマンドラインに含まれているかどうかで対象を選ぶことはしない——無関係なスクリプトがたまたまリポジトリのパスを引数に受け取ることもあるため、それは所有権の証拠にならないからだ。PIDは常に利用者自身が選ぶものであり、Ember側がそのprocessの所属を検証した結果ではない。
実際に試す
READMEの主張を確かめるため、公開リポジトリの demo/run-demo.cjs を今回改めて実行した。このデモは使い捨てのGitリポジトリと、デモ自身が起動したNode processだけを使う。実在する他のprocessへは一切接続しない。
node demo\run-demo.cjs
今回の実行結果は次の4シナリオすべてでPASSだった。
[PASS] A. pre-inspected rescue — recovered=46ec6a6b1a98 disk=6db7279cc3d5
[PASS] B. plain Node --pid --activate-inspector rescue — hash=6163ca0fa7c8
[PASS] C. multi-version rescue — saved=f585437f,70ff4638 disk=7dd7a8a9
[PASS] D. negative case (exited PID) — failed=true activated=false
4/4 scenarios PASS
- A. already-inspected:–inspect 付きで起動したprocessのファイルをディスク上で上書きしたあと救出。救出したハッシュは上書き前のオリジナルと一致し、ディスクのハッシュは上書き後の内容と一致した。
- B. plain activation:–inspect なしのprocessは、デフォルトの実行では見えないことをまず確認。そのあと –pid + –activate-inspector を渡した実行だけがソースを救出し、そのハッシュは元のソースと一致した。
- C. multi-version:同じパスの2つの異なる内容をロードした2つのprocessから、それぞれ別のファイルとして両方を救出。どちらのハッシュも、現在ディスクにある3つ目の内容とは一致しなかった。
- D. negative case: すでに終了したPIDを指定した実行は、activatedとして報告されず、failureとして報告された。
hashで確認する、ということ
上のA・Bのシナリオが示しているのは、「Inspectorのエンドポイントが開いた」ことではなく、「取り出したソーステキストのSHA-256が、意図したオリジナルのSHA-256と一致した」ことだ。EmberはInspector接続に成功しただけでは成功とは報告しない。manifest.jsonにはソースのSHA-256と、ディスク/HEAD/indexそれぞれとの比較結果(IN_SYNC、HEAD_SURVIVES、MEMORY_ONLY_VS_DISK_HEAD_INDEXなど)が記録され、実際に何かが救出されたかどうかはrecovery.savedの件数とハッシュで確認する必要がある。
複数versionを区別して救出する
上のシナリオCが示すように、同じリポジトリ相対パスに対して複数のprocessが異なる内容をロードしていた場合、Emberはそれぞれを別々に、独自のファイル名で保存する。1つのprocessから救出した内容が、別のprocessや現在のディスクの内容で上書きされることはない。
出力とmanifest
Emberはデフォルトで、OSの一時ディレクトリの下に新しいディレクトリを作る。対象リポジトリの内部や、Ember自身のツールディレクトリの内部には書き込まない。各実行は新しいディレクトリと一意なファイル名を使い、既存ファイルを上書きすることなく書き込み、書き込んだ内容のSHA-256を検証する。
manifest.jsonに記録されるのは、PID、scriptId(そのInspectorセッション内でのみ有効な識別子)、リポジトリ相対パス、保存したファイル名、ソースのSHA-256、そして各行のstatusだ。対象のコマンドラインや、生のソースURL文字列は記録されない。
Inspectorを後付けする副作用
–activate-inspector は、対象processに対する実際のミューテーションだ。process._debugProcess を通じてInspectorを有効化し、その結果開いたリスナーはEmberの終了後も残り続ける——Emberは自分でそれを閉じない。有効化の設定(バインド先など)は対象process自身の設定を引き継ぐため、Emberがそれをloopbackに強制することはできない。Emberが実際に接続するのは、自分で観測した127.0.0.1または::1上のエンドポイントだけだ。デフォルトポート9229がすでに使われている場合、そのターゲットへの有効化は失敗として報告される。
Ember自身が対象に対して送るInspectorメソッドはDebugger.enable・Debugger.getScriptSource・Debugger.disableの3つだけで、コードの評価や実行の一時停止、対象の書き換えは行わない。それでもDebuggerを有効化すること自体にオーバーヘッドがあり、すでに対象に別のデバッガが接続している場合は影響し合う可能性がある。
既存のInspector機能との違い
Node Inspector/Chrome DevTools Protocol自体は、Emberが発明したものではない。ソースを取り出すgetScriptSourceも、後付けでInspectorを有効化するprocess._debugProcessも、既存の、文書化された機能だ。
Emberの価値は、この既存の能力を「事故対応のワークフロー」として一つにまとめたことにある。
- 対象processの選択(明示的なPID指定)
- オプトインの後付け有効化
- リポジトリ内スクリプトのソース検出
- ディスク・HEAD・indexとの比較
- 消失・変更の判定
- 複数バージョンの分離保存
- 上書きしない出力と検証済みmanifest
- 失敗・ゼロ件も含めた明示的な結果報告
これを手作業でやるなら、正しいInspectorエンドポイントを見つけ、スクリプト一覧を取得し、それぞれをディスクとGitと比較し、実際に違う部分だけをコピーする、という作業を毎回自分で組む必要がある。Emberのほうが上回っている、という話ではなく、同じ能力を持つ人なら自分でも構築できるものを、一つのコマンドとチェック可能な結果としてまとめた、という違いだ。
制約
- 取り出す仕組みは既存のNode Inspector∕CDPそのもので、新しいメモリフォレンジック技術ではない
- 救出できるのはV8が今保持しているソーステキストだけで、元のバイトエンコーディングの完全な再現や、全ソースの網羅、実行可能なプロジェクト全体の復元は保証しない
- すでに終了したprocess、アンロード・GCされたスクリプト、worker、ネイティブコード、データファイル、source mapの再構築はすべて対象外
- node_modules・.git・リポジトリ外のファイル・8 MiBを超えるスクリプトは除外
- 観測はatomicではなく、Git履歴全体からの不在の証明や、スクリプトのソースURLの真正性の証明ではない
- OSの権限、PID再利用によるレース、ポート衝突は完全にはEmberの制御下にない。有効化前後のリスナー差分はポート衝突の誤認を減らすが、なくすものではない
GitHub
ソースコード、テスト、READMEはこちらで公開している。
https://github.com/iwadjp/ember
関連するAI coding調査toolは、まとめページに整理しています。