PC host: review fixes (idle watchdog, controller close race, malformed input, loader path)

- The 10 s watchdog no longer ends idle sessions. PipeWire and WGC deliver
  frames only on damage; fail only when a frame entered the encoder and never
  came out, or the pipeline never produced its initial frame.
- Controller.state/call/update are inert after close(), so a /status poll
  racing Stop cannot dereference the freed native controller.
- Malformed viewer fields drop the event instead of ending the session.
- PATH/LD_LIBRARY_PATH prepends add no empty (current directory) entry.
- portal.c: document fd ownership. pipewiresrc dups the fd it is given
  (gstpipewirecore.c, F_DUPFD_CLOEXEC), so the portal closing its own fd is
  correct; clear it after close.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
saphidandClaude Opus 5.5 committed 2026-09-29 10:44:12 +10:00
1 parent d9d538690e
commit a992f6d896
5 files changed
+135 -14

No files matched your search

+37 -7
View File
@@ -27,6 +27,10 @@ from frame_pc_capture import Native, Controller, Windows, WindowsInput, PortalIn
from frame_stream_stats import Stats
# Viewer fields are untrusted JSON: float('x'), int(None), m['t'] missing.
MALFORMED = (ValueError, TypeError, KeyError, OverflowError)
class Grants:
"""Caller holds the agent lock, including replacement and Stop."""
def __init__(self, token):
@@ -172,6 +176,22 @@ class Session:
self.stop_event.set()
return -1
STALL = 10000000
def stalled(self, now, opened, captured_at_open):
"""PipeWire and WGC deliver frames only on screen damage, so an idle
desktop legitimately produces no output. Fail only on evidence of a
fault: a frame entered the encoder and never came out, or the capture
never produced its initial frame after the pipeline opened."""
with self.lock:
oldest = min((r['e0'] for r in self.pending.values()), default=None)
captured = self.stats.captured
if oldest is not None and now-oldest > self.STALL:
return 'The encoder stopped producing frames; check the video encoder'
if captured == captured_at_open and now-opened > self.STALL:
return 'No frames captured for 10 seconds; check capture permissions'
return None
def fresh_pipewire(self):
if 'portal' not in self.source:
return
@@ -193,21 +213,24 @@ class Session:
if not capture:
raise RuntimeError(error.value.decode(errors='replace'))
last_update = last_stats = self.native.now()
last_output = last_config = last_update
last_config = opened = last_update
captured_at_open = self.stats.captured
applied = (self.w, self.h, self.bitrate)
wanted = applied
while not self.stop_event.is_set():
while not self.test_inputs.empty():
event = self.test_inputs.get_nowait()
native.fc_capture_test(capture, int(event.get('i', 0)) & 0xffffffff)
self.stats.input(event)
try:
native.fc_capture_test(capture, int(event.get('i', 0)) & 0xffffffff)
self.stats.input(event)
except MALFORMED:
pass # a bad probe field drops the event, not the stream
output = Encoded()
result = native.fc_capture_pull(capture, C.byref(output))
now = self.native.now()
if result < 0:
raise RuntimeError(native.fc_capture_error(capture).decode(errors='replace'))
if result:
last_output = now
with self.lock:
# At most three raw frames are in flight; no B-frames.
# x264 offsets PTS, so match the FIFO encode order while
@@ -224,8 +247,9 @@ class Session:
with self.lock:
f['wire'] = self.native.now()
self.pending.pop(raw_pts, None)
if now-last_output > 10000000:
raise RuntimeError('No encoded frames for 10 seconds; check capture permissions and the encoder')
stall = self.stalled(now, opened, captured_at_open)
if stall:
raise RuntimeError(stall)
if self.key_event.is_set():
self.key_event.clear()
if self.codec == 'h264':
@@ -260,6 +284,7 @@ class Session:
if not capture:
raise RuntimeError(error.value.decode(errors='replace'))
applied, self.bitrate, last_config = wanted, wanted[2], now
opened, captured_at_open = now, self.stats.captured
with self.lock:
self.reconfiguring = False
if now-last_stats >= 1000000:
@@ -304,7 +329,10 @@ class Session:
elif t == 'key-frame':
self.key_event.set()
elif t in ('rx', 'fd', 'clock'):
self.stats.report(m)
try:
self.stats.report(m)
except MALFORMED:
pass
if t == 'rx' and isinstance(m.get('s'), int):
self.controller.call('ack', m['s'] & 0xffffffff, self.native.now())
elif t in ('m', 'wheel', 'k', 'text', 'release'):
@@ -317,6 +345,8 @@ class Session:
try:
self.input.handle(m)
self.stats.input(m)
except MALFORMED:
pass # drop a malformed viewer event; keep the session
except RuntimeError as e:
self.input_enabled = False
try:
+11 -3
View File
@@ -75,7 +75,9 @@ class Native:
class Controller:
def __init__(self, lib, fps, ceiling):
self.lib, self.lock = lib, threading.RLock()
# close() may run on the stream thread while /status reads state();
# after close every entry point is a no-op, never a NULL dereference.
self.lib, self.lock, self.fps = lib, threading.RLock(), fps
self.enabled = os.environ.get('FRAME_MAC_VIEW_ADAPT') != '0'
self.ptr = lib.fc_new(fps, self.enabled)
if not self.ptr:
@@ -86,17 +88,23 @@ class Controller:
def state(self):
with self.lock:
names = ['target', 'ceiling', 'tier', 'fps', 'scale', 'baseRtt', 'inFlight', 'slack']
out = {k: self.lib.fc_value(self.ptr, i) for i, k in enumerate(names)}
if not self.ptr:
out = dict.fromkeys(names, 0)
out.update(fps=self.fps, scale=100)
else:
out = {k: self.lib.fc_value(self.ptr, i) for i, k in enumerate(names)}
out.update(scale=out['scale'] / 100, baseRtt=out['baseRtt'] / 1000,
slack=out['slack'] / 1000, adapt=self.enabled)
return out
def call(self, name, *args):
with self.lock:
return getattr(self.lib, 'fc_' + name)(self.ptr, *args)
return getattr(self.lib, 'fc_' + name)(self.ptr, *args) if self.ptr else 0
def update(self, now):
with self.lock:
if not self.ptr:
return 0
old = self.state()
result = self.lib.fc_update(self.ptr, now)
state = self.state()
+8 -2
View File
@@ -11,6 +11,12 @@ from frame_macview import MacView, MacViewError, ROOT
from frame_pc_capture import LIBRARY, NATIVE
def prepend(env, name, path):
"""An empty entry means the current directory to the loader; never add one."""
rest = env.get(name, '')
env[name] = str(path) + (os.pathsep + rest if rest else '')
class PCView(MacView):
viewer_profile = 'pc-view'
host = 'windows' if sys.platform == 'win32' else 'linux'
@@ -46,9 +52,9 @@ class PCView(MacView):
env['GST_REGISTRY_1_0'] = os.path.join(cache, 'gstreamer-registry.bin')
env['GST_REGISTRY_FORK'] = 'no'
if sys.platform == 'win32':
env['PATH'] = str(NATIVE / 'bin') + os.pathsep + env.get('PATH', '')
prepend(env, 'PATH', NATIVE / 'bin')
else:
env['LD_LIBRARY_PATH'] = str(NATIVE / 'lib') + os.pathsep + env.get('LD_LIBRARY_PATH', '')
prepend(env, 'LD_LIBRARY_PATH', NATIVE / 'lib')
env['PIPEWIRE_MODULE_DIR'] = str(NATIVE / 'lib' / 'pipewire-0.3')
env['SPA_PLUGIN_DIR'] = str(NATIVE / 'lib' / 'spa-0.2')
env['PIPEWIRE_CONFIG_DIR'] = str(NATIVE / 'share' / 'pipewire')