Recover Rime faults, persist window modes and fix GPU allocation

This commit is contained in:
aria committed 2026-10-28 02:32:19 +09:00
1 parent 8e9a5838ab
commit 6f34fd080f
27 files changed
+1260 -34

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
imports.gi.versions.Gtk = '4.0';
const {Gtk, Gio, GLib, Gdk} = imports.gi;
const output = ARGV[0];
const application = new Gtk.Application({application_id: 'dev.ferese.RimeValidation'});
const application = new Gtk.Application({application_id: ARGV[1] || 'dev.ferese.RimeValidation'});
application.connect('activate', () => {
const css = new Gtk.CssProvider(); css.load_from_string('window { font-family: "MiSans"; }');
Gtk.StyleContext.add_provider_for_display(Gdk.Display.get_default(), css, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION);
+40
View File
@@ -0,0 +1,40 @@
/* Disposable protocol locker: no credentials, no PAM bypass in real locker.
* Blank lock frames are presented by the compositor on every physical output.
* The release file instructs this actual protocol owner to unlock normally. */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <poll.h>
#include <unistd.h>
#include <wayland-client.h>
#include "ext-session-lock-client-protocol.h"
static struct ext_session_lock_manager_v1 *manager;
static int confirmed, outputs;
static void global(void *d, struct wl_registry *r, uint32_t n, const char *i, uint32_t v) {
(void)d; (void)v;
if (!strcmp(i,"ext_session_lock_manager_v1")) manager=wl_registry_bind(r,n,&ext_session_lock_manager_v1_interface,1);
if (!strcmp(i,"wl_output")) outputs++;
}
static void removed(void *d, struct wl_registry *r, uint32_t n) {(void)d;(void)r;(void)n;}
static void locked(void *d, struct ext_session_lock_v1 *l) {
(void)d;(void)l;confirmed=1;printf("{\"event\":\"locked\",\"outputs\":%d}\n",outputs);fflush(stdout);
}
static void finished(void *d, struct ext_session_lock_v1 *l) {(void)d;(void)l;exit(3);}
int main(int argc,char **argv) {
if (argc!=2 || !getenv("FERESE_TEST_LOCK") || strcmp(getenv("FERESE_TEST_LOCK"),"1")) return 2;
struct wl_display *display=wl_display_connect(NULL);if(!display)return 1;
struct wl_registry *registry=wl_display_get_registry(display);
const struct wl_registry_listener r={global,removed};wl_registry_add_listener(registry,&r,NULL);
if(wl_display_roundtrip(display)<0 || !manager)return 1;
struct ext_session_lock_v1 *lock=ext_session_lock_manager_v1_lock(manager);
const struct ext_session_lock_v1_listener listener={locked,finished};ext_session_lock_v1_add_listener(lock,&listener,NULL);
for (;;) {
if(wl_display_dispatch_pending(display)<0 || wl_display_flush(display)<0)return 1;
if(confirmed && access(argv[1],F_OK)==0) {
ext_session_lock_v1_unlock_and_destroy(lock);wl_display_roundtrip(display);
puts("{\"event\":\"unlocked\"}");return 0;
}
struct pollfd fd={wl_display_get_fd(display),POLLIN,0};
if(poll(&fd,1,100)>0 && wl_display_dispatch(display)<0)return 1;
}
}
+99
View File
@@ -0,0 +1,99 @@
"""Physical test against the separately started disposable logind session.
FERESE_TEST_DRM=1 python3 scripts/tests/test_rime_drm_isolated.py
Never starts/switches sessions itself. Requires its prepared lock-cycle binary.
"""
import importlib.util
import json
import os
from pathlib import Path
import subprocess
import time
import unittest
ROOT=Path(__file__).resolve().parents[2]
spec=importlib.util.spec_from_file_location("drm",ROOT/"scripts/rime-drm-validation.py")
d=importlib.util.module_from_spec(spec);spec.loader.exec_module(d)
@unittest.skipUnless(os.environ.get("FERESE_TEST_DRM")=="1","explicit physical-session validation")
class PhysicalRime(unittest.TestCase):
def test_three_outputs_and_lock_cycle(self):
for _ in range(100):
if (d.DATA/"context.json").exists() and (d.DATA/"entry-state.json").exists():break
time.sleep(.1)
c=json.loads((d.DATA/"context.json").read_text())
self.assertTrue(Path(f"/proc/{c['compositor_pid']}").exists())
self.assertNotEqual(subprocess.check_output(["loginctl","show-seat","seat0","-p","ActiveSession","--value"],text=True).strip(),d.original())
env={**os.environ,"XDG_RUNTIME_DIR":str(d.RUNTIME),"WAYLAND_DISPLAY":c["wayland"],
"DBUS_SESSION_BUS_ADDRESS":c["bus"],"FERESE_SOCKET":c["socket"],"GDK_BACKEND":"wayland",
"GTK_IM_MODULE":"wayland","FERESE_TEST_INPUT":"1","FERESE_TEST_DRM_PID":str(c["compositor_pid"]),
"FERESE_RIME_CONTROL":str(d.RUNTIME/"rime-control.sock"),"GTK_A11Y":"none","GSETTINGS_BACKEND":"keyfile"}
env["GSK_RENDERER"]=os.environ.get("FERESE_TEST_GSK","vulkan")
control=[str(ROOT/"target/debug/feresectl"),"--socket",c["socket"]]
def run(args):return subprocess.check_output(args,env=env,text=True,stderr=subprocess.PIPE).strip()
def ctl(*args):return json.loads(run([str(ROOT/"target/debug/ferese-rime"),*args]))["state"]
def wait(fn,message,timeout=5):
until=time.monotonic()+timeout
while time.monotonic()<until:
if fn():return
time.sleep(.1)
raise AssertionError(message)
def keys(*args):run([str(ROOT/"target/debug/ferese-rime-probe"),*args]);time.sleep(.1)
outputs=json.loads(run(control+["--json","outputs"]))
active=[o for o in outputs if o["enabled"]]
self.assertEqual({o["name"] for o in active},{"DP-4","DP-5","DP-6"})
checks=[];apps=[]
(d.DATA/"outputs.json").write_text(json.dumps(outputs,indent=2)+"\n")
try:
for output in sorted(active,key=lambda o:o["x"]):
name=output["name"]; app=f"dev.ferese.RimeDRM{output['id']}";file=d.DATA/f"entry-{name}.json"
file.unlink(missing_ok=True)
run(control+["focus-output",name])
proc=subprocess.Popen(["gjs",str(ROOT/"scripts/tests/fixtures/rime-entry.js"),str(file),app],env=env,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL);apps.append(proc)
wait(lambda:file.exists(),"Fresh GTK fixture did not initialize",timeout=10)
wait(lambda: (ctl("--status")["context"] or {}).get("app_id")==app,"Monitor fixture did not activate")
time.sleep(.25)
ctl("--mode","rime")
keys("--keys","nihao")
image=subprocess.check_output(control+["screenshot","--geometry",f"{output['x']},{output['y']} {output['width']}x{output['height']}"],env=env)
(d.DATA/f"candidate-{name}.png").write_bytes(image)
keys("--keys"," ")
wait(lambda:json.loads(file.read_text())["normal"]=="你好",f"Chinese input failed on {name}")
checks.append(f"{name}: Chinese preedit/candidates/commit at {output['scale']}x, {output['current_mode']['refresh_hz']} Hz")
keys("--keys","nihao")
release=d.DATA/"unlock-trigger";release.unlink(missing_ok=True)
log=(d.DATA/"lock-cycle.log").open("w")
locker=subprocess.Popen([str(d.DATA/"lock-cycle"),str(release)],env={**env,"FERESE_TEST_LOCK":"1"},stdout=log,stderr=subprocess.STDOUT)
try:
wait(lambda:'"event":"locked"' in (d.DATA/"lock-cycle.log").read_text(),"Physical outputs did not confirm protected presentation")
self.assertIn('"outputs":3',(d.DATA/"lock-cycle.log").read_text())
wait(lambda:not ctl("--status")["active"],"IME remained active while locked")
before=file.read_text();keys("--keys","secret-test")
self.assertEqual(file.read_text(),before)
screenshot=subprocess.run(control+["screenshot"],env=env,capture_output=True)
self.assertNotEqual(screenshot.returncode,0)
old=ctl("--status")["pid"]
subprocess.run(["systemctl","--user","kill","--signal=KILL",d.IME],env=d.manager_env(),check=True)
def recovered():
try:return ctl("--status")["pid"]!=old
except (subprocess.SubprocessError,ValueError):return False
wait(recovered,"IME did not recover during lock",timeout=8)
self.assertFalse(ctl("--status")["active"])
release.touch();self.assertEqual(locker.wait(timeout=5),0)
wait(lambda:ctl("--status")["active"],"Text input did not reactivate on unlock")
keys("--key","End","--keys","nihao ")
wait(lambda:json.loads(file.read_text())["normal"]=="你好你好","Chinese input failed after unlock")
checks.append("three physical outputs confirmed locked; candidates/input disabled; capture denied; IME restarted while locked; unlock restores Chinese")
finally:
release.touch()
if locker.poll() is None:locker.wait(timeout=5)
log.close()
(d.DATA/f"validation-{env['GSK_RENDERER']}.json").write_text(json.dumps({"status":"passed","renderer":env["GSK_RENDERER"],"checks":checks,"pam_authentication":"not covered by protocol-owner test"},ensure_ascii=False,indent=2)+"\n")
print(json.dumps(checks,ensure_ascii=False))
finally:
for proc in apps:
proc.terminate();proc.wait(timeout=5)
if __name__=="__main__":unittest.main()
+10 -9
View File
@@ -47,11 +47,11 @@ class NativeRimeTest(unittest.TestCase):
(preview.DATA / name).write_bytes(p.stdout)
clear()
baseline_windows={window["id"] for window in preview.windows()}
probe("--keys", "nihao")
expect("") # Preedit is displayed, not prematurely committed.
screenshot("candidate-normal.png")
self.assertLessEqual({window["app_id"] for window in preview.windows()},
{"dev.ferese.RimeValidation", "io.github.lgse.Strata"})
self.assertEqual({window["id"] for window in preview.windows()},baseline_windows)
probe("--keys", " ")
expect("你好")
results.append("native GTK preedit, candidate popup and space commit")
@@ -145,18 +145,19 @@ class NativeRimeTest(unittest.TestCase):
clear()
probe("--keys", "nihao")
compositor_pid = preview.prop(preview.MAIN, "MainPID")
old_ime_pid = preview.prop(preview.IME, "MainPID")
subprocess.run(["systemctl", "--user", "kill", "--signal=KILL", preview.IME], check=True)
preview.wait_for(lambda: preview.prop(preview.IME, "ActiveState") != "active", "IME did not terminate")
preview.wait_for(lambda: preview.prop(preview.IME, "MainPID") not in ("", "0", old_ime_pid)
and preview.prop(preview.IME, "ActiveState") == "active", "IME did not automatically recover")
self.assertEqual(preview.prop(preview.MAIN, "ActiveState"), "active")
self.assertEqual(preview.prop(preview.MAIN, "MainPID"), compositor_pid)
probe("--keys", "abc")
expect("abc")
preview.wait_for(lambda: '"event":"ready"' in preview.run(
["journalctl", "--user", f"_PID={preview.prop(preview.IME, 'MainPID')}", "--no-pager", "-o", "cat"]), "Recovered IME was not ready")
time.sleep(.3)
screenshot("ime-crash-desktop-survives.png")
preview.start_ime()
time.sleep(.2)
probe("--keys", "nihao ")
expect("abc你好")
results.append("IME SIGKILL leaves desktop alive; ASCII works; restarted IME commits Chinese")
expect("你好")
results.append("IME SIGKILL automatically restarts with desktop alive and Chinese restored")
# The validation driver must refuse the real host before creating a keyboard.
host = Path(os.environ.get("WAYLAND_DISPLAY", "wayland-0"))
@@ -0,0 +1,102 @@
"""Opt-in multi-window preferences and real service fault recovery.
FERESE_TEST_RIME=1 python3 scripts/tests/test_rime_resilience_isolated.py
Uses disposable native GTK windows only; never writes user chat/profile data.
"""
import importlib.util
import json
import os
from pathlib import Path
import subprocess
import time
import unittest
ROOT = Path(__file__).resolve().parents[2]
spec = importlib.util.spec_from_file_location("preview", ROOT / "scripts/rime-preview.py")
p = importlib.util.module_from_spec(spec)
spec.loader.exec_module(p)
ALT = "ferese-rime-second-20261028.service"
@unittest.skipUnless(os.environ.get("FERESE_TEST_RIME") == "1", "opt-in Rime resilience validation")
class Resilience(unittest.TestCase):
def test_preferences_and_recovery(self):
p.start()
env = {**os.environ, **p.child_environment()}
results = []
def ctl(*args):
return json.loads(p.run([str(ROOT / "target/debug/ferese-rime"), *args], env=env))["state"]
def keys(*args):
p.probe(list(args)); time.sleep(.15)
def text(name="entry-state.json"):
return json.loads((p.DATA / name).read_text())["normal"]
def expect(value, name="entry-state.json"):
p.wait_for(lambda: text(name) == value, f"Expected {value!r} in {name}", timeout=5)
def ready_after(pid, timeout=20):
def ready():
try:
state = ctl("--status")
return state["pid"] != int(pid) and state["active"] and state["context"]
except (RuntimeError, ValueError):
return False
p.wait_for(ready, "Input service did not automatically recover", timeout=timeout)
p.focus_entry(); p.action("clear")
keys("--key", "Escape")
ctl("--select-schema", "luna_pinyin_simp"); ctl("--mode", "rime")
keys("--keys", "nihao "); expect("你好")
try:
p.start_unit(ALT, ["/usr/bin/gjs", str(ROOT / "scripts/tests/fixtures/rime-entry.js"),
str(p.DATA / "second-state.json"), "dev.ferese.RimeSecond"], p.child_environment(), "512M")
p.wait_for(lambda: any(w["focused"] and w["app_id"] == "dev.ferese.RimeSecond" for w in p.windows()), "Second window did not focus")
p.wait_for(lambda: ctl("--status")["context"]["app_id"] == "dev.ferese.RimeSecond", "Second input context missing")
ctl("--mode", "ascii"); keys("--keys", "abc"); expect("abc", "second-state.json")
p.focus_entry(); p.action("normal"); time.sleep(.25)
self.assertFalse(ctl("--status")["profile"]["ascii"])
p.action("clear"); keys("--keys", "nihao "); expect("你好")
results.append("two windows retain independent ASCII/Rime modes on focus changes")
ctl("--select-schema", "luna_pinyin")
p.action("clear"); keys("--keys", "hanzi "); expect("漢字")
# Reopening the same application must use its persisted preference.
ctl("--mode", "ascii")
subprocess.run(["systemctl", "--user", "stop", p.APP], check=True)
p.wait_for(lambda: not any(w["app_id"] == "dev.ferese.RimeValidation" for w in p.windows()), "First window did not close")
p.start_unit(p.APP, ["/usr/bin/gjs", str(ROOT / "scripts/tests/fixtures/rime-entry.js"),
str(p.DATA / "entry-state.json")], p.child_environment(), "512M")
p.focus_entry()
p.wait_for(lambda: (ctl("--status")["context"] or {}).get("app_id") == "dev.ferese.RimeValidation", "Reopened context missing")
self.assertEqual(ctl("--status")["profile"], {"schema":"luna_pinyin", "ascii":True})
keys("--keys", "abc"); expect("abc")
results.append("closed/reopened application restores mode and Rime schema")
ctl("--mode", "rime"); p.action("clear"); keys("--keys", "hanzi")
desktop = p.prop(p.MAIN, "MainPID"); old = p.prop(p.IME, "MainPID")
started = time.monotonic()
subprocess.run(["systemctl", "--user", "kill", "--signal=KILL", p.IME], check=True)
ready_after(old)
self.assertEqual(p.prop(p.MAIN, "MainPID"), desktop)
self.assertEqual(ctl("--status")["profile"], {"schema":"luna_pinyin", "ascii":False})
keys("--keys", "hanzi "); expect("漢字")
results.append(f"SIGKILL recovery restores current-window schema in {time.monotonic()-started:.2f}s without stale composition")
p.action("clear"); old = p.prop(p.IME, "MainPID"); started=time.monotonic()
subprocess.run(["systemctl", "--user", "kill", "--signal=STOP", p.IME], check=True)
ready_after(old, timeout=20)
self.assertEqual(p.prop(p.MAIN, "MainPID"), desktop)
keys("--keys", "hanzi "); expect("漢字")
results.append(f"stalled input loop is killed by watchdog and restored in {time.monotonic()-started:.2f}s")
finally:
subprocess.run(["systemctl", "--user", "stop", ALT], capture_output=True)
p.focus_entry()
ctl("--select-schema", "luna_pinyin_simp"); ctl("--mode", "rime")
(p.DATA / "resilience-validation.json").write_text(json.dumps({"status":"passed","checks":results}, ensure_ascii=False,indent=2)+"\n")
print(json.dumps(results, ensure_ascii=False))
if __name__ == "__main__":
unittest.main()