Skip to content

Commit 8af0932

Browse files
authored
Merge pull request #1052 from skyeckstrom/fix/decodefield-exception-mistaken-for-eof
Don't mistake a failed decode thread for end-of-input
2 parents e060a9c + 9a4703a commit 8af0932

2 files changed

Lines changed: 161 additions & 1 deletion

File tree

lddecode/core.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4023,6 +4023,9 @@ def decodefield(self, start, mtf_level, prevfield=None, initphase=False, redo=Fa
40234023

40244024
rv['field'] = None
40254025
rv['offset'] = None
4026+
# Always present so readfield() can test it without a .get() dance; see
4027+
# the handler around lpf_wrapper() below for why it exists.
4028+
rv['exception'] = None
40264029

40274030
readloc = int(start - self.rf.blockcut)
40284031
if readloc < 0:
@@ -4073,7 +4076,16 @@ def decodefield(self, start, mtf_level, prevfield=None, initphase=False, redo=Fa
40734076
except (KeyboardInterrupt, SystemExit):
40744077
raise
40754078
except Exception as e:
4076-
raise e
4079+
# When decodefield() runs on a worker thread (see readfield), raising
4080+
# only unwinds that thread -- the exception never reaches the main one.
4081+
# rv is then left holding the field/offset None sentinels set above,
4082+
# which is exactly what a genuine end-of-input returns (see the
4083+
# `rawdecode is None` early return), so readfield() cannot tell a dead
4084+
# worker from EOF and the decode ends early, successfully, with a
4085+
# truncated .tbc.json. Hand the exception back on rv so readfield() can
4086+
# re-raise it on the main thread.
4087+
rv['exception'] = e
4088+
raise
40774089

40784090
rv['field'] = f
40794091
rv['offset'] = f.nextfieldoffset - (readloc - rawdecode["startloc"])
@@ -4139,6 +4151,14 @@ def readfield(self, initphase=False):
41394151
# In non-threaded mode self.threadreturn was filled earlier...
41404152
# ... but if the first call, this is empty
41414153
if len(self.threadreturn) > 0:
4154+
# A decode thread that died left field/offset as None, which is
4155+
# indistinguishable from the EOF sentinel and would be mistaken
4156+
# for one below ("if f is None and offset is None"), ending the
4157+
# decode early with a zero exit code. Re-raise on the main thread
4158+
# instead, where main()'s handler can report it and exit non-zero.
4159+
if self.threadreturn['exception'] is not None:
4160+
raise self.threadreturn['exception']
4161+
41424162
f, offset = self.threadreturn['field'], self.threadreturn['offset']
41434163

41444164
# Start new thread

tests/test_decodefield_errors.py

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
"""Tests that a failed decode thread is not mistaken for end-of-input.
2+
3+
decodefield() pre-populates its result dict with field/offset None sentinels
4+
before doing any work. Those sentinels are also exactly what a genuine EOF
5+
returns, so when decodefield() runs on a worker thread (the default path) and
6+
raises, readfield() used to read the untouched sentinels back as EOF: the decode
7+
stopped early but exited 0 and wrote a .tbc.json whose field count described the
8+
truncated output as complete.
9+
10+
decodefield() now records the exception on the shared dict and readfield()
11+
re-raises it on the main thread, while a real EOF still reads as EOF.
12+
13+
This is not specific to any exception type -- the sentinels are pre-populated
14+
before any code that can raise, so anything a field's process() throws is
15+
swallowed the same way. The tests therefore use a meaningless exception class,
16+
with the ZeroDivisionError from the original report kept as a single named case.
17+
18+
These drive the two functions against stub objects: constructing a real LDdecode
19+
requires an RF source and a populated DemodCache, which is far more scaffolding
20+
than the None-sentinel/exception contract under test needs.
21+
"""
22+
23+
import types
24+
25+
import pytest
26+
27+
from lddecode.core import LDdecode
28+
29+
30+
class _DecodeBoom(Exception):
31+
"""An arbitrary decode-time failure.
32+
33+
Deliberately not a builtin: the sentinel/EOF collision swallows *any*
34+
exception raised on the decode thread, so the tests use an exception with no
35+
meaning of its own. The ZeroDivisionError that surfaced this in the field is
36+
covered separately below, as one instance of the general case rather than the
37+
thing being fixed.
38+
"""
39+
40+
41+
class _BoomField:
42+
"""Stands in for FieldClass; blows up in process() like a real bad field."""
43+
44+
def __init__(self, *args, **kwargs):
45+
self.valid = False
46+
47+
def process(self):
48+
raise _DecodeBoom("field failed to process")
49+
50+
51+
def _decodefield_stub(rawdecode, field_class=_BoomField):
52+
"""Minimal `self` carrying only what decodefield() touches before process()."""
53+
return types.SimpleNamespace(
54+
rf=types.SimpleNamespace(blockcut=0),
55+
blocksize=32768,
56+
readlen=65536,
57+
demodcache=types.SimpleNamespace(read=lambda *a, **kw: rawdecode),
58+
FieldClass=field_class,
59+
fields_written=0,
60+
wow_level_adjust_smoothing=None,
61+
wow_interpolation_method=None,
62+
curfield=None,
63+
use_profiler=False,
64+
system="NTSC",
65+
)
66+
67+
68+
def _readfield_stub(threadreturn):
69+
"""Minimal `self` carrying only what readfield() touches before the raise."""
70+
return types.SimpleNamespace(
71+
fieldstack=[],
72+
second_decode=None,
73+
fields_written=0,
74+
decodethread=None,
75+
threadreturn=threadreturn,
76+
fdoffset=0,
77+
mtf_level=0,
78+
numthreads=0,
79+
decodefield=lambda *a, **kw: (None, None),
80+
)
81+
82+
83+
def test_decodefield_records_exception_on_rv_and_still_raises():
84+
"""A field that fails to process leaves the exception on the shared dict."""
85+
rv = {}
86+
stub = _decodefield_stub({"startloc": 0})
87+
88+
with pytest.raises(_DecodeBoom):
89+
LDdecode.decodefield(stub, 0, 0, rv=rv)
90+
91+
assert isinstance(rv["exception"], _DecodeBoom)
92+
# The sentinels the caller would otherwise read as EOF are still in place --
93+
# the exception is what distinguishes this dict from a real end-of-input.
94+
assert rv["field"] is None
95+
assert rv["offset"] is None
96+
97+
98+
def test_decodefield_leaves_exception_none_at_eof():
99+
"""A real EOF (no rawdecode) returns the bare sentinels and no exception."""
100+
rv = {}
101+
stub = _decodefield_stub(None)
102+
103+
assert LDdecode.decodefield(stub, 0, 0, rv=rv) == (None, None)
104+
assert rv["exception"] is None
105+
106+
107+
def test_readfield_reraises_a_dead_decode_thread():
108+
"""The failure surfaces on the main thread instead of reading as EOF."""
109+
boom = _DecodeBoom("field failed to process")
110+
stub = _readfield_stub({"field": None, "offset": None, "exception": boom})
111+
112+
with pytest.raises(_DecodeBoom) as excinfo:
113+
LDdecode.readfield(stub)
114+
115+
# The original exception object, so its traceback points at the real cause.
116+
assert excinfo.value is boom
117+
118+
119+
def test_readfield_reraises_the_reported_zerodivisionerror():
120+
"""The specific failure this was found through, as one case of the general one.
121+
122+
A ZeroDivisionError out of a field's process() is what truncated a real
123+
capture at 43% while exiting 0. It gets its own test only so the report has a
124+
regression test that names it -- nothing here is specific to the exception
125+
type, which is the whole point of the fix.
126+
"""
127+
boom = ZeroDivisionError("float division by zero")
128+
stub = _readfield_stub({"field": None, "offset": None, "exception": boom})
129+
130+
with pytest.raises(ZeroDivisionError) as excinfo:
131+
LDdecode.readfield(stub)
132+
133+
assert excinfo.value is boom
134+
135+
136+
def test_readfield_still_reports_eof_as_eof():
137+
"""A clean (None, None) with no exception must keep meaning end-of-input."""
138+
stub = _readfield_stub({"field": None, "offset": None, "exception": None})
139+
140+
assert LDdecode.readfield(stub) is None

0 commit comments

Comments
 (0)