From c4cbcc60a9e962dc158ed27438cbce303d11744c Mon Sep 17 00:00:00 2001 From: zefutoff Date: Sun, 4 Oct 2026 22:12:15 +0200 Subject: [PATCH 1/2] fix: serialize IME engine lifecycle --- CHANGELOG.md | 1 + ROADMAP.md | 2 +- .../unofficial/GrammalecteImeService.kt | 23 +++++-- .../engine/RealEngineSmokeTest.kt | 69 +++++++++++++++++++ 4 files changed, 90 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f623113..2cbf417 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - Local and CI checks now share the same `make check` and `make assemble` entry points. - Grammalecte vendoring is normalized and checked for reproducible output. - CI verifies the permissions of the final debug APK in addition to source manifests. +- IME analysis now uses a single serialized background worker so QuickJS engine creation, evaluation and shutdown cannot race during rapid repeated corrections or service destruction. ### Fixed diff --git a/ROADMAP.md b/ROADMAP.md index 61aa638..a7ebb7c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -68,7 +68,7 @@ Exit criterion: an installable APK performs local French spelling and grammar co - [x] Validate clean replacement in SMS - [x] Add a basic user-friendly IME activation and selection flow - [x] Add an easy way to return to the previous keyboard -- [ ] Review QuickJS execution serialization inside the IME +- [x] Review QuickJS execution serialization inside the IME - [ ] Add automated IME integration tests where practical - [x] Cover IME discovery, permission and metadata - [ ] Cover IME lifecycle and selected-text replacement through `InputConnection` diff --git a/app/src/main/kotlin/fr/grammalecteandroid/unofficial/GrammalecteImeService.kt b/app/src/main/kotlin/fr/grammalecteandroid/unofficial/GrammalecteImeService.kt index 9df4f47..7bc9688 100644 --- a/app/src/main/kotlin/fr/grammalecteandroid/unofficial/GrammalecteImeService.kt +++ b/app/src/main/kotlin/fr/grammalecteandroid/unofficial/GrammalecteImeService.kt @@ -17,9 +17,14 @@ import android.widget.ScrollView import android.widget.TextView import fr.grammalecteandroid.core.GrammarIssue import fr.grammalecteandroid.engine.GrammalecteQuickJsEngine +import java.util.concurrent.Executors class GrammalecteImeService : InputMethodService() { private val mainHandler = Handler(Looper.getMainLooper()) + private val analysisExecutor = + Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "GrammalecteImeAnalysis") + } private lateinit var selectedTextView: TextView private lateinit var statusView: TextView private lateinit var issuesLayout: LinearLayout @@ -32,7 +37,10 @@ class GrammalecteImeService : InputMethodService() { private var originalSelectedText = "" private var latestIssues: List = emptyList() + // The engine is created, used and closed only on analysisExecutor. private var engine: GrammalecteQuickJsEngine? = null + + @Volatile private var analysisGeneration = 0 override fun onCreateInputView(): View { @@ -335,7 +343,11 @@ class GrammalecteImeService : InputMethodService() { latestIssues = emptyList() setApplyAllButtonsEnabled(false) - Thread { + analysisExecutor.execute { + if (generation != analysisGeneration) { + return@execute + } + val result = runCatching { val currentEngine = @@ -370,7 +382,7 @@ class GrammalecteImeService : InputMethodService() { setApplyButtonsEnabled(false) } } - }.start() + } } private fun renderIssues( @@ -757,8 +769,11 @@ class GrammalecteImeService : InputMethodService() { override fun onDestroy() { analysisGeneration++ - engine?.close() - engine = null + analysisExecutor.execute { + engine?.close() + engine = null + } + analysisExecutor.shutdown() super.onDestroy() } diff --git a/engine-grammalecte/src/test/kotlin/fr/grammalecteandroid/engine/RealEngineSmokeTest.kt b/engine-grammalecte/src/test/kotlin/fr/grammalecteandroid/engine/RealEngineSmokeTest.kt index f6bfc40..9a7fb72 100644 --- a/engine-grammalecte/src/test/kotlin/fr/grammalecteandroid/engine/RealEngineSmokeTest.kt +++ b/engine-grammalecte/src/test/kotlin/fr/grammalecteandroid/engine/RealEngineSmokeTest.kt @@ -8,6 +8,9 @@ import org.junit.Test import java.nio.charset.StandardCharsets import java.nio.file.Files import java.nio.file.Path +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit class RealEngineSmokeTest { @Test @@ -61,6 +64,72 @@ class RealEngineSmokeTest { } } + @Test + fun concurrentCallsShareRuntimeSafely() { + val engine = + GrammalecteQuickJsEngine( + FileAssetTextLoader(findAssetRoot()), + ) + + val executor = Executors.newFixedThreadPool(4) + val startGate = CountDownLatch(1) + + try { + val futures = + List(4) { index -> + executor.submit { + startGate.await() + + if (index % 2 == 0) { + val text = "Je suis aller au magasin." + + engine + .check( + text = text, + localeTag = "fr-FR", + ).any { issue -> + text.substring( + issue.start, + issue.endExclusive, + ) == "aller" + } + } else { + val check = + engine.checkWord( + word = "magazin", + localeTag = "fr-FR", + suggestionLimit = 8, + ) + + !check.valid && + "magasin" in check.suggestions + } + } + } + + startGate.countDown() + + futures.forEachIndexed { index, future -> + assertTrue( + "Concurrent engine call $index failed", + future.get( + 60, + TimeUnit.SECONDS, + ), + ) + } + } finally { + startGate.countDown() + executor.shutdown() + + if (!executor.awaitTermination(60, TimeUnit.SECONDS)) { + executor.shutdownNow() + } + + engine.close() + } + } + private fun findAssetRoot(): Path { val candidates = listOf( From 9ab734c38ff238c1ee9cd28f4dbfacf72b8dd43e Mon Sep 17 00:00:00 2001 From: zefutoff Date: Sun, 4 Oct 2026 22:39:09 +0200 Subject: [PATCH 2/2] build: stabilize Grammalecte plugin ordering --- .../assets/grammalecte/fr/gc_functions.js | 350 +++++++++--------- tools/vendor-grammalecte.sh | 28 ++ 2 files changed, 203 insertions(+), 175 deletions(-) diff --git a/engine-grammalecte/src/main/assets/grammalecte/fr/gc_functions.js b/engine-grammalecte/src/main/assets/grammalecte/fr/gc_functions.js index f249e9f..9bc2d94 100644 --- a/engine-grammalecte/src/main/assets/grammalecte/fr/gc_functions.js +++ b/engine-grammalecte/src/main/assets/grammalecte/fr/gc_functions.js @@ -690,6 +690,181 @@ function g_setmeta (oToken, sType) { +// GRAMMAR CHECKING ENGINE PLUGIN: Parsing functions for French language + +/* jshint esversion:6 */ +/* jslint esversion:6 */ + +function g_morphVC (oToken, sPattern, sNegPattern="") { + let nEnd = oToken["sValue"].lastIndexOf("-"); + if (oToken["sValue"].gl_count("-") > 1) { + if (oToken["sValue"].includes("-t-")) { + nEnd = nEnd - 2; + } + else if (oToken["sValue"].search(/-l(?:es?|a)-(?:[mt]oi|nous|leur)$|(?:[nv]ous|lui|leur)-en$/) != -1) { + nEnd = oToken["sValue"].slice(0,nEnd).lastIndexOf("-"); + } + } + return g_morph(oToken, sPattern, sNegPattern, 0, nEnd); +} + +function apposition (sWord1, sWord2) { + // returns true if nom + nom (no agreement required) + return sWord2.length < 2 || (cregex.mbNomNotAdj(gc_engine.oSpellChecker.getMorph(sWord2)) && cregex.mbPpasNomNotAdj(gc_engine.oSpellChecker.getMorph(sWord1))); +} + +function g_agreement (oToken1, oToken2, bNotOnlyNames=true) { + // check agreement between and + let lMorph1 = oToken1.hasOwnProperty("lMorph") ? oToken1["lMorph"] : gc_engine.oSpellChecker.getMorph(oToken1["sValue"]); + if (lMorph1.length === 0) { + return true; + } + let lMorph2 = oToken2.hasOwnProperty("lMorph") ? oToken2["lMorph"] : gc_engine.oSpellChecker.getMorph(oToken2["sValue"]); + if (lMorph2.length === 0) { + return true; + } + if (bNotOnlyNames && !(cregex.mbAdj(lMorph2) || cregex.mbAdjNb(lMorph1))) { + return false; + } + return cregex.agreement(lMorph1, lMorph2); +} + +function mbUnit (s) { + if (/[µ\/⁰¹²³⁴⁵⁶⁷⁸⁹Ωℓ·]/.test(s)) { + return true; + } + if (s.length > 1 && s.length < 16 && s.slice(0, 1).gl_isLowerCase() && (!s.slice(1).gl_isLowerCase() || /[0-9]/.test(s))) { + return true; + } + return false; +} + +function queryNamesPOS (sWord1, sWord2) { + let lMorph1 = gc_engine.oSpellChecker.getMorph(sWord1); + let lMorph2 = gc_engine.oSpellChecker.getMorph(sWord2); + if (lMorph1.length == 0 || lMorph2.length == 0) { + return ":N:e:p"; + } + let [sGender1, ] = cregex.getGenderNumber(lMorph1); + let [sGender2, ] = cregex.getGenderNumber(lMorph2); + if (sGender1 == ":m" || sGender2 == ":m") { + return ":N:m:p"; + } + if (sGender1 == ":f" || sGender2 == ":f") { + return ":N:f:p"; + } + return ":N:e:p"; +} + + +// GRAMMAR CHECKING ENGINE PLUGIN + +// Check date validity +// WARNING: when creating a Date, month must be between 0 and 11 + +/* jshint esversion:6 */ +/* jslint esversion:6 */ + + +const _lDay = ["dimanche", "lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi"]; +const _dMonth = new Map ([ + ["janvier", 1], ["février", 2], ["mars", 3], ["avril", 4], ["mai", 5], ["juin", 6], ["juillet", 7], + ["août", 8], ["aout", 8], ["septembre", 9], ["octobre", 10], ["novembre", 11], ["décembre", 12] +]); +const _dDaysInMonth = new Map ([ + [1, 31], [2, 28], [3, 31], [4, 30], [5, 31], [6, 30], [7, 31], + [8, 31], [8, 31], [9, 30], [10, 31], [11, 30], [12, 31] +]); + +// Dans Python, datetime.weekday() envoie le résultat comme si nous étions dans un calendrier grégorien universal. +// https://fr.wikipedia.org/wiki/Passage_du_calendrier_julien_au_calendrier_gr%C3%A9gorien +// Selon Grégoire, le jeudi 4 octobre 1582 est immédiatement suivi par le vendredi 15 octobre. +// En France, la bascule eut lieu le 9 décembre 1582 qui fut suivi par le 20 décembre 1582. +// C’est la date retenue pour la bascule dans Grammalecte, mais le calendrier grégorien fut adopté dans le monde diversement. +// Il fallut des siècles pour qu’il soit adopté par l’Occident et une grande partie du reste du monde. +const _dGregorianToJulian = new Map ([ + ["lundi", "jeudi"], + ["mardi", "vendredi"], + ["mercredi", "samedi"], + ["jeudi", "dimanche"], + ["vendredi", "lundi"], + ["samedi", "mardi"], + ["dimanche", "mercredi"] +]); + +function _checkDate (nDay, nMonth, nYear) { + // returns true or false + if (nMonth > 12 || nMonth < 1 || nDay > 31 || nDay < 1) { + return false; + } + if (nDay <= _dDaysInMonth.get(nMonth)) { + return true; + } + if (nDay === 29) { + // leap years, http://jsperf.com/ily/15 + return !(nYear & 3 || !(nYear % 25) && nYear & 15); + } + return false; +} + +function checkDate (sDay, sMonth, sYear) { + // return True if the date is valid + if (!sMonth.gl_isDigit()) { + sMonth = _dMonth.get(sMonth.toLowerCase()); + } + if (_checkDate(parseInt(sDay, 10), parseInt(sMonth, 10), parseInt(sYear, 10))) { + return new Date(parseInt(sYear, 10), parseInt(sMonth, 10)-1, parseInt(sDay, 10)); + } + return false; +} + +function checkDay (sWeekday, sDay, sMonth, sYear) { + // return True if sWeekday is valid according to the given date + let xDate = checkDate(sDay, sMonth, sYear); + if (xDate && _getDay(xDate) != sWeekday.toLowerCase()) { + return false; + } + // if the date isn’t valid, any day is valid. + return true; +} + +function getDay (sDay, sMonth, sYear) { + // return the day of the date (in Gregorian calendar after 1582-12-20, in Julian calendar before 1582-12-09) + let xDate = checkDate(sDay, sMonth, sYear); + if (xDate) { + return _getDay(xDate); + } + return "" +} + +function _getDay (xDate) { + // return the day of the date (in Gregorian calendar after 1582-12-20, in Julian calendar before 1582-12-09) + if (xDate.getFullYear() > 1582) { + // Calendrier grégorien + return _lDay[xDate.getDay()]; + } + if (xDate.getFullYear() < 1582) { + // Calendrier julien + let sGregorianDay = _lDay[xDate.getDay()]; + return _dGregorianToJulian.get(sGregorianDay, "Erreur: jour inconnu") + } + // 1582 + if ((xDate.getMonth()+1) < 12 || xDate.getDate() <= 9) { + // Calendrier julien + let sGregorianDay = _lDay[xDate.getDay()]; + return _dGregorianToJulian.get(sGregorianDay, "Erreur: jour inconnu"); + } + else if (xDate.getDate() >= 20) { + // Calendrier grégorien + return _lDay[xDate.getDay()]; + } + else { + // 10 - 19 décembre 1582: jours inexistants en France. + return ""; + } +} + + // GRAMMAR CHECKING ENGINE PLUGIN: Suggestion mechanisms /* jshint esversion:6 */ @@ -1497,181 +1672,6 @@ function normalizeInclusiveWriting (sToken) { } -// GRAMMAR CHECKING ENGINE PLUGIN - -// Check date validity -// WARNING: when creating a Date, month must be between 0 and 11 - -/* jshint esversion:6 */ -/* jslint esversion:6 */ - - -const _lDay = ["dimanche", "lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi"]; -const _dMonth = new Map ([ - ["janvier", 1], ["février", 2], ["mars", 3], ["avril", 4], ["mai", 5], ["juin", 6], ["juillet", 7], - ["août", 8], ["aout", 8], ["septembre", 9], ["octobre", 10], ["novembre", 11], ["décembre", 12] -]); -const _dDaysInMonth = new Map ([ - [1, 31], [2, 28], [3, 31], [4, 30], [5, 31], [6, 30], [7, 31], - [8, 31], [8, 31], [9, 30], [10, 31], [11, 30], [12, 31] -]); - -// Dans Python, datetime.weekday() envoie le résultat comme si nous étions dans un calendrier grégorien universal. -// https://fr.wikipedia.org/wiki/Passage_du_calendrier_julien_au_calendrier_gr%C3%A9gorien -// Selon Grégoire, le jeudi 4 octobre 1582 est immédiatement suivi par le vendredi 15 octobre. -// En France, la bascule eut lieu le 9 décembre 1582 qui fut suivi par le 20 décembre 1582. -// C’est la date retenue pour la bascule dans Grammalecte, mais le calendrier grégorien fut adopté dans le monde diversement. -// Il fallut des siècles pour qu’il soit adopté par l’Occident et une grande partie du reste du monde. -const _dGregorianToJulian = new Map ([ - ["lundi", "jeudi"], - ["mardi", "vendredi"], - ["mercredi", "samedi"], - ["jeudi", "dimanche"], - ["vendredi", "lundi"], - ["samedi", "mardi"], - ["dimanche", "mercredi"] -]); - -function _checkDate (nDay, nMonth, nYear) { - // returns true or false - if (nMonth > 12 || nMonth < 1 || nDay > 31 || nDay < 1) { - return false; - } - if (nDay <= _dDaysInMonth.get(nMonth)) { - return true; - } - if (nDay === 29) { - // leap years, http://jsperf.com/ily/15 - return !(nYear & 3 || !(nYear % 25) && nYear & 15); - } - return false; -} - -function checkDate (sDay, sMonth, sYear) { - // return True if the date is valid - if (!sMonth.gl_isDigit()) { - sMonth = _dMonth.get(sMonth.toLowerCase()); - } - if (_checkDate(parseInt(sDay, 10), parseInt(sMonth, 10), parseInt(sYear, 10))) { - return new Date(parseInt(sYear, 10), parseInt(sMonth, 10)-1, parseInt(sDay, 10)); - } - return false; -} - -function checkDay (sWeekday, sDay, sMonth, sYear) { - // return True if sWeekday is valid according to the given date - let xDate = checkDate(sDay, sMonth, sYear); - if (xDate && _getDay(xDate) != sWeekday.toLowerCase()) { - return false; - } - // if the date isn’t valid, any day is valid. - return true; -} - -function getDay (sDay, sMonth, sYear) { - // return the day of the date (in Gregorian calendar after 1582-12-20, in Julian calendar before 1582-12-09) - let xDate = checkDate(sDay, sMonth, sYear); - if (xDate) { - return _getDay(xDate); - } - return "" -} - -function _getDay (xDate) { - // return the day of the date (in Gregorian calendar after 1582-12-20, in Julian calendar before 1582-12-09) - if (xDate.getFullYear() > 1582) { - // Calendrier grégorien - return _lDay[xDate.getDay()]; - } - if (xDate.getFullYear() < 1582) { - // Calendrier julien - let sGregorianDay = _lDay[xDate.getDay()]; - return _dGregorianToJulian.get(sGregorianDay, "Erreur: jour inconnu") - } - // 1582 - if ((xDate.getMonth()+1) < 12 || xDate.getDate() <= 9) { - // Calendrier julien - let sGregorianDay = _lDay[xDate.getDay()]; - return _dGregorianToJulian.get(sGregorianDay, "Erreur: jour inconnu"); - } - else if (xDate.getDate() >= 20) { - // Calendrier grégorien - return _lDay[xDate.getDay()]; - } - else { - // 10 - 19 décembre 1582: jours inexistants en France. - return ""; - } -} - - -// GRAMMAR CHECKING ENGINE PLUGIN: Parsing functions for French language - -/* jshint esversion:6 */ -/* jslint esversion:6 */ - -function g_morphVC (oToken, sPattern, sNegPattern="") { - let nEnd = oToken["sValue"].lastIndexOf("-"); - if (oToken["sValue"].gl_count("-") > 1) { - if (oToken["sValue"].includes("-t-")) { - nEnd = nEnd - 2; - } - else if (oToken["sValue"].search(/-l(?:es?|a)-(?:[mt]oi|nous|leur)$|(?:[nv]ous|lui|leur)-en$/) != -1) { - nEnd = oToken["sValue"].slice(0,nEnd).lastIndexOf("-"); - } - } - return g_morph(oToken, sPattern, sNegPattern, 0, nEnd); -} - -function apposition (sWord1, sWord2) { - // returns true if nom + nom (no agreement required) - return sWord2.length < 2 || (cregex.mbNomNotAdj(gc_engine.oSpellChecker.getMorph(sWord2)) && cregex.mbPpasNomNotAdj(gc_engine.oSpellChecker.getMorph(sWord1))); -} - -function g_agreement (oToken1, oToken2, bNotOnlyNames=true) { - // check agreement between and - let lMorph1 = oToken1.hasOwnProperty("lMorph") ? oToken1["lMorph"] : gc_engine.oSpellChecker.getMorph(oToken1["sValue"]); - if (lMorph1.length === 0) { - return true; - } - let lMorph2 = oToken2.hasOwnProperty("lMorph") ? oToken2["lMorph"] : gc_engine.oSpellChecker.getMorph(oToken2["sValue"]); - if (lMorph2.length === 0) { - return true; - } - if (bNotOnlyNames && !(cregex.mbAdj(lMorph2) || cregex.mbAdjNb(lMorph1))) { - return false; - } - return cregex.agreement(lMorph1, lMorph2); -} - -function mbUnit (s) { - if (/[µ\/⁰¹²³⁴⁵⁶⁷⁸⁹Ωℓ·]/.test(s)) { - return true; - } - if (s.length > 1 && s.length < 16 && s.slice(0, 1).gl_isLowerCase() && (!s.slice(1).gl_isLowerCase() || /[0-9]/.test(s))) { - return true; - } - return false; -} - -function queryNamesPOS (sWord1, sWord2) { - let lMorph1 = gc_engine.oSpellChecker.getMorph(sWord1); - let lMorph2 = gc_engine.oSpellChecker.getMorph(sWord2); - if (lMorph1.length == 0 || lMorph2.length == 0) { - return ":N:e:p"; - } - let [sGender1, ] = cregex.getGenderNumber(lMorph1); - let [sGender2, ] = cregex.getGenderNumber(lMorph2); - if (sGender1 == ":m" || sGender2 == ":m") { - return ":N:m:p"; - } - if (sGender1 == ":f" || sGender2 == ":f") { - return ":N:f:p"; - } - return ":N:e:p"; -} - - // generated code, do not edit var gc_functions = { diff --git a/tools/vendor-grammalecte.sh b/tools/vendor-grammalecte.sh index 82329af..6658521 100755 --- a/tools/vendor-grammalecte.sh +++ b/tools/vendor-grammalecte.sh @@ -31,6 +31,34 @@ git -C "$SOURCE_DIR" remote add origin "$GRAMMALECTE_REPOSITORY" git -C "$SOURCE_DIR" fetch -q --depth 1 origin "$GRAMMALECTE_COMMIT" git -C "$SOURCE_DIR" checkout -q --detach FETCH_HEAD +# Grammalecte 2.3.0 concatenates JavaScript language plugins using +# os.listdir(), whose order depends on the filesystem. Sort these entries +# in the temporary upstream checkout so generated assets are reproducible. +MAKE_PY="$SOURCE_DIR/make.py" python3 - <<'PY_SORT_PLUGINS' +import os +from pathlib import Path + +path = Path(os.environ["MAKE_PY"]) +text = path.read_text(encoding="utf-8") + +old = 'for sf in os.listdir(spLang+"/modules-js"):' +new = 'for sf in sorted(os.listdir(spLang+"/modules-js")):' + +count = text.count(old) +if count != 2: + raise SystemExit( + f"Unexpected modules-js os.listdir occurrence count: {count} " + "(expected 2)" + ) + +path.write_text( + text.replace(old, new), + encoding="utf-8", +) + +print("Normalized Grammalecte modules-js iteration order") +PY_SORT_PLUGINS + ( cd "$SOURCE_DIR" python3 make.py fr -js