flatpak: run host interpreters via flatpak-spawn; validation --mode flag
py_func, lua_func and the run item now reach host binaries through `flatpak-spawn --host` instead of trying to load them under the sandbox runtime (which fails with a glibc ABI mismatch). Adds `--talk-name=org.freedesktop.Flatpak` to the manifest, stages the /app/lib/testium tree under /tmp so the host can read it, and drops the dead `_FLATPAK_HOST_DIRS` / lib-injection code paths that the new approach makes obsolete. Validation suite gains a `--mode source|wheel|pyinstaller|flatpak| appimage` flag so the same item set can run against every packaging channel; per-mode report file names avoid clobbering. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -24,9 +24,15 @@ def _testium_launch_cmd():
|
||||
appimage = os.environ.get("APPIMAGE")
|
||||
if appimage:
|
||||
return [appimage]
|
||||
# Flatpak: re-launch via the Flatpak app id.
|
||||
# Flatpak: re-launch via the Flatpak app id, but on the host side —
|
||||
# the `flatpak` CLI cannot run inside our sandbox (no D-Bus access to the
|
||||
# host Flatpak service, and the host binary would need host libs that are
|
||||
# ABI-incompatible with the sandbox runtime). flatpak-spawn proxies the
|
||||
# call to the host via org.freedesktop.Flatpak (allowed by --talk-name in
|
||||
# the manifest).
|
||||
if os.path.isfile("/.flatpak-info"):
|
||||
return ["flatpak", "run", "org.testium.Testium"]
|
||||
return ["flatpak-spawn", "--host",
|
||||
"flatpak", "run", "org.testium.Testium"]
|
||||
# PyInstaller frozen exe: sys.executable is the binary itself.
|
||||
if getattr(sys, "frozen", False):
|
||||
return [sys.executable]
|
||||
|
||||
@@ -17,8 +17,11 @@ Public API
|
||||
``reset()`` : clear the cache (mostly useful for tests)
|
||||
"""
|
||||
|
||||
import atexit
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
import api.testium as tm
|
||||
from interpreter.utils.paths import sys_app_path_lin, sys_app_path_win
|
||||
@@ -30,20 +33,6 @@ from runtime.tum_except import ETUMRuntimeError
|
||||
_PYTHON_CANDIDATES = ["python3", "python"]
|
||||
_LUA_CANDIDATES = ["lua", "lua5.5", "lua5.4", "lua5.3", "lua5.2", "lua5.1"]
|
||||
|
||||
# When running inside a Flatpak, --filesystem=host-os mounts the host at
|
||||
# /run/host (read-only). Binaries and libraries from the host are not on the
|
||||
# sandbox PATH/LD_LIBRARY_PATH, so we probe and inject them explicitly.
|
||||
_FLATPAK_HOST_DIRS = [
|
||||
"/run/host/usr/local/bin",
|
||||
"/run/host/usr/bin",
|
||||
"/run/host/bin",
|
||||
]
|
||||
_FLATPAK_HOST_LIB_DIRS = [
|
||||
"/run/host/usr/lib",
|
||||
"/run/host/usr/lib64",
|
||||
"/run/host/usr/local/lib",
|
||||
]
|
||||
|
||||
# Inside an AppImage, AppRun prepends $APPDIR/usr/bin to PATH and exports a
|
||||
# bundle-local PYTHONHOME / PYTHONPATH / LD_LIBRARY_PATH. We want py_func and
|
||||
# lua_func to run under the *host* interpreter (not the bundled one), so we
|
||||
@@ -64,78 +53,162 @@ def _in_appimage():
|
||||
return "APPIMAGE" in os.environ
|
||||
|
||||
|
||||
def apply_host_lua_paths(env):
|
||||
"""Prepend host Lua module dirs to LUA_PATH / LUA_CPATH (Flatpak only).
|
||||
|
||||
Must be called after user-defined lua_env overrides are applied, so host
|
||||
paths are always first regardless of user config. User-defined paths remain
|
||||
in the variable but after the host ones.
|
||||
"""
|
||||
if not _in_flatpak():
|
||||
return
|
||||
_LUA_VERSIONS = ["5.5", "5.4", "5.3", "5.2", "5.1"]
|
||||
_HOST = "/run/host/usr"
|
||||
cpath_dirs, lpath_dirs = [], []
|
||||
for v in _LUA_VERSIONS:
|
||||
for base in [f"{_HOST}/lib/lua/{v}",
|
||||
f"{_HOST}/lib64/lua/{v}",
|
||||
f"{_HOST}/lib/x86_64-linux-gnu/lua/{v}"]:
|
||||
cpath_dirs.append(f"{base}/?.so")
|
||||
lpath_dirs.append(f"{_HOST}/share/lua/{v}/?.lua")
|
||||
lpath_dirs.append(f"{_HOST}/share/lua/{v}/?/init.lua")
|
||||
sep = ";"
|
||||
host_cpath = sep.join(cpath_dirs)
|
||||
host_lpath = sep.join(lpath_dirs)
|
||||
# ;; keeps Lua's compiled-in defaults at the end as last resort
|
||||
env["LUA_CPATH"] = host_cpath + sep + env.get("LUA_CPATH", ";;")
|
||||
env["LUA_PATH"] = host_lpath + sep + env.get("LUA_PATH", ";;")
|
||||
|
||||
|
||||
def apply_host_libs(env):
|
||||
"""Prepare *env* for launching a host binary from inside our bundle.
|
||||
"""Strip bundle-local entries from *env* so a host binary can run cleanly.
|
||||
|
||||
- Flatpak: prepend host library dirs to LD_LIBRARY_PATH so the dynamic
|
||||
linker can find host .so files mounted under /run/host.
|
||||
- AppImage: strip $APPDIR-prefixed entries from LD_LIBRARY_PATH and
|
||||
PYTHONPATH and drop PYTHONHOME, so the host interpreter doesn't try
|
||||
to load the bundled (incompatible) Python lib/site-packages.
|
||||
- Otherwise: no-op.
|
||||
Only meaningful for AppImage: removes $APPDIR-prefixed entries from
|
||||
LD_LIBRARY_PATH / PYTHONPATH / PATH and drops PYTHONHOME, so the host
|
||||
interpreter doesn't try to load the bundled (incompatible) Python
|
||||
lib/site-packages. Flatpak is handled via flatpak-spawn --host instead
|
||||
(see flatpak_host_spawn), so the sandbox env is irrelevant there.
|
||||
"""
|
||||
if _in_flatpak():
|
||||
dirs = ":".join(d for d in _FLATPAK_HOST_LIB_DIRS if os.path.isdir(d))
|
||||
if dirs:
|
||||
existing = env.get("LD_LIBRARY_PATH", "")
|
||||
env["LD_LIBRARY_PATH"] = dirs + (":" + existing if existing else "")
|
||||
if not _in_appimage():
|
||||
return
|
||||
if _in_appimage():
|
||||
appdir = os.environ.get("APPDIR", "")
|
||||
if appdir:
|
||||
for var, sep in (("LD_LIBRARY_PATH", ":"),
|
||||
("PYTHONPATH", os.pathsep),
|
||||
("PATH", os.pathsep)):
|
||||
cur = env.get(var, "")
|
||||
if not cur:
|
||||
continue
|
||||
cleaned = sep.join(
|
||||
p for p in cur.split(sep)
|
||||
if p and not p.startswith(appdir)
|
||||
)
|
||||
if cleaned:
|
||||
env[var] = cleaned
|
||||
else:
|
||||
env.pop(var, None)
|
||||
env.pop("PYTHONHOME", None)
|
||||
appdir = os.environ.get("APPDIR", "")
|
||||
if appdir:
|
||||
for var, sep in (("LD_LIBRARY_PATH", ":"),
|
||||
("PYTHONPATH", os.pathsep),
|
||||
("PATH", os.pathsep)):
|
||||
cur = env.get(var, "")
|
||||
if not cur:
|
||||
continue
|
||||
cleaned = sep.join(
|
||||
p for p in cur.split(sep)
|
||||
if p and not p.startswith(appdir)
|
||||
)
|
||||
if cleaned:
|
||||
env[var] = cleaned
|
||||
else:
|
||||
env.pop(var, None)
|
||||
env.pop("PYTHONHOME", None)
|
||||
|
||||
|
||||
# ---------- Flatpak: spawn on host outside the sandbox -----------------------
|
||||
#
|
||||
# Inside a Flatpak the sandbox glibc is incompatible with host shared libraries,
|
||||
# so we can't run host Python/Lua under the sandbox runtime — `LD_LIBRARY_PATH`
|
||||
# tricks hit a `_dl_call_libc_early_init` assertion. The supported way out is
|
||||
# `flatpak-spawn --host`, which talks to the session-bus Flatpak D-Bus service
|
||||
# (org.freedesktop.Flatpak.Development) and asks it to spawn a process in the
|
||||
# host execution environment instead of inside our sandbox. The manifest must
|
||||
# grant `--talk-name=org.freedesktop.Flatpak` for the D-Bus call to be allowed.
|
||||
#
|
||||
# The host process can't see our /app/ contents (sandbox-only), so when we
|
||||
# spawn host Python/Lua to run `py_func` / `lua_func`, the cwd must be a
|
||||
# directory both sides can reach. /tmp is shared (--filesystem=/tmp), so we
|
||||
# stage the testium package there once per process and reuse it for every
|
||||
# spawn. In source mode (testium under $HOME) the host already sees the
|
||||
# original path, so we skip the copy.
|
||||
|
||||
_staged_testium_path = None
|
||||
|
||||
|
||||
def _get_host_testium_path():
|
||||
"""Return a path to the testium package that the host can read.
|
||||
|
||||
- Source / wheel / PyInstaller install under $HOME → return testium_path()
|
||||
as-is (host sees the same path via --filesystem=home).
|
||||
- Flatpak bundle (testium under /app/) → stage a copy under /tmp on first
|
||||
call and reuse it for the rest of the process.
|
||||
"""
|
||||
global _staged_testium_path
|
||||
if _staged_testium_path is not None:
|
||||
return _staged_testium_path
|
||||
|
||||
# Imported lazily to avoid a circular import (paths.py -> api.testium).
|
||||
from interpreter.utils.paths import testium_path
|
||||
tp = testium_path()
|
||||
|
||||
if not tp.startswith("/app/"):
|
||||
_staged_testium_path = tp
|
||||
return tp
|
||||
|
||||
staged = tempfile.mkdtemp(prefix="testium_host_", dir="/tmp")
|
||||
# copytree refuses to write into an existing dir unless dirs_exist_ok=True.
|
||||
# mkdtemp creates the dir, so we copy *into* it.
|
||||
for entry in os.listdir(tp):
|
||||
src = os.path.join(tp, entry)
|
||||
dst = os.path.join(staged, entry)
|
||||
if os.path.isdir(src):
|
||||
shutil.copytree(src, dst, symlinks=True)
|
||||
else:
|
||||
shutil.copy2(src, dst, follow_symlinks=False)
|
||||
_staged_testium_path = staged
|
||||
atexit.register(shutil.rmtree, staged, ignore_errors=True)
|
||||
return staged
|
||||
|
||||
|
||||
_FORWARDED_ENV_KEYS = (
|
||||
"HOME", "USER", "LOGNAME", "TMPDIR",
|
||||
"XDG_RUNTIME_DIR", "XDG_DATA_HOME", "XDG_CONFIG_HOME", "XDG_CACHE_HOME",
|
||||
"DBUS_SESSION_BUS_ADDRESS", "DISPLAY", "WAYLAND_DISPLAY",
|
||||
"LANG", "LC_ALL",
|
||||
)
|
||||
|
||||
|
||||
def flatpak_host_spawn(interp_bin, cmd_args, host_cwd, extra_env=None):
|
||||
"""Build a flatpak-spawn --host command vector.
|
||||
|
||||
Args:
|
||||
interp_bin: absolute path to the host interpreter (e.g. /usr/bin/python3).
|
||||
cmd_args: list of arguments passed to the interpreter.
|
||||
host_cwd: working directory on the host (must be reachable from host).
|
||||
extra_env: optional {name: value} of env vars to set on the host side
|
||||
in addition to the default forwarded set. Values of ""
|
||||
unset the variable on the host.
|
||||
|
||||
Returns a list suitable for subprocess.Popen.
|
||||
"""
|
||||
spawn = ["flatpak-spawn", "--host", f"--directory={host_cwd}"]
|
||||
forwarded = {}
|
||||
for key in _FORWARDED_ENV_KEYS:
|
||||
val = os.environ.get(key)
|
||||
if val:
|
||||
forwarded[key] = val
|
||||
if extra_env:
|
||||
forwarded.update(extra_env)
|
||||
for k, v in forwarded.items():
|
||||
if v == "":
|
||||
spawn.append(f"--unset-env={k}")
|
||||
else:
|
||||
spawn.append(f"--env={k}={v}")
|
||||
spawn.append(interp_bin)
|
||||
spawn.extend(cmd_args)
|
||||
return spawn
|
||||
|
||||
|
||||
def _which_host_flatpak(name):
|
||||
"""Resolve a binary name (or absolute path) on the host via flatpak-spawn.
|
||||
|
||||
We can't probe /run/host/... because (a) only host-os is mounted there,
|
||||
not arbitrary paths like /scratch, and (b) returning a /run/host path
|
||||
would be useless — the host-side spawn sees a different filesystem and
|
||||
needs the host-native path anyway.
|
||||
"""
|
||||
if os.path.isabs(name):
|
||||
cmd = flatpak_host_spawn("/bin/sh", ["-c", f'test -x "{name}"'],
|
||||
host_cwd="/tmp")
|
||||
try:
|
||||
r = subprocess.run(cmd, capture_output=True, timeout=10)
|
||||
except (FileNotFoundError, PermissionError, subprocess.TimeoutExpired):
|
||||
return ""
|
||||
return name if r.returncode == 0 else ""
|
||||
cmd = flatpak_host_spawn("/bin/sh", ["-c", f'command -v "{name}"'],
|
||||
host_cwd="/tmp")
|
||||
try:
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
|
||||
except (FileNotFoundError, PermissionError, subprocess.TimeoutExpired):
|
||||
return ""
|
||||
if r.returncode != 0:
|
||||
return ""
|
||||
return r.stdout.strip()
|
||||
|
||||
|
||||
def _which(name):
|
||||
if tm.OS() == "Windows":
|
||||
return sys_app_path_win(name)
|
||||
if _in_flatpak():
|
||||
for d in _FLATPAK_HOST_DIRS:
|
||||
p = os.path.join(d, name)
|
||||
if os.path.isfile(p) and os.access(p, os.X_OK):
|
||||
return p
|
||||
return ""
|
||||
return _which_host_flatpak(name)
|
||||
if _in_appimage():
|
||||
for d in _APPIMAGE_HOST_DIRS:
|
||||
p = os.path.join(d, name)
|
||||
@@ -146,14 +219,33 @@ def _which(name):
|
||||
|
||||
|
||||
def _probe_env():
|
||||
"""Subprocess env for probing host binaries (adds host libs in Flatpak)."""
|
||||
"""Subprocess env for probing host binaries.
|
||||
|
||||
In AppImage we still need to scrub APPDIR-prefixed entries; in Flatpak we
|
||||
delegate execution to the host via flatpak-spawn so the sandbox env doesn't
|
||||
matter, but apply_host_libs is a no-op cost.
|
||||
"""
|
||||
env = os.environ.copy()
|
||||
apply_host_libs(env)
|
||||
return env
|
||||
|
||||
|
||||
def _python_version(path):
|
||||
cmd = [path, "-c", "import sys; print(sys.version_info[:3])"]
|
||||
def _run_probe(cmd):
|
||||
"""Run a probe command, dispatching through flatpak-spawn --host in Flatpak.
|
||||
|
||||
Returns (stdout, stderr) as str, or None on failure.
|
||||
"""
|
||||
if _in_flatpak():
|
||||
spawn = flatpak_host_spawn(cmd[0], cmd[1:], host_cwd="/tmp")
|
||||
try:
|
||||
r = subprocess.run(
|
||||
spawn, capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
except (FileNotFoundError, PermissionError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
if r.returncode != 0:
|
||||
return None
|
||||
return r.stdout, r.stderr
|
||||
try:
|
||||
r = subprocess.run(
|
||||
cmd, capture_output=True, text=True,
|
||||
@@ -161,8 +253,15 @@ def _python_version(path):
|
||||
)
|
||||
except (FileNotFoundError, PermissionError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
return r.stdout, r.stderr
|
||||
|
||||
|
||||
def _python_version(path):
|
||||
out = _run_probe([path, "-c", "import sys; print(sys.version_info[:3])"])
|
||||
if out is None:
|
||||
return None
|
||||
try:
|
||||
return eval(r.stdout)
|
||||
return eval(out[0])
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -173,15 +272,11 @@ def _is_python3(path):
|
||||
|
||||
|
||||
def _lua_version(path):
|
||||
try:
|
||||
r = subprocess.run(
|
||||
[path, "-v"], capture_output=True, text=True, timeout=10,
|
||||
env=_probe_env(),
|
||||
)
|
||||
except (FileNotFoundError, PermissionError, subprocess.TimeoutExpired):
|
||||
out = _run_probe([path, "-v"])
|
||||
if out is None:
|
||||
return None
|
||||
# On Windows the version banner goes to stderr.
|
||||
line = r.stdout or r.stderr
|
||||
line = out[0] or out[1]
|
||||
try:
|
||||
major, minor, _patch = line.split(" ")[1].split(".")
|
||||
return (int(major), int(minor))
|
||||
@@ -225,7 +320,10 @@ def _resolve(name):
|
||||
# Absolute path: accept as-is (user knows exactly what they want).
|
||||
# Bare name: resolve via _which() so the override stays host-only in
|
||||
# Flatpak/AppImage instead of silently picking the bundled interpreter.
|
||||
if os.path.isabs(override):
|
||||
# In Flatpak we always defer to _which() so even absolute paths are
|
||||
# checked from the host's perspective (the sandbox can't see e.g.
|
||||
# /scratch/... paths that the user may have configured).
|
||||
if os.path.isabs(override) and not _in_flatpak():
|
||||
resolved = override if (os.path.isfile(override)
|
||||
and os.access(override, os.X_OK)) else ""
|
||||
else:
|
||||
|
||||
@@ -47,9 +47,16 @@ class LuaProcessBase:
|
||||
if self._process is not None:
|
||||
raise ETUMRuntimeError("The function subprocess has already been started.")
|
||||
|
||||
func_proc_path = os.path.realpath(
|
||||
os.path.join(subproc_path(), "lua_func")
|
||||
)
|
||||
# In Flatpak the host can't see /app/lib/testium/lua_func, so use a
|
||||
# staged copy under /tmp (shared between sandbox and host).
|
||||
if bins._in_flatpak():
|
||||
func_proc_path = os.path.join(
|
||||
bins._get_host_testium_path(), "lua_func"
|
||||
)
|
||||
else:
|
||||
func_proc_path = os.path.realpath(
|
||||
os.path.join(subproc_path(), "lua_func")
|
||||
)
|
||||
|
||||
# POpen config
|
||||
CUST_ENV = {
|
||||
@@ -71,7 +78,6 @@ class LuaProcessBase:
|
||||
env[k] = e
|
||||
else:
|
||||
env[k] = e + ";" + env.get(k, "")
|
||||
bins.apply_host_lua_paths(env)
|
||||
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.bind(("localhost", 0))
|
||||
@@ -79,8 +85,7 @@ class LuaProcessBase:
|
||||
sock.close()
|
||||
|
||||
# POpen params
|
||||
params = [
|
||||
self._lbin,
|
||||
cmd_args = [
|
||||
"main.lua",
|
||||
"--timeout",
|
||||
f"{self._timeout}",
|
||||
@@ -91,14 +96,31 @@ class LuaProcessBase:
|
||||
]
|
||||
|
||||
if tm.debug_enabled() and tm.gd("debug_rpc", False):
|
||||
params.append("--verbose")
|
||||
cmd_args.append("--verbose")
|
||||
|
||||
if bins._in_flatpak():
|
||||
# Run on the host outside the sandbox: avoids glibc ABI mismatches
|
||||
# between the Flatpak runtime and host shared libraries.
|
||||
host_env = {
|
||||
k: env[k] for k in ("LUA_PATH", "LUA_CPATH", "PATH")
|
||||
if k in env and env[k]
|
||||
}
|
||||
params = bins.flatpak_host_spawn(
|
||||
self._lbin, cmd_args, host_cwd=func_proc_path,
|
||||
extra_env=host_env,
|
||||
)
|
||||
popen_kwargs = {}
|
||||
else:
|
||||
params = [self._lbin, *cmd_args]
|
||||
popen_kwargs = {"env": env, "cwd": func_proc_path}
|
||||
|
||||
self._process = subprocess.Popen(
|
||||
params, env=env, cwd=func_proc_path,
|
||||
params,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
restore_signals=False,
|
||||
**popen_kwargs,
|
||||
)
|
||||
# Route subprocess stdout/stderr (lua require failures, syntax
|
||||
# errors, anything written to fd 1/2 before the in-script
|
||||
|
||||
@@ -61,14 +61,18 @@ class PyProcessBase:
|
||||
if sock is not None:
|
||||
sock.close()
|
||||
|
||||
# Add the path of the subprocess (root sources of testium)
|
||||
tstium_path = os.path.realpath(testium_path())
|
||||
func_proc_path = os.path.realpath(subproc_path())
|
||||
# In Flatpak the host can't see /app/lib/testium, so use a staged copy
|
||||
# under /tmp (shared between sandbox and host) for both cwd and as the
|
||||
# root in PYTHONPATH. Outside Flatpak the original paths are used.
|
||||
if bins._in_flatpak():
|
||||
tstium_path = bins._get_host_testium_path()
|
||||
func_proc_path = tstium_path
|
||||
else:
|
||||
tstium_path = os.path.realpath(testium_path())
|
||||
func_proc_path = os.path.realpath(subproc_path())
|
||||
env["PYTHONPATH"] = tstium_path + os.pathsep + self._ppath + os.pathsep + env.get("PYTHONPATH", "")
|
||||
|
||||
params = [
|
||||
self._pbin,
|
||||
# "-m",
|
||||
cmd_args = [
|
||||
"py_func",
|
||||
"-p",
|
||||
f"{self._port}",
|
||||
@@ -77,14 +81,31 @@ class PyProcessBase:
|
||||
]
|
||||
|
||||
if tm.debug_enabled() and tm.gd("debug_rpc", False):
|
||||
params.append("-v")
|
||||
cmd_args.append("-v")
|
||||
|
||||
if bins._in_flatpak():
|
||||
# Run on the host outside the sandbox: avoids glibc ABI mismatches
|
||||
# between the Flatpak runtime and host shared libraries.
|
||||
host_env = {
|
||||
k: env[k] for k in ("PYTHONPATH", "PATH")
|
||||
if k in env and env[k]
|
||||
}
|
||||
params = bins.flatpak_host_spawn(
|
||||
self._pbin, cmd_args, host_cwd=func_proc_path,
|
||||
extra_env=host_env,
|
||||
)
|
||||
popen_kwargs = {}
|
||||
else:
|
||||
params = [self._pbin, *cmd_args]
|
||||
popen_kwargs = {"env": env, "cwd": func_proc_path}
|
||||
|
||||
self._process = subprocess.Popen(
|
||||
params, env=env, cwd=func_proc_path,
|
||||
params,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
restore_signals=False,
|
||||
**popen_kwargs,
|
||||
)
|
||||
# Route subprocess stdout/stderr (early-startup errors,
|
||||
# unhandled exceptions, anything written to fd 1/2 before the
|
||||
|
||||
Reference in New Issue
Block a user