From 01c45c8d909b32667fbb2ce4988faed70d1cf83b Mon Sep 17 00:00:00 2001 From: Jia Sui Date: Fri, 2 Oct 2026 20:15:41 +0800 Subject: [PATCH] =?UTF-8?q?fix(server,ui-html,engine):=20octagram=20?= =?UTF-8?q?=E6=BF=80=E6=B4=BB=E4=B8=AD=E7=9A=84=E6=A8=A1=E5=9E=8B=E5=8C=85?= =?UTF-8?q?=E4=B9=9F=E5=8F=AF=E5=88=A0=E9=99=A4=EF=BC=8C=E5=BC=95=E6=93=8E?= =?UTF-8?q?=E6=98=A0=E5=B0=84=E6=94=AF=E6=8C=81=E5=8A=A8=E6=80=81=E9=87=8A?= =?UTF-8?q?=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- engine/ngram/octagram/octagram_gram.cpp | 39 +++++++-- engine/ngram/octagram/octagram_gram.h | 14 ++- engine/quanpin/lattice_rerank.cpp | 2 +- engine/quanpin/lattice_rerank.h | 2 +- engine/quanpin/quanpin_dictionary.h | 6 +- engine/shuangpin/shuangpin_dictionary.h | 2 +- server/src/settings/collocation_model.cpp | 87 +++++++++++++++---- server/src/settings/collocation_model.h | 7 +- server/tests/src/test_collocation_model.cpp | 18 ++-- .../ime-settings/src/modules/shared.test.ts | 6 +- .../ime-settings/src/modules/shared.ts | 6 +- 11 files changed, 143 insertions(+), 46 deletions(-) diff --git a/engine/ngram/octagram/octagram_gram.cpp b/engine/ngram/octagram/octagram_gram.cpp index 9c2cd432..4326b60f 100644 --- a/engine/ngram/octagram/octagram_gram.cpp +++ b/engine/ngram/octagram/octagram_gram.cpp @@ -326,23 +326,46 @@ double GramDb::query(const std::string &context, const std::string &word, bool i // ---- 进程内共享 -------------------------------------------------------------- -const GramDb *shared_gram_db(const std::filesystem::path &file) +namespace +{ +// 缓存的锁与存储提升到文件作用域:shared_gram_db_evict 要在同一把锁下摘引用。 +std::mutex &shared_cache_mutex() { - // 照 ngram::shared_language_model:实例进程生命周期内常驻(故意不释放—— - // 互斥锁的竞态和析构顺序问题用「永不析构」一并解决),mmap 的物理内存由 - // 操作系统按命中页回收,真正占住的是地址空间。 static std::mutex mutex; - static std::unordered_map> cache; + return mutex; +} + +std::unordered_map> &shared_cache() +{ + static std::unordered_map> cache; + return cache; +} +} // namespace + +std::shared_ptr shared_gram_db(const std::filesystem::path &file) +{ + // 照 ngram::shared_language_model:实例按路径缓存、多会话共享。缓存引用可以 + // 被 shared_gram_db_evict 摘掉(设置侧删除模型包后),词典在下一次 + // set_sentence_association 时松手;在飞查询按值持有 shared_ptr 副本,实例的 + // 析构时机由引用计数兜底——没有「永不析构」,也就没有删不掉的文件。 const std::string key = file.u8string(); - std::lock_guard lock(mutex); + std::lock_guard lock(shared_cache_mutex()); + auto &cache = shared_cache(); auto &slot = cache[key]; if (!slot) { - auto db = std::make_unique(); + auto db = std::make_shared(); db->open(file); slot = std::move(db); } - return slot.get(); + return slot; +} + +void shared_gram_db_evict(const std::filesystem::path &file) +{ + const std::string key = file.u8string(); + std::lock_guard lock(shared_cache_mutex()); + shared_cache().erase(key); } } // namespace gram diff --git a/engine/ngram/octagram/octagram_gram.h b/engine/ngram/octagram/octagram_gram.h index 43ee765c..fd016afe 100644 --- a/engine/ngram/octagram/octagram_gram.h +++ b/engine/ngram/octagram/octagram_gram.h @@ -114,9 +114,17 @@ std::string first_codepoints(std::string_view utf8, size_t n); std::string last_codepoints(std::string_view utf8, size_t n); // 进程内按路径共享的模型,照 ngram::shared_language_model 的范式:查询全是 -// 只读(mmap 页 + const 方法),多会话可同时持有返回的指针;载入失败返回 -// valid() == false 的实例,调用方判空降级。全拼和双拼词典各持一份指针、共享 +// 只读(mmap 页 + const 方法),多会话可同时持有返回的引用;载入失败返回 +// valid() == false 的实例,调用方判空降级。全拼和双拼词典各持一份引用、共享 // 同一份映射,不重复付 390MB 的物理内存。 -const GramDb *shared_gram_db(const std::filesystem::path &file); +// +// 引用是 shared_ptr:查询按值持有副本,在飞查询不受缓存淘汰影响。设置侧删除 +// 模型包后调 shared_gram_db_evict 摘掉缓存引用,词典在下一次 +// set_sentence_association(换路径或留空)时松手,最后一个引用松开才真正 +// unmap,被删的 .gram 随之可以物理删除。 +std::shared_ptr shared_gram_db(const std::filesystem::path &file); + +// 摘掉指定路径的缓存引用(不强制析构:仍有会话持有时由 shared_ptr 兜底)。 +void shared_gram_db_evict(const std::filesystem::path &file); } // namespace gram diff --git a/engine/quanpin/lattice_rerank.cpp b/engine/quanpin/lattice_rerank.cpp index 0adf2373..120f92a0 100644 --- a/engine/quanpin/lattice_rerank.cpp +++ b/engine/quanpin/lattice_rerank.cpp @@ -54,7 +54,7 @@ LatticeReranker make_neural_reranker(const neural::SentenceModel *model, const s }; } -LatticeReranker make_octagram_reranker(const gram::GramDb *db, double weight) +LatticeReranker make_octagram_reranker(const std::shared_ptr &db, double weight) { if (db == nullptr || !db->valid() || weight == 0.0) { diff --git a/engine/quanpin/lattice_rerank.h b/engine/quanpin/lattice_rerank.h index cf38491a..56bbef9e 100644 --- a/engine/quanpin/lattice_rerank.h +++ b/engine/quanpin/lattice_rerank.h @@ -30,6 +30,6 @@ LatticeReranker make_neural_reranker(const neural::SentenceModel *model, const s // with the rear term on the sentence-final word). Empty std::function when db is null or weight is // zero — the feature is off, the lattice's own order stands. Synchronous and read-only (mmap'd // trie), unlike the neural reranker's background thread. -LatticeReranker make_octagram_reranker(const gram::GramDb *db, double weight); +LatticeReranker make_octagram_reranker(const std::shared_ptr &db, double weight); } // namespace quanpin diff --git a/engine/quanpin/quanpin_dictionary.h b/engine/quanpin/quanpin_dictionary.h index 9b090a08..e3dc2c9b 100644 --- a/engine/quanpin/quanpin_dictionary.h +++ b/engine/quanpin/quanpin_dictionary.h @@ -160,8 +160,10 @@ class QuanpinDictionary // valid() 为假,word_lattice 退回旧的启发式打分:整句候选只是变差,不会消失。 const ngram::LanguageModel *language_model_ = nullptr; // 万象语法模型(octagram .gram),随 set_sentence_association 的模型路径解析, - // 进程内按路径共享、mmap 只读。路径为空或无效时为 nullptr,词格退回无搭配打分。 - const gram::GramDb *collocation_db_ = nullptr; + // 进程内按路径共享、mmap 只读。路径为空或无效时为空,词格退回无搭配打分。 + // shared_ptr 持有:设置侧删除模型包 evict 缓存并清掉这里的引用后,映射即可 + // 释放,被删的文件能被物理清扫。 + std::shared_ptr collocation_db_ = nullptr; // 神经整句模型(chinese-ime-lm),按资源路径进程内共享、只读。两档可同时参与打分; // 缺文件时对应的整句候选不出,不影响其它候选。对应开关第一次生效时才载入, // 关着的那档不占内存。 diff --git a/engine/shuangpin/shuangpin_dictionary.h b/engine/shuangpin/shuangpin_dictionary.h index f697b9eb..43b4f9a3 100644 --- a/engine/shuangpin/shuangpin_dictionary.h +++ b/engine/shuangpin/shuangpin_dictionary.h @@ -101,7 +101,7 @@ class ShuangpinDictionary // 与全拼共用的词格打分模型,见 QuanpinDictionary::language_model_。 const ngram::LanguageModel *language_model_ = nullptr; // 万象语法模型(octagram .gram),见 QuanpinDictionary::collocation_db_。 - const gram::GramDb *collocation_db_ = nullptr; + std::shared_ptr collocation_db_ = nullptr; // 神经整句模型,见 QuanpinDictionary::neural_desktop_model_。与全拼共用同一份 // 进程内缓存(按资源路径),双拼这侧只是各自持一份句柄,开关生效时才载入。 neural::LazySentenceModel neural_desktop_model_; diff --git a/server/src/settings/collocation_model.cpp b/server/src/settings/collocation_model.cpp index 58cdc3e0..8e02b5c5 100644 --- a/server/src/settings/collocation_model.cpp +++ b/server/src/settings/collocation_model.cpp @@ -69,6 +69,28 @@ std::filesystem::path PartFile(const std::string &model_id) return ModelDirectory(model_id) / (model_id + ".gram.part"); } +// 逻辑删除的清扫:.gram 被引擎的内存映射占着时物理删除会失败,DeleteModel 落下的 +// .trash 标记把目录转成「待清扫」。这里在状态查询与重新下载前重试,映射一旦释放 +// (切换模型或重启 Server)目录就被清干净。 +void PurgeIfTrashed(const std::string &model_id) +{ + std::error_code error; + const std::filesystem::path trash = ModelDirectory(model_id) / ".trash"; + if (!std::filesystem::exists(trash, error) || error) + { + return; + } + if (std::filesystem::exists(ModelFile(model_id), error) && !error) + { + std::filesystem::remove(ModelFile(model_id), error); + if (error) + { + return; + } + } + std::filesystem::remove_all(ModelDirectory(model_id), error); +} + // 目录条目的 host/path 都是 ASCII(域名与仓库路径),NOTICE.md 是 UTF-8 文本,写之前把 // 宽字符转回来;走 CP_UTF8 而不是逐字节截断,将来条目里出现非 ASCII 也不会产出坏字节。 std::string Utf8FromWide(const wchar_t *wide) @@ -297,22 +319,37 @@ std::map GetModelStatuses() std::map statuses; for (const auto &entry : Catalog()) { + PurgeIfTrashed(entry.id); + std::error_code fs_error; + // runtime 下载与错误态优先于磁盘:刚失败的落位比「旧的已验证文件还在」更值得 + // 看见。逻辑已删除(.trash 标记在)一律按未下载呈现,哪怕 .gram 还没物理消失。 + { + std::lock_guard lock(g_mutex); + const auto it = g_states.find(entry.id); + if (it != g_states.end() && it->second.state == DownloadState::Downloading) + { + statuses[entry.id] = {"downloading", it->second.progress, {}}; + continue; + } + if (it != g_states.end() && it->second.state == DownloadState::Error) + { + statuses[entry.id] = {"error", 0, it->second.error}; + continue; + } + } + if (std::filesystem::exists(ModelDirectory(entry.id) / ".trash", fs_error) && !fs_error) + { + statuses[entry.id] = {"absent", 0, {}}; + continue; + } // 落位发生在格式校验之后,所以「文件存在」即代表曾经通过校验。这里不再比对 // 字节数:钉死它就等于钉死上游的每次重训。 - std::error_code fs_error; if (std::filesystem::exists(ModelFile(entry.id), fs_error) && !fs_error) { statuses[entry.id] = {"ready", 100, {}}; continue; } - std::lock_guard lock(g_mutex); - const auto it = g_states.find(entry.id); - if (it == g_states.end() || it->second.state == DownloadState::Idle) - statuses[entry.id] = {"absent", 0, {}}; - else if (it->second.state == DownloadState::Downloading) - statuses[entry.id] = {"downloading", it->second.progress, {}}; - else - statuses[entry.id] = {"error", 0, it->second.error}; + statuses[entry.id] = {"absent", 0, {}}; } return statuses; } @@ -322,10 +359,13 @@ bool StartDownload(const std::string &model_id) const CatalogEntry *entry = FindEntry(model_id); if (entry == nullptr) return false; - // 已存在且通过格式校验的包不重下:落位只在格式校验之后发生,文件存在即曾通过校验, - // 重下只会白白覆盖一份好包。 + PurgeIfTrashed(model_id); std::error_code fs_error; - if (std::filesystem::exists(ModelFile(model_id), fs_error) && !fs_error) + // 已存在且通过格式校验的包不重下:落位只在格式校验之后发生,文件存在即曾通过校验, + // 重下只会白白覆盖一份好包。逻辑已删除(.trash 在)的包不在此列:它们必须允许重新 + // 下载,哪怕旧 .gram 的物理删除还在等映射释放(落位会在映射释放后成功)。 + const bool trashed = std::filesystem::exists(ModelDirectory(model_id) / ".trash", fs_error) && !fs_error; + if (!trashed && std::filesystem::exists(ModelFile(model_id), fs_error) && !fs_error) return true; { std::lock_guard lock(g_mutex); @@ -355,15 +395,30 @@ bool DeleteModel(const std::string &model_id) if (it != g_states.end() && it->second.state == DownloadState::Downloading) return false; } - // 当前显式激活的包不许删:删掉正在使用的包会让整句加成静默失效,输入侧看不到任何 - // 报错。激活值留空(未选择)时没有受保护的目标,任何包都可删。 - if (model_id == GetConfiguredAssocSentenceCollocationModel()) + PurgeIfTrashed(model_id); + // 删的是当前激活的包时先清激活再删文件:清空写入失败就原地返回 false,什么都没 + // 发生,用户重试即可;反过来先删文件再清空,一次写入失败就会留下「指向已删除包」 + // 的僵尸选中态。未选择(留空)时没有需要清的目标。 + if (model_id == GetConfiguredAssocSentenceCollocationModel() && !SetConfiguredAssocSentenceCollocationModel("")) + { return false; + } std::error_code error; // .gram 与 NOTICE.md 都在这个目录里,整目录删除即卸载;目录不存在时 remove_all // 无错返回,删除因此是幂等的。 std::filesystem::remove_all(ModelDirectory(model_id), error); - return !error; + if (std::filesystem::exists(ModelFile(model_id), error)) + { + // .gram 还被引擎的内存映射占着(激活后打过字就会映射,句柄不带 + // FILE_SHARE_DELETE),物理删除要等引用释放。落一个标记把删除降级为逻辑删除: + // 状态按未下载呈现、不可选中;下面的 evict 摘掉缓存引用后,词典在下一次应用 + // 配置时松手,状态查询的重试清扫就能真正删掉文件。 + std::ofstream trash(ModelDirectory(model_id) / ".trash", std::ios::binary | std::ios::trunc); + } + // 引擎缓存里这个路径的映射引用摘掉:词典在下一次应用配置(击键)松手后, + // 上面的 .trash 清扫就能真正物理删除残留的 .gram(不用等重启)。 + gram::shared_gram_db_evict(ModelFile(model_id)); + return true; } } // namespace collocation diff --git a/server/src/settings/collocation_model.h b/server/src/settings/collocation_model.h index a7a56ed8..0cd315f2 100644 --- a/server/src/settings/collocation_model.h +++ b/server/src/settings/collocation_model.h @@ -59,9 +59,10 @@ std::map GetModelStatuses(); // false,原因记入该 id 的状态。完成/失败经状态查询可见。 bool StartDownload(const std::string &model_id); -// 删除已下载的模型目录(.gram 与 NOTICE.md 一并)。守卫:该 id 下载中拒绝;id 为当前显式 -// 激活的包拒绝——删掉正在用的包会让整句加成静默失效。激活值留空(未选择)时没有受保护 -// 的目标。目录里没有的 id 返回 false。 +// 删除已下载的模型目录(.gram 与 NOTICE.md 一并)。守卫:该 id 下载中拒绝。删除当前 +// 激活的包时激活值一并清空(未选择),整句加成随之关闭,重新下载后需重新激活。若 .gram +// 还被引擎的内存映射占用,物理删除推迟到映射释放(状态查询按未下载呈现,重启 Server +// 必然清掉)。目录里没有的 id 返回 false。 bool DeleteModel(const std::string &model_id); } // namespace collocation diff --git a/server/tests/src/test_collocation_model.cpp b/server/tests/src/test_collocation_model.cpp index f11697ec..2f2070ac 100644 --- a/server/tests/src/test_collocation_model.cpp +++ b/server/tests/src/test_collocation_model.cpp @@ -183,18 +183,24 @@ TEST_CASE(collocation_activation_write_and_delete_guard) REQUIRE(SetConfiguredAssocSentenceCollocationModel("")); REQUIRE(GetConfiguredAssocSentenceCollocationModel().empty()); - // 未选择时没有受保护目标:推荐包也只是普通目录条目,删掉就是删掉。 + // 未选择时任何包都可删:目录条目没有受保护的目标。 SeedModelFile(env.models_dir(), L"wanxiang-lts-zh-hans"); - SeedModelFile(env.models_dir(), L"zh-moqi"); REQUIRE(collocation::DeleteModel(collocation::kRecommendedModelId)); REQUIRE(!std::filesystem::exists(env.models_dir() / L"wanxiang-lts-zh-hans")); - REQUIRE(collocation::DeleteModel("zh-moqi")); - REQUIRE(!std::filesystem::exists(env.models_dir() / L"zh-moqi")); - // 显式激活 zh-moqi 后守卫换目标:它不可删,其他包不受影响。 + // 删除当前激活的包:文件与激活一并清掉——配置指向不存在的包只会留下僵尸选中态。 SeedModelFile(env.models_dir(), L"zh-moqi"); REQUIRE(SetConfiguredAssocSentenceCollocationModel("zh-moqi")); - REQUIRE(!collocation::DeleteModel("zh-moqi")); + REQUIRE(collocation::DeleteModel("zh-moqi")); + REQUIRE(!std::filesystem::exists(env.models_dir() / L"zh-moqi")); + REQUIRE(GetConfiguredAssocSentenceCollocationModel().empty()); + + // 删除非激活的包不动激活值:显式激活 zh-moqi 后删推荐包,激活保持不变。 + SeedModelFile(env.models_dir(), L"zh-moqi"); + SeedModelFile(env.models_dir(), L"wanxiang-lts-zh-hans"); + REQUIRE(SetConfiguredAssocSentenceCollocationModel("zh-moqi")); + REQUIRE(collocation::DeleteModel(collocation::kRecommendedModelId)); + REQUIRE_EQ(GetConfiguredAssocSentenceCollocationModel(), std::string("zh-moqi")); REQUIRE(std::filesystem::exists(env.models_dir() / L"zh-moqi" / L"zh-moqi.gram")); // 还原激活状态,不把测试残留带给同进程的后续用例。 REQUIRE(SetConfiguredAssocSentenceCollocationModel("")); diff --git a/ui-html/webview2/settings/ime-settings/src/modules/shared.test.ts b/ui-html/webview2/settings/ime-settings/src/modules/shared.test.ts index 45fd08bb..b32f4725 100644 --- a/ui-html/webview2/settings/ime-settings/src/modules/shared.test.ts +++ b/ui-html/webview2/settings/ime-settings/src/modules/shared.test.ts @@ -288,13 +288,15 @@ describe('collocation model status gating', () => { expect(removeOf(moqi).hidden).toBe(true); }); - it('activates a ready model, unguards the toggles, and offers deletion only for inactive rows', () => { + it('activates a ready model, unguards the toggles, and offers deletion for every downloaded row', () => { applyCollocationModelStatus({ [RECOMMENDED_ID]: { state: 'ready' }, 'zh-moqi': { state: 'ready' } }, CATALOG, 'zh-moqi'); expect(radioOf(rowById('zh-moqi')).disabled).toBe(false); expect(radioOf(rowById('zh-moqi')).checked).toBe(true); // 显式激活只选中自己:推荐行不再有「留空回退」的隐式选中。 expect(radioOf(rowById(RECOMMENDED_ID)).checked).toBe(false); - expect(removeOf(rowById('zh-moqi')).hidden).toBe(true); + // 删除对每一行就绪包开放(含激活中的行):宿主删除激活包时会一并清空激活值, + // 快照回放后该行回到未下载、未选中。 + expect(removeOf(rowById('zh-moqi')).hidden).toBe(false); expect(removeOf(rowById(RECOMMENDED_ID)).hidden).toBe(false); // 生效解析 = zh-moqi 已就绪 → 整句开关解禁。 for (const id of TOGGLE_IDS) { diff --git a/ui-html/webview2/settings/ime-settings/src/modules/shared.ts b/ui-html/webview2/settings/ime-settings/src/modules/shared.ts index 1bc8dc7f..06bd9f09 100644 --- a/ui-html/webview2/settings/ime-settings/src/modules/shared.ts +++ b/ui-html/webview2/settings/ime-settings/src/modules/shared.ts @@ -139,9 +139,9 @@ export function applyCollocationModelStatus( if (download) download.disabled = state === 'downloading' || state === 'ready' || anyDownloading; const remove = row.querySelector('.collocation-model-delete'); if (remove) { - // 删除守卫在宿主侧再拦一次;这里把明显不可删的藏掉:没下载的、下载中的、 - // 当前生效解析的包。 - remove.hidden = state !== 'ready' || id === activeId; + // 任何就绪的包都可删(包括当前激活的包):宿主删除激活包时会一并清空激活值, + // 快照回放后该行回到未下载、未选中。 + remove.hidden = state !== 'ready'; } const radio = row.querySelector('input[type="radio"]'); if (radio) {