fix(apk): retain gamepad and storage ownership across native launch

This commit is contained in:
toorux committed 2026-10-07 02:28:27 +08:00
1 parent 0ec7afeafa
commit 5a2bb2dacd
5 files changed
+127 -42

No files matched your search

+5 -3
View File
@@ -1,7 +1,6 @@
// Session-owned OpenVR -> Android gamepad. No CEF dependency or timed test limit.
#include "openvr.h"
#include <linux/uinput.h>
#include <sys/prctl.h>
#include <fcntl.h>
#include <unistd.h>
#include <poll.h>
@@ -23,7 +22,10 @@ static bool event(int fd,int type,int code,int value){input_event e{};e.type=typ
static bool send(int fd,const int* state){bool ok=true;for(int k=0;k<10;k++)ok=event(fd,EV_KEY,keys[k],state[k])&&ok;for(int k=0;k<8;k++)ok=event(fd,EV_ABS,axes[k],state[10+k])&&ok;return event(fd,EV_SYN,SYN_REPORT,0)&&ok;}
int main(int argc,char**argv){
if(argc!=2||geteuid()==0){fprintf(stderr,"Run gamepad bridge as the Steam session user with an action manifest.\n");return 1;}
auto parent=getppid();prctl(PR_SET_PDEATHSIG,SIGTERM);if(getppid()!=parent)return 1;
// A session request runs on a short-lived worker thread. PR_SET_PDEATHSIG
// follows that thread's lifetime, so it destroys the pad as soon as prepare
// returns. The session-owned stdin pipe and parent process own this helper.
const auto parent=getppid();
signal(SIGTERM,stop);signal(SIGINT,stop);signal(SIGPIPE,SIG_IGN);
vr::EVRInitError error;vr::VR_Init(&error,vr::VRApplication_Overlay);if(error){fprintf(stderr,"SteamVR gamepad input unavailable: %d\n",error);return 1;}
auto*input=vr::VRInput();vr::VRActiveActionSet_t set{};
@@ -47,7 +49,7 @@ int main(int argc,char**argv){
printf("%s\n",node.c_str());fflush(stdout);
// Parent owns routing. Start neutral; EOF, disconnect or disabled actions release input.
bool enabled=false;std::string commands;int previous[18]{};
while(!stopped){pollfd p{STDIN_FILENO,POLLIN,0};if(poll(&p,1,0)>0){char buffer[128];auto count=read(0,buffer,sizeof buffer);if(count<=0)break;commands.append(buffer,count);size_t at;while((at=commands.find('\n'))!=std::string::npos){auto command=commands.substr(0,at);commands.erase(0,at+1);enabled=command=="enable";}if(commands.size()>128)break;}
while(!stopped&&getppid()==parent){pollfd p{STDIN_FILENO,POLLIN,0};if(poll(&p,1,0)>0){char buffer[128];auto count=read(0,buffer,sizeof buffer);if(count<=0)break;commands.append(buffer,count);size_t at;while((at=commands.find('\n'))!=std::string::npos){auto command=commands.substr(0,at);commands.erase(0,at+1);enabled=command=="enable";}if(commands.size()>128)break;}
int state[18]{};
// If Android releases its exclusive claim (e.g. InputReader restart),
// pause until it claims the node again. Do not feed other host consumers.
+4 -1
View File
@@ -4421,7 +4421,8 @@ setup_props
)
.unwrap();
assert!(native::prepare(&f.home, id, 0x92345679).is_err());
let (c, a, response) = native::prepare(&f.home, id, 0x92345678).unwrap();
let (c, a, response, ownership) = native::prepare(&f.home, id, 0x92345678).unwrap();
assert!(native::prepare(&f.home, id, 0x92345678).is_err());
assert_eq!(c.id, "test");
assert_eq!(c.name, "steamlaunch-2452903544");
assert!(!c.steam);
@@ -4439,6 +4440,8 @@ setup_props
fs::read(installed_dir.join("lib/arm64/game.so")).unwrap(),
b"existing extracted library"
);
drop(ownership);
assert!(native::prepare(&f.home, id, 0x92345678).is_ok());
}
#[test]
fn unreadable_state_is_not_uninstallation_or_a_launch_target() {
+23 -36
View File
@@ -49,7 +49,11 @@ pub(super) fn runtime_name(r: &Record) -> Option<String> {
.map(|id| format!("steamlaunch-{id}"))
}
pub(super) fn prepare(home: &Path, id: &str, app_id: u32) -> Result<(Container, App, Value)> {
pub(super) fn prepare(
home: &Path,
id: &str,
app_id: u32,
) -> Result<(Container, App, Value, fs::File)> {
let _guard = MUTATION
.try_lock()
.map_err(|_| anyhow::anyhow!("Another APK operation is running"))?;
@@ -71,6 +75,23 @@ pub(super) fn prepare(home: &Path, id: &str, app_id: u32) -> Result<(Container,
&& r.steam_app_id == Some(app_id),
"Steam APK binding changed; refresh the entry and retry"
);
// Steam's launcher closes inherited descriptors. Keep this lock in the
// session service, which owns it until the launch and container have ended.
fs::create_dir_all(root(home).join("steam"))?;
let ownership = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(false)
.mode(0o600)
.open(
root(home)
.join("steam")
.join(format!("context-{}.lock", hash(&r.context))),
)?;
ensure!(
unsafe { libc::flock(ownership.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0,
"Another application is starting or running in this container"
);
let (a, mut c) = app(home, &db, id)?;
ensure!(
a.installed && a.state_known && a.pending.is_none(),
@@ -163,7 +184,7 @@ pub(super) fn prepare(home: &Path, id: &str, app_id: u32) -> Result<(Container,
}
db.records.get_mut(id).unwrap().steam_app_id = Some(app_id);
save(home, &db)?;
Ok((c, a, json!({"script":path,"env":env})))
Ok((c, a, json!({"script":path,"env":env}), ownership))
}
pub(super) fn run(app: &str, token: &str, command: &[String]) -> Result<()> {
@@ -224,40 +245,6 @@ pub(super) fn run(app: &str, token: &str, command: &[String]) -> Result<()> {
fs::canonicalize(&command[entry + 3])? == fs::canonicalize(&apk)?,
"Unexpected APK launch target"
);
fs::create_dir_all(root(&home).join("steam"))?;
let context_lock = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(false)
.mode(0o600)
.open(
root(&home)
.join("steam")
.join(format!("context-{}.lock", hash(&r.context))),
)?;
ensure!(
unsafe { libc::flock(context_lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0,
"Another application is starting or running in this container"
);
ensure!(
unsafe { libc::fcntl(context_lock.as_raw_fd(), libc::F_SETFD, 0) } == 0,
"Cannot retain native storage ownership"
);
let lock = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(false)
.mode(0o600)
.open(steam_shortcuts::wrapper(&home, app).with_extension("owner.lock"))?;
ensure!(
unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0,
"This APK already has an active Steam launch"
);
// Keep the ownership lock across exec and Lepton's process-group launcher.
ensure!(
unsafe { libc::fcntl(lock.as_raw_fd(), libc::F_SETFD, 0) } == 0,
"Cannot retain native launch ownership"
);
let response = steam_bridge::native_call(
app,
token,
+33 -2
View File
@@ -274,14 +274,18 @@ fn handle(home: &Path, pid: u32, v: Value) -> Result<Value> {
.and_then(|v| u32::try_from(v).ok())
.filter(|v| *v >= 0x80000000)
.context("Launch this APK entry through Steam")?;
let (c, a, response) = super::native::prepare(home, id, app_id)?;
let (c, a, response, ownership) = super::native::prepare(home, id, app_id)?;
let owner_signature = signature(pid).context("Steam launch exited")?;
let home = home.to_owned();
let id = id.to_owned();
let token = token.to_owned();
let gamepad_event = response["env"]["FRAMELY_GAMEPAD_EVENT"]
.as_str()
.map(PathBuf::from);
std::thread::spawn(move || {
let result = (|| -> Result<Value> {
let deadline = Instant::now() + Duration::from_secs(120);
let creation_deadline = Instant::now() + Duration::from_secs(45);
loop {
ensure!(
signature(pid).as_deref() == Some(&owner_signature),
@@ -291,7 +295,14 @@ fn handle(home: &Path, pid: u32, v: Value) -> Result<Value> {
Instant::now() < deadline,
"Native Lepton startup timed out; inspect the APK log"
);
if let Ok(Some(instance)) = current_instance(&c) {
let instance = current_instance(&c)?;
if instance.is_none() {
if let Some(event) = &gamepad_event {
ensure!(event.exists(), "Virtual gamepad disappeared before Lepton created its container; retry the launch");
}
ensure!(Instant::now() < creation_deadline, "Lepton did not create its container; inspect the native Lepton startup log");
}
if let Some(instance) = instance {
if let Ok(s) = lifecycle::sample(&c) {
if s.package == a.metadata.package
&& s.alive.contains(&a.metadata.package)
@@ -316,12 +327,32 @@ fn handle(home: &Path, pid: u32, v: Value) -> Result<Value> {
std::thread::sleep(Duration::from_millis(500));
}
})();
// A failed Podman run can leave Lepton waiting for a create
// event forever. End only this verified, still-uncreated launch;
// never interrupt a container that has already booted.
if result.is_err() && matches!(current_instance(&c), Ok(None)) {
if signature(pid).as_deref() == Some(&owner_signature) {
unsafe {
libc::kill(pid as i32, libc::SIGTERM);
}
}
crate::gamepad::stop_context(&c.name);
}
if let Some(p) = PENDING.lock().unwrap().remove(&id) {
if result.is_ok() {
(p.progress)(json!({"phase":"started"}));
}
let _ = p.result.send(result.map_err(|e| format!("{e:#}")));
}
// Preserve storage ownership after reporting startup success.
// Steam drops inherited fds, so the service retains this lock
// through exit and the container's eventual shutdown.
while signature(pid).as_deref() == Some(&owner_signature)
|| !matches!(current_instance(&c), Ok(None))
{
std::thread::sleep(Duration::from_millis(500));
}
drop(ownership);
});
Ok(response)
}
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""Frame integration check: worker exit retains the pad, pipe EOF removes it.
Run as the Steam user with SteamVR awake. The helper remains disabled throughout;
this test creates its own neutral virtual device and never reads physical input.
Usage: python3 tests/gamepad_lifetime.py HELPER ACTION_MANIFEST
"""
import pathlib
import select
import subprocess
import sys
import threading
import time
def check(helper, manifest):
state = {}
def worker():
try:
child = subprocess.Popen(
[helper, manifest], stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
)
state['child'] = child
if not select.select([child.stdout], [], [], 8)[0]:
raise RuntimeError('Virtual gamepad readiness timed out')
state['node'] = pathlib.Path(child.stdout.readline().decode().strip())
except Exception as error:
state['error'] = error
thread = threading.Thread(target=worker)
thread.start()
thread.join(10)
child = state.get('child')
try:
assert not thread.is_alive(), 'Preparation worker did not finish'
if 'error' in state:
raise state['error']
node = state['node']
assert node.parent == pathlib.Path('/dev/input') and node.name.startswith('event')
time.sleep(2)
assert child.poll() is None, 'Worker exit destroyed the virtual gamepad'
assert node.is_char_device(), 'Virtual gamepad node disappeared'
child.stdin.close()
assert child.wait(timeout=5) == 0, 'Control pipe EOF did not cleanly stop the helper'
assert not node.exists(), 'Virtual gamepad survived control pipe EOF'
print('PASS: worker exit preserves the gamepad; session pipe close removes it')
finally:
if child is not None and child.poll() is None:
child.terminate()
try:
child.wait(timeout=5)
except subprocess.TimeoutExpired:
child.kill()
child.wait()
if __name__ == '__main__':
if len(sys.argv) != 3:
raise SystemExit(__doc__)
check(*sys.argv[1:])