make ICE recover on failure, resolve keyboard not responding to some users, handle blank screen caused by STATUS_STACK_BUFFER_OVERRUN with a watchdog implementation
This commit is contained in:
parent
c6868f692a
commit
19ee3072a0
5
start.sh
5
start.sh
@ -113,7 +113,10 @@ socat TCP-LISTEN:12345,reuseaddr,fork \
|
|||||||
EXEC:"pacat --playback --device=mic_sink --format=s16le --rate=48000 --channels=1 --latency-msec=20" &
|
EXEC:"pacat --playback --device=mic_sink --format=s16le --rate=48000 --channels=1 --latency-msec=20" &
|
||||||
echo "socat mic listener started on TCP :12345"
|
echo "socat mic listener started on TCP :12345"
|
||||||
|
|
||||||
# ── Start streaming agent ─────────────────────────────────────────────────────
|
# ── Start streaming agent (with auto-restart) ─────────────────────────────────
|
||||||
|
# gst_agent exits with code 1 on ICE failure or other fatal errors.
|
||||||
|
# Without a restart loop, the container sits dead until manually restarted.
|
||||||
|
# 3 second delay before each restart to avoid a tight crash loop.
|
||||||
while true; do
|
while true; do
|
||||||
python3 ~/streaming-agent/gst_agent.py
|
python3 ~/streaming-agent/gst_agent.py
|
||||||
echo "[gst_agent] exited with code $?, restarting in 3s..."
|
echo "[gst_agent] exited with code $?, restarting in 3s..."
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import queue as _queue
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
@ -69,15 +70,63 @@ def cf_put(path, body):
|
|||||||
raise RuntimeError(f"CF API error: {data['errorDescription']}")
|
raise RuntimeError(f"CF API error: {data['errorDescription']}")
|
||||||
return data
|
return data
|
||||||
|
|
||||||
# ── Input forwarding via xdotool ──────────────────────────────────────────────
|
# ── Input forwarding via a serialized xdotool worker ──────────────────────────
|
||||||
def xdotool(*args):
|
# Previously every input event spawned a fresh `xdotool` process via Popen with
|
||||||
|
# NO ordering guarantee. Under load (rapid mouse-move + key events) a keyup
|
||||||
|
# could execute before its keydown, leaving a key logically stuck down in X11 —
|
||||||
|
# that's the "keyboard stops responding until handoff/restart" bug.
|
||||||
|
#
|
||||||
|
# Fix: push all input commands onto a single queue processed by ONE worker
|
||||||
|
# thread. Commands execute strictly in the order they were received, so a
|
||||||
|
# keydown always runs before its matching keyup. Each command still runs as a
|
||||||
|
# short-lived xdotool invocation, but serialized, so ordering is guaranteed.
|
||||||
|
_xdotool_queue = _queue.Queue()
|
||||||
|
|
||||||
|
def _xdotool_worker():
|
||||||
env = {**os.environ, 'DISPLAY': DISPLAY}
|
env = {**os.environ, 'DISPLAY': DISPLAY}
|
||||||
subprocess.Popen(
|
while True:
|
||||||
['xdotool'] + list(args),
|
args = _xdotool_queue.get()
|
||||||
env=env,
|
|
||||||
stdout=subprocess.DEVNULL,
|
# Coalesce a run of consecutive mouse moves into one, but only by
|
||||||
stderr=subprocess.DEVNULL,
|
# peeking non-destructively: we drain moves, and if we hit a non-move
|
||||||
)
|
# command we execute the accumulated move, then execute that command.
|
||||||
|
if args and args[0] == 'mousemove_relative':
|
||||||
|
sum_dx = int(args[-2])
|
||||||
|
sum_dy = int(args[-1])
|
||||||
|
pending_cmd = None
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
nxt = _xdotool_queue.get_nowait()
|
||||||
|
except _queue.Empty:
|
||||||
|
break
|
||||||
|
if nxt and nxt[0] == 'mousemove_relative':
|
||||||
|
sum_dx += int(nxt[-2])
|
||||||
|
sum_dy += int(nxt[-1])
|
||||||
|
else:
|
||||||
|
pending_cmd = nxt
|
||||||
|
break
|
||||||
|
# Execute the accumulated movement as a single command
|
||||||
|
_run_xdotool(env, ['mousemove_relative', '--', str(sum_dx), str(sum_dy)])
|
||||||
|
# If we pulled a non-move command out of the queue, execute it too
|
||||||
|
if pending_cmd is not None:
|
||||||
|
_run_xdotool(env, [str(a) for a in pending_cmd])
|
||||||
|
else:
|
||||||
|
_run_xdotool(env, [str(a) for a in args])
|
||||||
|
|
||||||
|
def _run_xdotool(env, argv):
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
['xdotool'] + argv,
|
||||||
|
env=env,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
timeout=2,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f'xdotool error: {e}')
|
||||||
|
|
||||||
|
def xdotool(*args):
|
||||||
|
_xdotool_queue.put(args)
|
||||||
|
|
||||||
# Track held keys so we can release them all on controller change
|
# Track held keys so we can release them all on controller change
|
||||||
_held_keys = set()
|
_held_keys = set()
|
||||||
@ -225,8 +274,15 @@ class CSSStreamAgent:
|
|||||||
GstWebRTC.WebRTCICEConnectionState.COMPLETED):
|
GstWebRTC.WebRTCICEConnectionState.COMPLETED):
|
||||||
self._ice_ready.set()
|
self._ice_ready.set()
|
||||||
elif state == GstWebRTC.WebRTCICEConnectionState.FAILED:
|
elif state == GstWebRTC.WebRTCICEConnectionState.FAILED:
|
||||||
log.error('ICE failed')
|
log.error('ICE failed — exiting process so start.sh can restart it')
|
||||||
self.loop.quit()
|
# os._exit forces immediate termination of the entire process,
|
||||||
|
# including the main thread blocked in asyncio. self.loop.quit()
|
||||||
|
# alone only stops the GLib thread, leaving the asyncio signaling
|
||||||
|
# loop running forever — which is why it got stuck instead of
|
||||||
|
# restarting.
|
||||||
|
os._exit(1)
|
||||||
|
elif state == GstWebRTC.WebRTCICEConnectionState.DISCONNECTED:
|
||||||
|
log.warning('ICE disconnected — monitoring for recovery')
|
||||||
|
|
||||||
def set_remote_description(self, sdp_text, sdp_type='answer'):
|
def set_remote_description(self, sdp_text, sdp_type='answer'):
|
||||||
_, sdp = GstSdp.SDPMessage.new_from_text(sdp_text)
|
_, sdp = GstSdp.SDPMessage.new_from_text(sdp_text)
|
||||||
@ -261,6 +317,60 @@ class CSSStreamAgent:
|
|||||||
finally:
|
finally:
|
||||||
self.stop()
|
self.stop()
|
||||||
|
|
||||||
|
# ── CSS process watchdog ──────────────────────────────────────────────────────
|
||||||
|
def css_watchdog():
|
||||||
|
# If cstrike_win64.exe crashes (e.g. Wine c0000409 stack overrun), the
|
||||||
|
# stream keeps running but shows a black screen forever with no recovery.
|
||||||
|
# This watchdog detects the crash and relaunches CSS directly via revLoader,
|
||||||
|
# the same way start.sh does on initial boot. gst_agent keeps running and
|
||||||
|
# will simply start capturing the relaunched game once it renders.
|
||||||
|
#
|
||||||
|
# We only start watching once CSS has been seen alive at least once, to
|
||||||
|
# avoid racing its initial startup. Require several consecutive misses to
|
||||||
|
# avoid false positives during a normal controller-triggered relaunch
|
||||||
|
# (the signaling server kills+relaunches CSS on controller change).
|
||||||
|
home = os.path.expanduser('~')
|
||||||
|
revloader = os.path.join(home, 'Counter-Strike Source', 'revLoader.exe')
|
||||||
|
seen_alive = False
|
||||||
|
missing_count = 0
|
||||||
|
while True:
|
||||||
|
time.sleep(5)
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
['pgrep', '-f', 'cstrike_win64.exe'],
|
||||||
|
capture_output=True, text=True
|
||||||
|
)
|
||||||
|
alive = result.returncode == 0
|
||||||
|
except Exception:
|
||||||
|
alive = True # on error, assume alive (don't false-trigger)
|
||||||
|
|
||||||
|
if alive:
|
||||||
|
seen_alive = True
|
||||||
|
missing_count = 0
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not seen_alive:
|
||||||
|
continue
|
||||||
|
|
||||||
|
missing_count += 1
|
||||||
|
log.warning(f'CSS process not found ({missing_count}/4)')
|
||||||
|
# 4 consecutive misses (~20s) — long enough to not collide with the
|
||||||
|
# signaling server's own kill+relaunch cycle on controller change.
|
||||||
|
if missing_count >= 4:
|
||||||
|
log.error('CSS appears crashed — relaunching revLoader')
|
||||||
|
try:
|
||||||
|
env = dict(os.environ, DISPLAY=DISPLAY)
|
||||||
|
subprocess.Popen(['wine', revloader], env=env,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL)
|
||||||
|
log.info('revLoader relaunched by watchdog')
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f'watchdog relaunch failed: {e}')
|
||||||
|
# Reset so we give the relaunched game time to come up before
|
||||||
|
# counting misses again.
|
||||||
|
seen_alive = False
|
||||||
|
missing_count = 0
|
||||||
|
|
||||||
# ── Signaling ─────────────────────────────────────────────────────────────────
|
# ── Signaling ─────────────────────────────────────────────────────────────────
|
||||||
async def run_signaling(session_id, video_track_name, audio_track_name):
|
async def run_signaling(session_id, video_track_name, audio_track_name):
|
||||||
log.info(f'Connecting to signaling server {SIG_URL}')
|
log.info(f'Connecting to signaling server {SIG_URL}')
|
||||||
@ -290,6 +400,10 @@ def main():
|
|||||||
Gst.init(None)
|
Gst.init(None)
|
||||||
log.info(f'Starting — {CAP_W}x{CAP_H}@{CAP_FPS}fps display={DISPLAY}')
|
log.info(f'Starting — {CAP_W}x{CAP_H}@{CAP_FPS}fps display={DISPLAY}')
|
||||||
|
|
||||||
|
# Start the serialized xdotool input worker (guarantees keydown/keyup order)
|
||||||
|
xw = threading.Thread(target=_xdotool_worker, daemon=True)
|
||||||
|
xw.start()
|
||||||
|
|
||||||
agent = CSSStreamAgent()
|
agent = CSSStreamAgent()
|
||||||
agent.build_pipeline()
|
agent.build_pipeline()
|
||||||
agent.start()
|
agent.start()
|
||||||
@ -342,6 +456,10 @@ def main():
|
|||||||
log.info(f'Audio track: {audio_track_name}')
|
log.info(f'Audio track: {audio_track_name}')
|
||||||
log.info(f'Streaming — sessionId={session_id}')
|
log.info(f'Streaming — sessionId={session_id}')
|
||||||
|
|
||||||
|
# Start CSS crash watchdog (relaunches the game if it dies unexpectedly)
|
||||||
|
wd = threading.Thread(target=css_watchdog, daemon=True)
|
||||||
|
wd.start()
|
||||||
|
|
||||||
asyncio.run(run_signaling(session_id, video_track_name, audio_track_name))
|
asyncio.run(run_signaling(session_id, video_track_name, audio_track_name))
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user