Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

windowsでエンジンの多重起動を可能にする(openjtalkによるエラーを出なくする) #1347

Open
Hiroshiba opened this issue May 28, 2024 · 39 comments
Labels
優先度:中 重要 初心者歓迎タスク 初心者にも優しい簡単めなタスク 機能向上 状態:実装者募集 実装者を募集している状態

Comments

@Hiroshiba
Copy link
Member

Hiroshiba commented May 28, 2024

内容

windows環境で、エンジンを2つ起動するとopenjtalk周りでエラーが出ます。
pyopenjtalk.set_user_dict`したものはopenjtalk側でずっとファイルハンドラが必要で、同じファイルに書き込めないためです。

pyopenjtalk.set_user_dict(str(compiled_dict_path.resolve(strict=True)))

この辺りを、ユーザーにバグだと思われることなく解決したいです。

Pros 良くなる点

エディタ側でエンジンがなぜか消えずに残っていることが結構あり、エディターを起動した時にエンジンがエラーになるのを防げる。
あと開発段階でエンジンを複数起動できるようになる。

Cons 悪くなる点

完璧な実装方法が思いついてない

実現方法

コンパイルしたユーザー辞書をset_user_dictしてるので、そのコンパイル済みの辞書のパスをランダムにすれば一応問題は解決されるはず。
ただそうすると辞書を更新するたびに、あるいはエンジンが起動するたびに新しいファイルが生まれて残り続けてしまいます。

迂回作としては、ちょっと雑なアイデアだけど、compiled-0.diccompiled-4.dicまで空いてるパスを探すとか・・・?
追記:↑この方法が今のところ一番良さそう!(空いてる=開くことができる)

その他

#1332 (comment) で整理したメモをこちらにも転機します:

  • 目指す UX
    • ユーザーが意図していない挙動になっているのに、エラーや通知がないのは避けたい
    • エンジンの多重起動でエラーになるのも避けたい
    • ↑のUXが衝突する場合どっちが優先の高いかはエラー次第
  • 挙動の整理
    • update_dict、つまり保存した辞書などをopenjtalkに読み込ませる部分で、多重起動している場合にエラーになる
      • pyopenjtalk.set_user_dictしたものはopenjtalk側でずっとファイルハンドラが必要で、同じファイルに書き込めないから
    • 以前握りつぶすコードがあったが、今はなくなってる
  • 多重起動時の読み込みエラーを握りつぶすべきか
    • もし握りつぶした場合の意図しない挙動は「エンジンが2つ起動している時、辞書登録できたのに辞書が反映されない」になる
      • 後から起動したエンジンに対してVOICEVOX用の辞書には登録できるけど、openjtalk用の辞書には反映されない&エラーにもならないので、登録できたのに反映されない形になる
    • でも辞書登録しない人には関係ない
    • 判断つかない。。。迂回方法を探りたい。
@Hiroshiba Hiroshiba added 機能向上 要議論 実行する前に議論が必要そうなもの labels May 28, 2024
@Hiroshiba
Copy link
Member Author

Hiroshiba commented May 28, 2024

@takana-v @sabonerune ちょっとメンションすみません!!
コンパイル済みのユーザー辞書のパスを5つほど用意して順に空いてるの使う感じにすれば、エンジンが多重起動しない問題をそこそこ綺麗に解決するのではと思ったのですが、なんか認識間違ってる気がちょっとしてます 😇
変なとこあったらご指摘いただけると。。。

@sevenc-nanashi
Copy link
Member

sevenc-nanashi commented May 28, 2024

ただそうすると辞書を更新するたびに、あるいはエンジンが起動するたびに新しいファイルが生まれて残り続けてしまいます。

FastAPIにteardown的なフックってないんですかね?それで消すとかどうでしょう。(AIVoiceVoxだとctrl-cのフックで後処理をしていてちゃんと動いているので、多分FastAPIのteardownも呼ばれるはず)

@sabonerune
Copy link
Contributor

FastAPIにteardown的なフックってないんですかね?

https://fastapi.tiangolo.com/advanced/events/#lifespan

@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
user_dict.update_dict()
yield

lifespanで終了時のクリーンアップができます(yieldの後の行に処理を書く)

しかしエディタはnode-tree-killを使用しているため実行されません。


起動時にコンパイル済みの辞書を書き込むディレクトリを空にするという手もあると思います。
辞書の切り替え時にタイミング悪く削除されるリスクもありますがまあ、無視できるでしょう。

#620 (comment)
ユーザー辞書がコンパイルできなくなったとき起動できなくなる?

POSIXでもuser_dict.jsonの操作がアトミックではなかったり他のエンジンが辞書操作をするとpyopenjtalkが適用中の辞書とAPIが返す辞書の内容が異なるようになったりするので多重起動が可能と言える状態ではない気が

@Hiroshiba
Copy link
Member Author

@sevenc-nanashi
もちろんteardown的なとこで消せばよいのですが、なんかこう、electronがむずすぎて「終了時にシグナルを正確に呼ぶ」ってのが意外と大変そうなんですよね・・・ 😇

それプラスtmpディレクトリに保存する形であればまあ残っちゃっても良いかって感じなんですが、次は環境によってはtmpディレクトリへのファイルmvができずatomic操作ができないという問題が・・・・・・・・

あれ、もしかしてatomic操作いらなくなった????
プロセス開始時にランダムなtmpファイルを決めて書き込む形ならatomic操作不要かも・・・・・?
update_dict自体をそのプロセス内で排他制御しないといけないですが。


@sabonerune

起動時にコンパイル済みの辞書を書き込むディレクトリを空にするという手もあると思います。

自分もこれ考えたんですが、わざと2つ起動する人とかもいる気がするんですよね〜・・・。
Linuxとかだとファイル開き中でもファイル削除できちゃうわけで、そうすると起動済みだったエンジンがなぜかユーザー辞書使えない形になるとかあるんじゃないかなぁと。
なので避けられるなら避けたい系のイメージでした。

@Hiroshiba
Copy link
Member Author

Hiroshiba commented May 28, 2024

今のとこまとめると、こう?

今のmainの実装
→windowsが多重起動できなくて不便。まあまあ避けたい。

OSErrorを握りつぶす場合
→Windowsで2つ目に起動したエンジンは、ユーザー辞書登録ができるけど反映されない形になる。わりと避けたい。

コンパイル済み辞書の名前をN個用意し、1つずつ使えるか試していく
→N個より増えるとエラーになる。まあまあ避けたい。

コンパイル済み辞書の名前をランダムにし、teardownで消す
→エディタからteardownを正確に呼ぶ自信がない。まあでもアリかも。

コンパイル済み辞書の名前をランダムにし、エンジン起動時に全ファイル消す
→Linuxでエンジンを2つ起動すると、1つ目の方でユーザー辞書が使えなくなるかも。まあまあ避けたい。

コンパイル済み辞書の名前をランダムにし、tmpディレクトリに保存して、teardownで消す
→エディタからteardownを呼ぶ自信がないけど、まあtmpディレクトリだから許されそう。一番アリかも。

@sabonerune
Copy link
Contributor

@Hiroshiba

Linuxとかだとファイル開き中でもファイル削除できちゃうわけで、そうすると起動済みだったエンジンがなぜかユーザー辞書使えない形になるとかあるんじゃないかなぁと。

Linuxの場合開いているファイルを削除しても問題ないのではと思います。
Linuxの場合開かれているファイルを削除してもファイルパスから参照できなくなるだけで引き続き操作はできるという感じだったような気がします。

@Hiroshiba
Copy link
Member Author

@sabonerune まあ大丈夫かもなのですが、あんまりOSやライブラリやファイルシステムの(隠れ)仕様に頼った実装にしない方が良いかなぁと。
どうしても避けられないなら通らないとダメだけど、避けられる方法があるならわざわざ通る必要はないかなって感じです。


とはいえエディタ側にsignal実装もちょっと大変そうではあるんですよねぇ。。
うーーーーーん。候補ファイル全部消すのはちょっと怖い気もするし、かといって連番にして0から使ってくのは同じファイルにあたって変な挙動しかねなさそう。

うーーーーーーーーーん。ラウンドロビンもどきとかぁ・・・?
N個ファイル名候補用意して、更新時間でソートして、古いやつから使えるか試していくみたいな・・・。

@sevenc-nanashi
Copy link
Member

sevenc-nanashi commented May 29, 2024

しかしエディタはnode-tree-killを使用しているため実行されません。

これ本当です?AIVoiceVoxのteardown(ctrl-cハンドル)はちゃんと動いてるので動くと思いますが

もちろんteardown的なとこで消せばよいのですが、なんかこう、electronがむずすぎて「終了時にシグナルを正確に呼ぶ」ってのが意外と大変そうなんですよね・・・ 😇

tree-killってシグナル指定できませんでしたっけ?一回SIGINT送って10秒後にSIGKILLみたいなことできそうな気が。

@Hiroshiba
Copy link
Member Author

Hiroshiba commented May 29, 2024

@sevenc-nanashi とりあえずコード追っかけてみました。

エディタ側ではtreeKillを呼んでいて、
https://github.com/VOICEVOX/voicevox/blob/a07c776956987f8a44538089278449cd0469f022/src/backend/electron/manager/engineManager.ts#L504
treeKill側はwindowsは即強制終了のコマンド(たぶん)、linuxとかはSIGTERMが呼ばれてそうでした。
https://github.com/pkrumins/node-tree-kill/blob/cb478381547107f5c53362668533f634beff7e6e/index.js#L29
https://github.com/pkrumins/node-tree-kill/blob/cb478381547107f5c53362668533f634beff7e6e/index.js#L82

なのでwindowsなら即強制終了・・・という理解で合ってるはずなんですが、まだ未知ななにかがあるかもですねぇ。
今もなおエンジン側のteardownで拾えるシグナルが飛ばされてるのであれば、teardown実装するのはかなりアリ。

@sevenc-nanashi
Copy link
Member

なるほどー。

VOICEVOX/voicevox#1540

となるとこれを再Openしてもいいかも?

@tarepan tarepan added the 状態:設計 設計をおこなっている状態 label Jun 1, 2024
@Hiroshiba
Copy link
Member Author

@sevenc-nanashi VOICEVOX/voicevox#1540 は・・・うーん、1つのWebAPIとしては強力すぎると思うんですよね・・・。
なにか前例があれば参考にしたいです。特に無さそうであればコメントをオフトピックにしても良いかも?

@Hiroshiba
Copy link
Member Author

とりあえず候補が決まってない状況なのが一番もったいない気がしました!
個人的にはコンパイル済み辞書の名前をランダムにし、tmpディレクトリに保存して、teardownで消すが最良に思いました。

まず現状、エディタはどうやらSIGTERMをエンジンに送っているっぽいことがわかっているためです。
エディタで使っているtreekillはmacos/linuxならSIGTERMを送るし、windowsでも送られているっぽいためです。(後者はなぜかはわかりませんが・・・)

あとこの方法であれば、最悪問題がほぼ無いと思います。
エラーが起きることが原理上ない、というのが最大のメリットかなと。
問題があるとすれば、teardownが呼ばれなくてtmpディレクトリが肥大化していくことくらいですが、そもそもコンパイルされたユーザー辞書は相当小さいので問題にならないよなーと。

設計としてはこうでしょうか?

  1. pyopenjtalk.set_user_dictにセットするcompiled_dict_pathのパスをtmpディレクトリ内のランダムなパスにする
  2. Path.replaceを呼ぶために、pyopenjtalk.create_user_dictで作るtmp_compiled_pathも同じディレクトリ内にする
  3. lifespan内で辞書の終了処理をする
    • pyopenjtalk.unset_user_dictを実行してcompiled_dict_pathを削除するfinalize_dict関数を作って叩く感じ

@sabonerune
Copy link
Contributor

#1248 (comment)
Uvicornの仕様によりSIGINT以外を受け取った場合はgraceful shutdownになりません。

POSIXの方はTreeKillのシグナルをSIGINTにすれば(恐らく?)解決できますが、WIndowsではそれは不可能です。
(SIGINT送るときはTreeKillを使わない方がいい気がするが)

@Hiroshiba
Copy link
Member Author

@sabonerune
uvicornのコード読んでみたのですが、もしかしたらSIGINT以外でもgracefulシャットダウンしてるかも・・・?

ここでSIGINTもTERMもBREAKも同じ用にhandle_exit関数を呼ぶようにされていて、
https://github.com/encode/uvicorn/blob/c23cd24e6676ee0638d014d7000af1e6e0996bd6/uvicorn/server.py#L318

handle_exit関数は同じ処理をしているのでそう思いました。(SIGINTだけ2回目は強制終了?)
https://github.com/encode/uvicorn/blob/c23cd24e6676ee0638d014d7000af1e6e0996bd6/uvicorn/server.py#L330-L335

実際linux環境でSIGTERM(たぶんあたい何もなくkillすればよいだけ)してuvicornがgraceful shutdownしてそうか確かめると良さそう?

@sabonerune
Copy link
Contributor

@Hiroshiba
WindowsでもSIGBREAK(Ctrl + Break)で同じことができるので確認したところgraceful shutdownするみたいです。失礼しました。

ただ、taskkill /pid [pid] /T /Fで強制終了した場合は呼ばれないみたいです。

@Hiroshiba
Copy link
Member Author

@sabonerune

ただ、taskkill /pid [pid] /T /Fで強制終了した場合は呼ばれないみたいです。

以前の話だとなぜか呼ばれたという感じだった記憶がありますが、まあ、呼ばれない前提で考えるのが良さそう。
このあたりはWindowsのOSのバージョンによっても変わりそう。

そうなると #1347 (comment) の方法ではtmpに辞書が残り続けてしまう前提で良さそう。流石にそれは微妙そうに感じます。

とりあえずこのissueの解決策としては、ワークアラウンドですが↓の方法が良さそうかなと思いました。

コンパイル済み辞書の名前をN個用意し、1つずつ使えるか試していく

候補のファイルを5つくらい持っておいて、順に開いていって開けなかったら次のを試す、という感じをイメージしています。
これだとWindowsで6個目のエンジンを起動するとエラーになる問題がありますが、まあワークアラウンドということで・・・。

別件で、正常終了させる方法はほんとに難しいですね。。。
ちょっと終了方法のissueにコメントしてみます。

Copy link

github-actions bot commented Jan 7, 2025

本 Issue は直近 180 日間で活動がありません。今後の方針について VOICEVOX チームによる再検討がおこなわれる予定です。

@sevenc-nanashi
Copy link
Member

VST版で多重起動したい需要があるので優先度を上げたいです。

@Hiroshiba
Copy link
Member Author

Hiroshiba commented Jan 7, 2025

普通にエディタ開発するときとかも不便なので、優先度上げましょうか!!!


これ今更ながら最良の方法を思いつきました。
(追記)と思ったけど全然解決じゃない気がしてきました!!!一応detailsに残しておきますが無視していただければ。。。

ダメそうな提案

「辞書でハッシュを取ってファイル名に使用し、辞書内容が変わるタイミングで前の辞書のハッシュと同じファイル名があれば消す」で良い気がしました。

流れ:

  • エンジンが起動するとき
    • 辞書AからハッシュAを作る
    • コンパイル済みの辞書があるか、tmpディレクトリのハッシュAファイルを探す
    • あったらそれ使う、なかったらコンパイルしてtmpディレクトリに書き込む
  • エンジンが終了するとき
    • 何もしない
  • 辞書Aから辞書Bにアップデートされたとき
    • まず辞書AからハッシュAを作る
    • コンパイル済みの辞書がtmp/ハッシュAにあれば消す
    • 辞書BからハッシュBを作る
    • コンパイル済みの辞書をtmp/ハッシュBに作る

@Hiroshiba Hiroshiba added the 優先度:中 重要 label Jan 7, 2025
@Hiroshiba
Copy link
Member Author

数ヶ月経って今の感想ですが、やっぱり

コンパイル済み辞書の名前をN個用意し、1つずつ使えるか試していく

のワークアラウンド手法を使うのが良さそうな気がしました。
別にN個に限定する必要はなくて、compiled_dict_0.csvに書き込もうとしてエラーが出たら次のファイルを見れば良さそう。
ファイルは残しっぱなしで問題ないし、あとwindows以外だと複数のエンジンプロセスが同じファイルを見てしまうけどこれもまあ別に問題になることは少なそう!

ということで実装者募集中です!! そんなに難しくないと思うのでぜひぜひ!!

@Hiroshiba Hiroshiba removed 要議論 実行する前に議論が必要そうなもの 状態:設計 設計をおこなっている状態 非アクティブ labels Jan 7, 2025
@Hiroshiba Hiroshiba added 初心者歓迎タスク 初心者にも優しい簡単めなタスク 状態:実装者募集 実装者を募集している状態 labels Jan 7, 2025
@sabonerune
Copy link
Contributor

Pythonで作成したNamedTemporaryFileをpyopenjtalkに渡すという方法があります。

# コンパイル済み辞書の置き換え・読み込み
pyopenjtalk.unset_user_dict()
tmp_compiled_path.replace(compiled_dict_path)
if compiled_dict_path.is_file():
pyopenjtalk.set_user_dict(str(compiled_dict_path.resolve(strict=True)))

-            # コンパイル済み辞書の置き換え・読み込み
-            pyopenjtalk.unset_user_dict()
-            tmp_compiled_path.replace(compiled_dict_path)
-            if compiled_dict_path.is_file():
-                pyopenjtalk.set_user_dict(str(compiled_dict_path.resolve(strict=True)))
+            # コンパイル済み辞書の内容を一時ファイルに移し替える
+            with NamedTemporaryFile() as t:
+                t.write(tmp_compiled_path.read_bytes())
+                t.flush()
+
+                # コンパイル済み辞書の置き換え・読み込み
+                pyopenjtalk.unset_user_dict()
+                pyopenjtalk.set_user_dict(t.name)

この方法をWindowsで動作させるためにはOpenJTalkにも変更が必要です。

https://github.com/VOICEVOX/open_jtalk/blob/e8a99109e84cb1299726f2a9567d3400d70d973d/src/mecab/src/mmap.h#L110

-    hFile = ::CreateFileW(WPATH(filename), mode1, FILE_SHARE_READ, 0,
+    hFile = ::CreateFileW(WPATH(filename), mode1, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, 0,

一応動作確認ができたので案の一つとして報告です。

@Hiroshiba
Copy link
Member Author

Hiroshiba commented Jan 7, 2025

@sabonerune なるほどです!!

ちょっと考えたのですが、もしその方法適用するのであればopenjtalk側のいじった場所は問題が発生しないと自信が得られるまで掘ったほうが良さそうだと思いました!
(結構好みがありそう)

というのも、例えばそのopenjtalkのファイルを書き換えているところですが、hFileはdeleteされても大丈夫なのでしょうか・・・?
NamedTemporaryFileはwithを抜けるとファイルを削除するので、デリートされるはず。
でもmecabがそのファイルをもう一度読み込むことがないのか、仕様を知らないのでまた別の問題が起きる可能性があるなぁと。
例えばhMapCreateFileMappinghFilePAGE_READONLYとして開いていそうですが、これは何かのタイミングでもう一度ファイル読み込みを試みたりとかありそうだなと。
もちろんこれは例としてぱっと考えてるだけですが、問題がないはずだと自信が出るまで掘り続けないといけないかな~と。。
(にmecab側は1GBぐらいある辞書も想定してる気がしていて、だとしたらオンメモリにせずファイルに置いとくコードがあってもおかしくなさそう、とか。)

ということで、Pythonの自信が持てる範囲でワークアラウンドを適用ほうが良さそうかなと思いました!!
提案すごく嬉しいです、ありがとうございます!!

@Hiroshiba
Copy link
Member Author

Hiroshiba commented Jan 7, 2025

新しくいくつか手を考えてみました。
でもとても良い方法は思いつかなかったので、↑の多分一番良さそうな方法は変わらずです。メモまで。。

  • ポート番号に対応するファイル名にする
    • そのポートでエンジンを開ける=そのファイルが使える、になる
    • 必ず競合しないメリットがある
    • ただランダムにポートを開くようにしてる場合、コンパイル済み辞書が無限に増えてしまう
  • プロセス番号に対応するファイル名にし、ファイル名に対応するプロセスがない場合そのファイルを消す
    • まだ使っているのに消したりすることがないはず
    • エンジンに全く関係ないプロセスが偶然あっても特に問題ではない
    • Pythonでプロセス番号を大体取ってこれる方法があるのかは不明
    • ちょっと処理が大変だけどこの方法は割と良さそうな気がする(ワークアラウンドではない普通の処理そう)

@sabonerune
Copy link
Contributor

@Hiroshiba

というのも、例えばそのopenjtalkのファイルを書き換えているところですが、hFileはdeleteされても大丈夫なのでしょうか・・・?
NamedTemporaryFileはwithを抜けるとファイルを削除するので、デリートされるはず。
でもmecabがそのファイルをもう一度読み込むことがないのか、仕様を知らないのでまた別の問題が起きる可能性があるなぁと。
例えばhMapがCreateFileMappingでhFileをPAGE_READONLYとして開いていそうですが、これは何かのタイミングでもう一度ファイル読み込みを試みたりとかありそうだなと。

pyopenjtalkのset_user_dict()を呼んでからunset_user_dict()を呼ぶまでファイルハンドルが解放されないことが前提になっています。

Mmapクラスはopen()を呼ぶとclose()を呼ぶまでファイルを開いたままにします。
なのでhMapが後でファイルを読み込んでも問題はないはずです。

ただし、with文を抜けた後にOpenJtalkが内部的にMmapclose()を呼んだ後に再度open()を呼ぶといったコードが存在する場合は問題が起こります。

@Hiroshiba
Copy link
Member Author

Hiroshiba commented Jan 8, 2025

Mmapクラスはopen()を呼ぶとclose()を呼ぶまでファイルを開いたままにします。
なのでhMapが後でファイルを読み込んでも問題はないはずです。

おおなるほどです!この場合、エンジンAとBが同じ辞書を見るとして、
Aがopen → Bがopen → Aがclose → Bがclose
するとどうなりそうでしょうか・・・? 👀

(「Aがclose → Bがclose」の間にBがhMapでファイルを読み込みできないかも、と思ってます)

@sabonerune
Copy link
Contributor

@Hiroshiba

おおなるほどです!この場合、エンジンAとBが同じ辞書を見るとして、

そもそもエンジンのプロセス1つにつきコンパイル済みユーザー辞書が1つ作られるという感じなのでそもそも起こらないです。

仮にプロセス間で同じユーザー辞書を共有する場合、Windowsの場合はファイルを開いているハンドルがある限りファイルが残り続けるので動作します。
しかしPOSIXの場合with文を抜けた時点でファイルが消える(既に開いているファイル記述子からは読み書きできる)ので2番目に起動したエンジンは同じユーザー辞書を参照できないという感じになります。


OpenJTalkのユーザー辞書を開くコード周りをある程度確認しましたがMeCabのModelが破棄されるまでは辞書は開いたままになるはずです。
Tokenizerが辞書のファイル名を持っているけれど内部では使っていなさそうな感じがします。

@Hiroshiba
Copy link
Member Author

Hiroshiba commented Jan 8, 2025

なるほど、確かに複数のエンジンプロセスが同じコンパイル済み辞書のパスを見ることはなさそう!

まだまだ考えることがいっぱいあると思うので、問題が起こらなさそうか考えてみていただければ!
例えばパパと思いつくのは、windowsでエンジンが強制終了した時にtmpファイルは残らないのか(エンジンは強制終了されるので)、linux/macでMMapがopen中にコンパイル済み辞書ファイル(hFile)が削除されるけどhMapが読み込んだ時に問題にならないのか、とか・・・!


正直ドキュメントが書かれていない時点で、ソースコードいじるのはかなり不安です。
頼れるのは今まで動いていたという実績だけなので、挙動変えて良いものなのかがわからない。
なのでひたすら検証と調査をするしかないかなと・・・。

メンテナとしては問題はないこと把握する必要があり、そのためには思いつく限り全ての不安要素を調査しておきたい感じです!
実現できればこちらの方がUXは良いと思うので、よければご協力お願いできれば・・・!

@sabonerune
Copy link
Contributor

windowsでエンジンが強制終了した時にtmpファイルは残らないのか

一時ファイルの削除を行うのはWindows側の役割なのでエンジンが強制終了しても削除されます。

linux/macでMMapがopen中にコンパイル済み辞書ファイル(hFile)が削除されるけどhMapが読み込んだ時に問題にならないのか

POSIXではファイルが削除されるとディスクエントリが削除されてファイルの実態はファイル記述子やmmap等の参照が無くなるまで残ります。
https://pubs.opengroup.org/onlinepubs/9799919799/functions/unlink.html

DESCRIPTION
The unlink() function shall remove the directory entry named by path and shall decrement the link count of the file referenced by the directory entry. If path names a symbolic link, unlink() shall remove the symbolic link and shall not affect any file named by the contents of the symbolic link.

When the file's link count becomes 0 and no process has a reference to the file via an open file descriptor or a memory mapping (see mmap()), the space occupied by the file shall be freed and the file shall no longer be accessible. If one or more processes have such a reference to the file when the last link is removed, the link shall be removed before unlink() returns, but the removal of the file contents shall be postponed until there are no such references to the file. When the space occupied by the file has been freed, the file's serial number (st_ino), and therefore the file identity (see XBD <sys/stat.h>), shall become available for reuse.

そのため既に開いているfdやmmapはwith文を抜けてファイルが削除されても引き続きアクセスが可能です。

@Hiroshiba
Copy link
Member Author

なるほどです!! 僕が現状思いつく範囲の問題点はなさそうに思いました!

windowsでの挙動の確認ですが、NamedTemporaryFileにある以下の説明の2番めを満たしてるのでmecab側から開けて、

  • Opening the temporary file again by its name while it is still open works as follows:
    • On POSIX the file can always be opened again.
    • On Windows, make sure that at least one of the following conditions are fulfilled:
      • delete is false
      • additional open shares delete access (e.g. by calling os.open() with the flag O_TEMPORARY)
      • delete is true but delete_on_close is false.

with NamedTemporaryFileを抜けてもmecab側からアクセスし続けられるから問題ないという感じですよね。
ちなみになぜWindowsでもwithを抜けてもファイルをreadし続けられるのでしょうか。ファイルが削除されない・・・?

あとFILE_SHARE_WRITEの指定は要らなそう? 付けると変な意図が伝わるので、要らないなら無い方が良さそう!

ただし、with文を抜けた後にOpenJtalkが内部的にMmapのclose()を呼んだ後に再度open()を呼ぶといったコードが存在する場合は問題が起こります。

これは結構ありえなくはなさそうなので、一応一通り追いかけて確認したほうが良い気がしました。
Mmapのclose後の処理と、filenameを使いまわしてるとこがないのかと。
難しそうであれば僕の方で追っかけてみます。Mmapのopen()を実行してる入口の場所だけわかれば教えていただけると助かります 🙏

@sabonerune
Copy link
Contributor

sabonerune commented Jan 9, 2025

ちなみになぜWindowsでもwithを抜けてもファイルをreadし続けられるのでしょうか。ファイルが削除されない・・・?

そのファイルを参照する記述子が全て閉じられた時にWindowsによって削除されるようになっています。
MeCab側で新しく記述子が作られるのでwith文を抜けてNamedTenporaryFileで作成した記述子が閉じられても残ります。

あとFILE_SHARE_WRITEの指定は要らなそう? 付けると変な意図が伝わるので、要らないなら無い方が良さそう!

最初はなくても構わないと思ったのですがNamedTenporaryFileで開くときに書き込みアクセスが付けているのでFILE_SHARE_WRITEも必要なようです。
色々試したのですがPythonでGENERIC_READモードで開く方法はまだ見つけられていません。
引き続き調査続けます。

これは結構ありえなくはなさそうなので、一応一通り追いかけて確認したほうが良い気がしました。

https://github.com/VOICEVOX/open_jtalk/blob/e8a99109e84cb1299726f2a9567d3400d70d973d/src/mecab/src/tokenizer.cpp#L119
自分はtokenizer.cpp"userdic"パラメーターを取得しているところを見つけてそれを扱っているクラスを軽く確認した感じです。
自分はC/C++を使えるという能力ではないので確認してもらえると幸いです。

@Hiroshiba
Copy link
Member Author

なるほどです!!
たぶんNamedTenporaryFile側もFILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETEで開かれてる、みたいな感じですよねたぶん。まあドキュメントには書かれてないですが。

NamedTenporaryFileFILE_SHARE_WRITE付きだから必要、なるほどです。
依存関係が逆転してしまっている(openjtalk側がボイボに合わせてる)のもあり、せめてwrite権限だけは外せると嬉しそうではありますねぇ。

GENERIC_READがちょっとわかってないのですが、少なくともNamedTenporaryFilewで開かれるからFILE_SHARE_WRITEが必要になってる、という感じでしょうか?
もしそうなら↓の流れができるかも、と。

  1. ボイボ側が適当なPathにコンパイル済み辞書を書き込む
  2. closeする(単にPath.write_bytesとかで良さそう)
  3. openjtalkにsetする(FILE_SHARE_READ | FILE_SHARE_DELETEで読み込まれる)
  4. ボイボ側でファイルを消す(windowsはopenjtalkがcloseするまで消えない、linuxはすぐ消える)

filenameの場所もありがとうございます!
眺めてみましたが、まあ僕もC++ちゃんと知らないのでよくわからなかったです!!!

とりあえずopenjtalkのソースコード全体でfilenameclose(で検索して、まあfilenameが再利用されたり、closeして同じfilenameで再開したりみたいなのはなさそう・・・?という感じでした!

@sabonerune
Copy link
Contributor

@Hiroshiba

GENERIC_READがちょっとわかってないのですが、少なくともNamedTenporaryFilewで開かれるからFILE_SHARE_WRITEが必要になってる、という感じでしょうか?

自分もあまり理解できていないです。
色々動かして試したのですがrでファイルを開いても駄目でしたので微妙に違う感じでした。
また、openjtalkにsetした後通常の削除操作はエラーになってしまいました。

最終的に削除をする代わりにcdllでWin32APIのCreateFileWを直接使用してFILE_FLAG_DELETE_ON_CLOSEを付けてファイルを開いてすぐ閉じることで削除ができることが分かりました。
https://learn.microsoft.com/ja-jp/windows/win32/api/fileapi/nf-fileapi-createfilew
この方法はDeleteFileWのドキュメントにシンボリックリンク先のファイルの削除をする方法として紹介されているので完全に間違った使い方ではなさそうですが他に方法があるかもしれません。

@Hiroshiba
Copy link
Member Author

なるほどです!!
僕も少しpythonとFILE_SHARE_DELETEの兼ね合いを調べてみましたが、まあnamedtemtfileを使うのは難しそうな気がしました。

cdllで直接叩くのありだと感じました!
close on deleteで開いてすぐ閉じるのはわりと直感的ではないかもですね!(他の手がない場合はありかも)

代案として、python側もcdllでFILE_SHARE_DELETEを指定して開いてwriteしてpyopenjtalkにsetしてdeleteする、というのはいけそうですかね…?

@sabonerune
Copy link
Contributor

@Hiroshiba

代案として、python側もcdllでFILE_SHARE_DELETEを指定して開いてwriteしてpyopenjtalkにsetしてdeleteする、というのはいけそうですかね…?

一応できますがPOSIXのコードと乖離が大きくなるためかえって分かりにくくなりそうな気がします。
そもそもPOSIXの場合は一時ファイルとして開く必要性はなくOpenJTalkにsetした後に削除すれば十分なので削除する関数をWindowsとPOSIXで切り替えるようにすれば乖離も少なく分かりやすさも十分な気がします。

@Hiroshiba
Copy link
Member Author

Hiroshiba commented Jan 13, 2025

おっとなるほどです!

ファイル削除関数を分けるか、ファイル作成関数を分けるかの違いだと思うので、どちらも複雑性は違わない気が・・・?
と思ったけど、もしファイル作成が分岐する場合は、必ずファイル削除も分岐するからということですかね?

(意図がわからず、これ気づくのに15分くらいかかりました。。 😇
誰かが考えたことは他の人はパスできるので、what=「乖離が大きくなる」だけじゃなく、why=「createもdeleteもwindows用関数が必要」や、今回はないですがhowなどの根拠まで書いてもらえるとめちゃ助かります!!)

FILE_FLAG_DELETE_ON_CLOSEを指定してファイルを開いてcloseする、というのはちょっと複雑な気もしなくもないですが、確かにこちらのほうがcdll.CreateFilecdll.DeleteFileを呼ぶよりは簡単そうに感じました!


ちょっと勘違いかもなのですが、実はFILE_SHARE_DELETEさえ指定すれば、何もせずdeleteするだけで良かったりしないですかね・・・?
ちょっとややこしいので念の為思ってる手順を整理すると、こんな感じかなと

  • mecab内のopenにFILE_SHARE_DELETEフラグ追加
  • 今のmasterブランチのコード同様にpyopenjtalk.create_user_dict(tmp_csv_path, tmp_compiled_path)
  • 普通にpyopenjtalk.set_user_dict(tmp_compiled_path)
  • 全OSで共通して、Python内でtmp_compiled_path.unlink()
    • 今のmasterブランチのコードだとfinallytmp_compiled_path.unlink()してるので、変更は不要かも

C++内でCreateFile使ってFILE_SHARE_DELETEで開いたあと、Pythonから普通に削除できるのか不明なのですが、なんか原理上できる気がしてます!!!

@sabonerune
Copy link
Contributor

@Hiroshiba

もしファイル作成が分岐する場合は、必ずファイル削除も分岐するからということですかね?

FILE_FLAG_DELETE_ON_CLOSEで開いてすぐ閉じる場合はWindows固有コードは後処理部分に集約できるがpyopenjtalkにsetする前後で処理を追加する場合はその前後にコードが必要なので可読性が下がるのではと考えました。

自分が考えていたFILE_FLAG_DELETE_ON_CLOSEで開いてすぐ閉じる場合のコード

finally:
# 後処理
if tmp_csv_path.exists():
tmp_csv_path.unlink()
if tmp_compiled_path.exists():
tmp_compiled_path.unlink()

        finally:
            # 後処理
            if tmp_csv_path.exists():
                tmp_csv_path.unlink()
            if tmp_compiled_path.exists():
-                tmp_compiled_path.unlink()
+                if sys.platform == "win32":
+                    _unlink_file_on_close(str(tmp_compiled_path)) # この関数で`FILE_FLAG_DELETE_ON_CLOSE`で開いてすぐ閉じる
+                else:
+                    tmp_compiled_path.unlink()

ちょっと勘違いかもなのですが、実はFILE_SHARE_DELETEさえ指定すれば、何もせずdeleteするだけで良かったりしないですかね・・・?

色々試した結果CreateFileFILE_SHARE_DELETEを呼びだした場合開いた状態でも削除できますが、CreateFileMappingを呼びだすと削除できなくなることが分かりました。
ただ、ファイルを削除してからCreateFileMappingを呼びだすことはできるようです。

OpenJTalkではCreateFileを呼びだした後すぐにCreateFileMappingを呼びだしているのでこの方法はつかえなさそうです。

@Hiroshiba
Copy link
Member Author

Hiroshiba commented Jan 13, 2025

色々試した結果CreateFileでFILE_SHARE_DELETEを呼びだした場合開いた状態でも削除できますが、CreateFileMappingを呼びだすと削除できなくなることが分かりました。

なるほど!?!?!?!?
実際ググってみたら同じ結論に達されている方がいらっしゃいました。。
(追記として書かれてる)

うーーーーーーーん これちょっと詰んだ気がしますね!!

というのも、FILE_FLAG_DELETE_ON_CLOSEだったら消えるけれども、DeleteFileは消えないというのがなんか理にかなってないし、ドキュメントに書いてない気がするためです。
FILE_FLAG_DELETE_ON_CLOSEで消えてるのは、今偶然そうなってると考えた方が良いかも・・・?
Windows 10や11では動かないとか、あるいは Windows の限られたバージョンでは動かないとかが起こりえそう。


かなり調べたり調べてくださったりしたのに行き着いた結論がここ(アンドキュメント)なのは結構悔しいですが、どういう挙動になるかわからない以上フォールバックを用意する必要があるように感じました。
例えばFILE_FLAG_DELETE_ON_CLOSEでファイルを削除できずエラーになる場合とかに備えて、特定のファイル名のものが同じディレクトリになった場合は全部消すとか。

であればもうopenjtalkはいじらず、そのフォールバックだけで良いかもとも思いました・・・。
例えばこういうロジックとか:

  1. コンパイル済み辞書を、かぶらないランダム文字列+特定のpostfixを持つファイル名で保存
  2. 全OSで、特定のpostfixを持つファイルを全て削除、削除できなければ無視

このロジックであれば、finallyでファイル削除している部分をちょっと変更するだけで行けるかもです。

        finally:
          # 後処理
          if tmp_csv_path.exists():
            tmp_csv_path.unlink()
          # コンパイル済み辞書をすべて削除
          _unlink_without_error(directory.glob("*特定のpotfix.txt"))

python版jpreprocessとかができたらこの辺り抜本的に解決ができるかもですし、一旦ここはフォールバックを実装するのはどうでしょうか。。

あ、もちろんFILE_FLAG_DELETE_ON_CLOSEで削除する関数を実行した後に↑のフォールバックを実行するのでも良いと思います!
でもなんかmmap + delete_on_close + shared_deleteがセグフォとかで落ちる気がしないでもないんですよね。。。。。 😇

@sabonerune
Copy link
Contributor

かなり調べたり調べてくださったりしたのに行き着いた結論がここ(アンドキュメント)なのは結構悔しいですが、どういう挙動になるかわからない以上フォールバックを用意する必要があるように感じました。

すいません、そもそも自分は「Windows上では開かれているファイルを削除することはできない」と勘違いしていたせいで

最終的に削除をする代わりにcdllでWin32APIのCreateFileWを直接使用してFILE_FLAG_DELETE_ON_CLOSEを付けてファイルを開いてすぐ閉じることで削除ができることが分かりました。

削除できるNamedTenporaryFile同様OpenJTalk側がファイルを閉じた時に自動的にファイルが削除されるようにできる。という意味で書いたことが抜け落ちていました。

FILE_FLAG_DELETE_ON_CLOSEで開いて閉じた場合削除されるタイミングはOpenJTalk側で閉じた時(正確には全てのハンドルが閉じられた時)なのでCreateFileMapping中は削除できないという仕様とは矛盾していません。
なので致命的なエラーになることはないように思えます。

@Hiroshiba
Copy link
Member Author

Hiroshiba commented Jan 14, 2025

あーーーたしかに!!!!!!

FILE_FLAG_DELETE_ON_CLOSEはPython側がcloseしたときにcloseが発動するんだと勝手に勘違いしてしまいましたが、そのファイルを開く全てのハンドラ?がcloseしたときに発動する、ってドキュメントにもちゃんと書いてますね!!!!

ファイルは、指定されたハンドルとその他の開いているハンドルまたは重複するハンドルを含む、すべてのハンドルが閉じられた直後に削除されます。

https://learn.microsoft.com/ja-jp/windows/win32/api/fileapi/nf-fileapi-createfilea#:~:text=%E3%81%A6%E3%81%8F%E3%81%A0%E3%81%95%E3%81%84%E3%80%82-,FILE_FLAG_DELETE_ON_CLOSE,-0x04000000

まあバージョンによって挙動が違うこととかはあり得なくはないですが、Windows10/11だったら流石に揃ってると信じたい。


よし!!
openjtalk側にFILE_SHARE_DELETEを付け、python側にはFILE_FLAG_DELETE_ON_CLOSE でファイル削除する関数を作ってset_user_dictしたファイルをすぐ削除する仕様にしますか・・・!!!

実装面ですが、たぶんcompiled_dict_path引数や_COMPILED_DICT_PATHを消してしまって、代わりに_user_dict_pathからtmp_csv_pathtmp_compiled_pathを作っちゃえば特に破壊的変更なくできそう・・・?
良ければPRいただけると嬉しいです!!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
優先度:中 重要 初心者歓迎タスク 初心者にも優しい簡単めなタスク 機能向上 状態:実装者募集 実装者を募集している状態
Projects
None yet
Development

No branches or pull requests

4 participants