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

+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/python3
"""App-scoped native Wayland QQ/Teams checks with separate application profiles.
Never imports the host IBus/Fcitx launchers or modifies their desktop entries.
"""
import argparse
import importlib.util
import os
from pathlib import Path
import subprocess
ROOT=Path(__file__).resolve().parents[1]
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)
APPS={"qq":"/usr/bin/linuxqq","teams":"/usr/bin/teams-for-linux-electron"}
def unit(name):return f"ferese-rime-app-{name}-20261028.service"
if __name__=="__main__":
parser=argparse.ArgumentParser();parser.add_argument("action",choices=[*APPS,"stop","status"]);args=parser.parse_args()
if args.action=="stop":
for name in APPS:subprocess.run(["systemctl","--user","stop",unit(name)],capture_output=True)
elif args.action=="status":
for name in APPS:print(name,p.prop(unit(name),"ActiveState"))
else:
name=args.action;p.start()
if p.prop(unit(name),"ActiveState")=="active":print(f"{name} test instance is already running")
else:
data=p.DATA/f"app-{name}";data.mkdir(mode=0o700,exist_ok=True);data.chmod(0o700)
command=[APPS[name],"--ozone-platform=wayland","--enable-wayland-ime","--wayland-text-input-version=3",f"--user-data-dir={data}"]
if name=="qq":command += ["--no-sandbox"]
p.start_unit(unit(name),command,p.child_environment(),"2G")
print(f"{name} native Wayland validation instance opened")
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/bash
set -euo pipefail
root=$(cd -- "$(dirname -- "$0")/.." && pwd)
/usr/bin/python3 "$root/scripts/rime-drm-validation.py" clients &
exec /usr/bin/ferese-shell
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/python3
"""Disposable, separate logind/DRM session on an unused VT. Never logs GNOME out.
All system services are transient; an independent timer returns to the original
session even if this validation compositor fails. Requires existing sudo access.
"""
import argparse
import importlib.util
import json
import os
from pathlib import Path
import pwd
import socket
import struct
import subprocess
import time
ROOT = Path(__file__).resolve().parents[1]
DATA = ROOT / ".artifacts/rime-drm-validation-20261028"
UID = ROOT.stat().st_uid
USER = pwd.getpwuid(UID).pw_name
RUNTIME = Path(f"/run/user/{UID}/ferese-rime-drm-validation")
MAIN = "ferese-drm-validation-20261028.service"
RETURN = "ferese-drm-return-20261028"
IME = "ferese-rime-drm-20261028.service"
APP = "ferese-rime-drm-entry-20261028.service"
LOCK = "ferese-drm-real-lock-20261028.service"
def run(args, **kwargs):
return subprocess.check_output(args, text=True, **kwargs).strip()
def manager_env():
return {**os.environ, "XDG_RUNTIME_DIR":f"/run/user/{UID}",
"DBUS_SESSION_BUS_ADDRESS":f"unix:path=/run/user/{UID}/bus"}
def start():
return_seconds=int(os.environ.get("FERESE_DRM_RETURN_SECONDS","60"))
if not 30 <= return_seconds <= 180:raise ValueError("Return interval must be 30-180 seconds")
DATA.mkdir(mode=0o700,parents=True,exist_ok=True)
DATA.chmod(0o700)
# Build the disposable protocol fixture from source, never a PAM mock.
xml=Path(run(["pkg-config","--variable=pkgdatadir","wayland-protocols"])) / "staging/ext-session-lock/ext-session-lock-v1.xml"
for mode,name in (("client-header","ext-session-lock-client-protocol.h"),("private-code","ext-session-lock-protocol.c")):
subprocess.run(["wayland-scanner",mode,str(xml),str(DATA/name)],check=True)
flags=run(["pkg-config","--cflags","--libs","wayland-client"]).split()
subprocess.run(["cc","-Wall","-Wextra","-Werror",f"-I{DATA}",str(ROOT/"scripts/tests/fixtures/session-lock-cycle.c"),
str(DATA/"ext-session-lock-protocol.c"),"-o",str(DATA/"lock-cycle"),*flags],check=True)
RUNTIME.mkdir(mode=0o700, exist_ok=True)
if RUNTIME.stat().st_uid != UID or RUNTIME.stat().st_mode & 0o077:
raise RuntimeError("Unexpected private DRM runtime owner/permissions")
active = run(["loginctl", "show-seat", "seat0", "-p", "ActiveSession", "--value"])
if not active or run(["loginctl", "show-session", active, "-p", "Name", "--value"]) != USER:
raise RuntimeError("The original seat must belong to the repository owner")
vt = "3"
if any(run(["loginctl", "show-session", sid, "-p", "VTNr", "--value"]) == vt
for sid in run(["loginctl", "list-sessions", "--no-legend"]).splitlines()
for sid in [sid.split()[0]]):
raise RuntimeError("Validation VT is already in use")
(DATA / "original-session.json").write_text(json.dumps({"session":active,"vt":vt})+"\n")
(DATA / "context.json").unlink(missing_ok=True)
config=DATA/"profile/config/ferese";config.mkdir(parents=True,exist_ok=True)
(config/"config.kdl").write_text('''
xwayland { enabled #false; }
status { keybinding-guide #false; }
window-rule app-id="dev.ferese.RimeValidation" floating=#true width=880 height=560
theme { typography { font-family "MiSans"; }; }
output-profile "validation" layout="extend" {
output "DP-4" mode="3840x2160@60" scale=1.5 { position 0 0; }
output "DP-5" mode="3840x2160@160" scale=1.5 { position 2560 0; }
output "DP-6" mode="3840x2160@144" scale=1.5 { position 5120 0; }
output "HDMI-A-2" required=#false enabled=#false
}
''')
# Install the return path before anything may acquire the physical seat.
subprocess.run(["sudo","-n","systemd-run",f"--unit={RETURN}",f"--on-active={return_seconds}s",
"--timer-property=RemainAfterElapse=no","--collect",
"/usr/bin/loginctl","activate",active],check=True)
env={"XDG_SEAT":"seat0","XDG_VTNR":vt,"XDG_SESSION_TYPE":"wayland","XDG_SESSION_CLASS":"user",
"XDG_RUNTIME_DIR":str(RUNTIME),"XDG_CURRENT_DESKTOP":"Ferese","XDG_SESSION_DESKTOP":"ferese",
"FERESE_SESSION_IMPORT_ENV":"0","FERESE_ENABLE_INPUT_METHOD":"1","FERESE_ENABLE_SCREENCOPY":"1",
"FERESE_RIME_CONTROL":str(RUNTIME/"rime-control.sock"),"LIBSEAT_BACKEND":"logind",
"FERESE_ENABLE_TEST_POINTER":"1",
"GTK_IM_MODULE":"wayland","QT_IM_MODULE":"wayland","GDK_BACKEND":"wayland",
"GSETTINGS_BACKEND":"keyfile","GTK_A11Y":"none","NO_AT_BRIDGE":"1","IBUS_ADDRESS":""}
env.update({f"XDG_{kind.upper()}_HOME":str(DATA/"profile"/kind) for kind in ("config","cache","data","state")})
args=["sudo","-n","systemd-run","--collect",f"--unit={MAIN}",f"--uid={USER}","--service-type=exec",
"--property=PAMName=login",f"--property=TTYPath=/dev/tty{vt}","--property=StandardInput=tty",
"--property=StandardOutput=journal","--property=StandardError=journal","--property=TTYReset=yes",
"--property=RuntimeMaxSec=15min","--property=TimeoutStopSec=8",
f"--property=ExecStopPost=+/usr/bin/python3 {Path(__file__)} cleanup",f"--working-directory={ROOT}"]
args += [f"--setenv={k}={v}" for k,v in env.items()]
# pam_systemd sets its standard runtime directory. Restore the private one
# before starting the private bus, including its activation environment.
subprocess.run(args+["/usr/bin/env",f"XDG_RUNTIME_DIR={RUNTIME}","/usr/bin/dbus-run-session","--","/usr/bin/python3",str(Path(__file__)),"compositor"],check=True)
# PAM/logind supplies a distinct session; activate it without terminating GNOME.
for _ in range(100):
candidates=[line.split()[0] for line in run(["loginctl","list-sessions","--no-legend"]).splitlines()]
found=[sid for sid in candidates if run(["loginctl","show-session",sid,"-p","VTNr","--value"])==vt]
if found:
(DATA/"original-session.json").write_text(json.dumps({"session":active,"vt":vt,"validation_session":found[0]})+"\n")
subprocess.run(["sudo","-n","loginctl","activate",found[0]],check=True)
print(json.dumps({"validation_session":found[0],"original_session":active,"return_timer_seconds":return_seconds}));return
time.sleep(.1)
raise RuntimeError("The separate logind session did not appear; return timer remains armed")
def clients():
# These clients use the public Wayland socket; shell capabilities never leak.
env={k:v for k,v in os.environ.items() if k not in ("WAYLAND_SOCKET","FERESE_SHELL_CONTROL_SOCKET","NOTIFY_SOCKET","WATCHDOG_USEC")}
env["WAYLAND_DISPLAY"]=env["FERESE_PUBLIC_WAYLAND_DISPLAY"]
sock=socket.socket(socket.AF_UNIX);sock.connect(str(RUNTIME/env["WAYLAND_DISPLAY"]))
pid,_,_=struct.unpack("3i",sock.getsockopt(socket.SOL_SOCKET,socket.SO_PEERCRED,12));sock.close()
env["FERESE_TEST_DRM_PID"]=str(pid)
(DATA/"context.json").write_text(json.dumps({"socket":env["FERESE_SOCKET"],"wayland":env["WAYLAND_DISPLAY"],
"bus":env["DBUS_SESSION_BUS_ADDRESS"],"compositor_pid":pid},indent=2)+"\n")
def unit(name,command,ime=False):
args=["systemd-run","--user","--collect",f"--unit={name}",f"--service-type={'notify' if ime else 'exec'}",
f"--working-directory={ROOT}","--property=MemoryMax=1G"]
if ime:
args += ["--property=Restart=on-failure","--property=RestartSec=1","--property=StartLimitIntervalSec=60",
"--property=StartLimitBurst=5","--property=WatchdogSec=10","--property=WatchdogSignal=SIGKILL",
"--property=TimeoutStartSec=45","--property=TimeoutStopSec=3"]
# Only the explicitly constructed session environment enters the user unit.
allowed={k:v for k,v in env.items() if k.startswith(("XDG_","FERESE_","GTK_","QT_","GDK_")) or
k in ("WAYLAND_DISPLAY","DBUS_SESSION_BUS_ADDRESS","GSETTINGS_BACKEND","NO_AT_BRIDGE","IBUS_ADDRESS","PATH","HOME","LANG")}
subprocess.run(args+[f"--setenv={k}={v}" for k,v in allowed.items()]+command,env=manager_env(),check=True)
unit(IME,[str(ROOT/"target/debug/ferese-rime"),"--user-data",str(DATA/"rime-user")],True)
unit(APP,["/usr/bin/gjs",str(ROOT/"scripts/tests/fixtures/rime-entry.js"),str(DATA/"entry-state.json")])
def cleanup():
args=["systemctl","--user","stop",APP,IME]
if subprocess.run(["systemctl","--user","show",LOCK,"-p","LoadState","--value"],
env=manager_env(),text=True,capture_output=True).stdout.strip()=="loaded":args.append(LOCK)
if os.getuid()==0:
args=["runuser","-u",USER,"--","env",f"XDG_RUNTIME_DIR=/run/user/{UID}",
f"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/{UID}/bus",*args]
subprocess.run(args,env=manager_env(),capture_output=True)
def original():
return json.loads((DATA/"original-session.json").read_text())["session"]
def real_lock():
c=json.loads((DATA/"context.json").read_text())
env={"XDG_RUNTIME_DIR":str(RUNTIME),"WAYLAND_DISPLAY":c["wayland"],"DBUS_SESSION_BUS_ADDRESS":c["bus"],
"FERESE_SOCKET":c["socket"],"XDG_CURRENT_DESKTOP":"Ferese","XDG_SESSION_TYPE":"wayland"}
env.update({f"XDG_{kind.upper()}_HOME":str(DATA/"profile"/kind) for kind in ("config","cache","data","state")})
subprocess.run(["systemd-run","--user","--collect",f"--unit={LOCK}","--service-type=exec",
"--property=MemoryMax=1G",*[f"--setenv={k}={v}" for k,v in env.items()],
"/usr/bin/ferese-lock","--foreground"],env=manager_env(),check=True)
def stop():
subprocess.run(["sudo","-n","loginctl","activate",original()],check=True)
subprocess.run(["sudo","-n","systemctl","stop",MAIN],check=True)
subprocess.run(["sudo","-n","systemctl","stop",f"{RETURN}.timer"],capture_output=True)
record=json.loads((DATA/"original-session.json").read_text());sid=record.get("validation_session")
if sid and sid!=record["session"]:
# PAM may leave its private bus in the abandoned session scope.
info=subprocess.run(["loginctl","show-session",sid,"-p","VTNr","--value"],text=True,capture_output=True)
if info.returncode==0 and info.stdout.strip()==record["vt"]:
subprocess.run(["sudo","-n","loginctl","terminate-session",sid],check=True)
subprocess.run(["sudo","-n","loginctl","kill-session","--signal=KILL",sid],capture_output=True)
if __name__ == "__main__":
parser=argparse.ArgumentParser();parser.add_argument("action",choices=["start","stop","cleanup","clients","compositor","return","status","real-lock"])
action=parser.parse_args().action
if action=="compositor":
os.environ["XDG_RUNTIME_DIR"]=str(RUNTIME)
os.environ.pop("DISPLAY",None);os.environ.pop("WAYLAND_DISPLAY",None)
os.execv(str(ROOT/"target/debug/ferese"),["ferese","--backend","drm","--supervise-client","--grant-effects","--grant-shell-control","--","/usr/bin/bash",str(ROOT/"scripts/rime-drm-shell")])
elif action=="return":subprocess.run(["sudo","-n","loginctl","activate",original()],check=True)
elif action=="status":print(run(["systemctl","show",MAIN,"-p","ActiveState","-p","MainPID"]))
elif action=="real-lock":real_lock()
else:globals()[action]()
+14 -2
View File
@@ -30,10 +30,15 @@ def prop(unit, field):
def start_unit(unit, command, env, limit):
args = ["systemd-run", "--user", "--collect", f"--unit={unit}", "--service-type=exec",
args = ["systemd-run", "--user", "--collect", f"--unit={unit}", f"--service-type={'notify' if unit == IME else 'exec'}",
f"--working-directory={ROOT}", f"--property=MemoryMax={limit}", "--property=TasksMax=512"]
if unit != MAIN:
args += [f"--property=BindsTo={MAIN}", f"--property=After={MAIN}"]
if unit == IME:
args += ["--property=Restart=on-failure", "--property=RestartSec=1",
"--property=StartLimitIntervalSec=60", "--property=StartLimitBurst=5",
"--property=WatchdogSec=10", "--property=WatchdogSignal=SIGKILL",
"--property=TimeoutStopSec=3", "--property=TimeoutStartSec=45"]
run(args + [f"--setenv={key}={value}" for key, value in env.items()] + list(command))
@@ -76,6 +81,8 @@ def environment():
"QT_IM_MODULE": "wayland", "QT_QPA_PLATFORM": "wayland", "NO_AT_BRIDGE": "1", "GTK_A11Y": "none",
"GSETTINGS_BACKEND": "keyfile",
"IBUS_ADDRESS": "", "FERESE_SESSION_IMPORT_ENV": "0", "FERESE_ENABLE_INPUT_METHOD": "1",
"FERESE_RIME_CONTROL": str(RUNTIME / "rime-control.sock"),
"FERESE_ENABLE_TEST_POINTER": "1",
"FERESE_ENABLE_SCREENCOPY": "1", "XDG_DATA_DIRS": f"{Path.home()}/.local/share:/usr/local/share:/usr/share"}
values.update({f"XDG_{kind.upper()}_HOME": str(DATA / "profile" / kind) for kind in ("config", "data", "cache", "state")})
return values
@@ -97,7 +104,8 @@ def focus_entry():
wait_for(lambda: any(w["app_id"] == "dev.ferese.RimeValidation" for w in windows()), "Validation window did not map")
if not any(w["focused"] and w["app_id"] == "dev.ferese.RimeValidation" for w in windows()):
c = context()
run([str(ROOT / "target/debug/feresectl"), "--socket", c["socket"], "focus-last-window"])
entry=next(w for w in windows() if w["app_id"]=="dev.ferese.RimeValidation")
run([str(ROOT / "target/debug/feresectl"), "--socket", c["socket"], "focus-window", str(entry["id"])])
wait_for(lambda: any(w["focused"] and w["app_id"] == "dev.ferese.RimeValidation" for w in windows()), "Validation window did not focus")
@@ -114,6 +122,8 @@ def start():
if prop(MAIN, "ActiveState") == "active":
print("开发预览已在运行。")
return
DATA.mkdir(mode=0o700,parents=True,exist_ok=True)
DATA.chmod(0o700)
if not (ROOT / "target/debug/ferese-rime").exists():
raise RuntimeError("Build ferese, feresectl and ferese-rime first")
for kind in ("config/ferese", "data", "cache", "state"):
@@ -126,6 +136,8 @@ xwayland { enabled #false; }
status { keybinding-guide #false; }
window-rule app-id="dev.ferese.RimeValidation" floating=#true width=880 height=560
window-rule app-id="io.github.lgse.Strata" floating=#true width=1050 height=720
window-rule app-id="QQ" floating=#true width=1000 height=720
window-rule app-id="teams-for-linux" floating=#true width=1000 height=720
theme { typography { font-family "MiSans"; }; }
''')
host_runtime = Path(os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}"))
+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()