fix(vendor): end the manual-intervention wait when nobody can answer it (#522) - #542
Conversation
…it (#522) Closes #522. `Vendor._request()`'s last resort -- ask an operator to save the page by hand -- was a `while True` whose only pause was an `input()` wrapped in `contextlib.suppress(Exception)`. With no terminal `input()` raises instead of blocking, the suppression discarded it, the file the loop waits for never appeared, and the iteration repeated immediately: 26.7 million lines / 2.6 GB of output measured in the issue before the process was killed. Never a hang, a printer. - vendor/default.py: new `stdin_is_interactive()`, and a fetch failure with no interactive `stdin` now re-raises the `RequestException` exactly as `PCAPKIT_CI_MODE` does, before a browser is opened, a temporary directory made, or a word printed. This is the half no in-loop handling can cover: under `yes | make vendor` the prompt returns 'y' forever and raises nothing at all, and the old loop still spun. - vendor/default.py: the loop's `suppress(Exception)` becomes an `except` that re-raises the fetch error with the prompt failure as its `__cause__`. Measured on CPython 3.14.7, `input()` reports an unusable stdin three ways -- `EOFError` at EOF, `ValueError` when closed, `RuntimeError` when `sys.stdin` is gone -- and all three were suppressed alike. Still needed alongside the check above, since a terminal can go away *during* the wait. `KeyboardInterrupt` is a `BaseException` and keeps aborting as itself. - vendor/default.py: `_request`'s docstring gains the `Raises:` clause it never had and states which runs take the manual path. - tests: 16 cases in tests/vendor/test_request_prompt_unit.py, bounded by a fake `input` that gives up after 16 prompts rather than by a timeout, so the unfixed code fails in milliseconds instead of printing. Reverting only the loop fix fails 2 cases and all 3 sub-cases; reverting only the interactivity gate fails 1; both in place, pytest exits 0. Coverage of vendor/default.py 50% -> 67%. `.github/workflows/cron-vendor.yml` already exports `PCAPKIT_CI_MODE=1`, so the scheduled job was never exposed -- local, piped and containerised runs were.
6bccb2a to
7a886e6
Compare
|
✅ GOOD TO MERGE (head |
Detailed review (independent verification, falsify-not-bless)Head sha reviewed: The three-way
|
Closes #522.
Root cause
Vendor._request()'s last resort is to ask an operator to fetch the page byhand. The wait for them was a
while Truewhose only pause was aninput()wrapped in
contextlib.suppress(Exception):With nobody at a keyboard, the suppression is what does the damage rather than
the loop.
input()raises instead of blocking, the suppression discards theexception,
os.path.isfile(temp_file)is false because nobody could save thepage, and the iteration repeats immediately -- three lines of output per pass,
which the issue measured at 26.7 million lines / 2.6 GB before the process
was killed. It was never a hang; it was a printer.
Two things make the defect wider than "stdin is closed":
input()reports an unusable stdin three different ways, and thesuppression discarded all three alike. Measured on CPython 3.14.7 in this
venv:
EOFErrorat end of file (redirected from/dev/null, a closed pipe),ValueError: I/O operation on closed filewhenstdinhas been closed, andRuntimeError: lost sys.stdinwhensys.stdinisNone. A fix that caughtonly
EOFErrorwould leave two thirds of it live.yes | make vendor,input()returns'y'forever, the file still neverappears, and the loop still spins at full speed. No amount of exception
handling inside the loop catches that one.
The fix
Two halves, because neither covers the other.
stdin_is_interactive()decides whether there is aterminal at all, and a fetch failure without one re-raises the
requests.RequestException-- exactly asPCAPKIT_CI_MODEalready did --before a browser is opened, a temporary directory is created under
os.curdir, or a word is printed. This is the half that covers the pipedcase above, and it also stops a non-interactive run littering the working
directory and the log with instructions nobody will read. The predicate
answers
Falserather than raising for an absent, closed, orisatty-lessstdin, so a crawler reporting a fetch failure cannot have it replaced by anunrelated traceback.
suppress(Exception)becomes anexceptthat re-raises the fetch error with the prompt failure as its
__cause__, sothe caller gets the same failure
PCAPKIT_CI_MODEwould have given it and thetraceback still says why the manual path gave up. The precondition does not
make this redundant: a terminal can go away during the wait -- an SSH session
dropping, a parent closing the descriptor -- and that lands in exactly the loop
the precondition already let the process into.
KeyboardInterruptis aBaseException, so it escaped the oldsuppress(Exception)and escapes the newexcept Exceptionunchanged. Ctrl-C ishow an operator declines, and turning it into "connection failed" would be a lie
about what happened; that is pinned by a test rather than left to reading.
_request's docstring gains theRaises:clause it never had, and says whichruns take the manual path.
Behaviour in each of the three cases
PCAPKIT_CI_MODEunsetPCAPKIT_CI_MODEunsetinput()raised (/dev/null, closed pipe) or returned forever (yes |).Connection failed; exit as stdin is not interactive...and re-raises therequests.RequestException. No browser, no temporary directory, nothing printed.PCAPKIT_CI_MODE=1exit on CI mode...and re-raises before any of the above.stdinis consulted, so a CI job that happens to get an allocated tty keeps failing fast..github/workflows/cron-vendor.ymlexportsPCAPKIT_CI_MODE=1(lines 63-64), sothis repo's weekly scheduled crawl was never exposed. The exposure was everything
else that drives the crawlers non-interactively -- a local
make vendorunder apipe, a container build, a cron entry that is not that workflow, an agent session
-- none of which has a reason to know the variable exists.
Tests, and the fails-without evidence
tests/vendor/test_request_prompt_unit.py, 16 cases (plus 3 sub-cases), unittier, no network:
requests.getis replaced for the whole of every case, and onecase asserts the quiet direction -- that a crawler which can fetch never
consults
stdinat all.No case can wedge the suite, and none relies on a timeout to notice the
runaway. The fake
inputanswers a bounded number of prompts and then raises_Runaway, which derives fromBaseExceptionprecisely so that neither the oldsuppress(Exception)nor the newexcept Exceptioncan catch it. Against theunfixed code a case therefore fails in milliseconds with a message naming the
defect (
input() was answered 16 times and the manual-intervention wait still had not ended: the loop is unbounded (#522)) instead of printing until something killsit.
tests._support.time_limitis layered on as a backstop, and stdout iscaptured throughout so the bounded flood is counted rather than spilled.
Each half of the fix was reverted on its own, so the evidence is behavioural
rather than "the new helper is missing". Run as
PYTHONSAFEPATH=1 .venv/bin/python -m pytest tests/vendor/test_request_prompt_unit.py -v,with pytest's exit status read from a file rather than a pipeline position:
suppress(Exception)restored, gate kept)test_a_stdin_at_eof_ends_the_wait_instead_of_spinning,test_the_prompt_failure_is_chained_as_the_cause, and all 3 sub-cases oftest_every_way_input_can_fail_ends_the_waittest_a_non_interactive_run_never_reaches_the_promptpcapkit/vendor/default.pyexactly as it stands onmainThe full unit tier -- the selection
.github/workflows/unit-tests.ymlruns, i.e.pytest tests --ignore=tests/integration --ignore-glob='*_runtime.py' --ignore-glob='*_regression.py'-- is green on this branch rebased onto
691f12ab5: 1052 passed, 8 skipped,2491 subtests passed, exit code 0, no failures.
Coverage of
pcapkit/vendor/default.pyundertests/vendor/, measured withcoverage run -m pytest(notpytest-cov): 50% -> 67%, misses 86 -> 59,partial branches 9 -> 8. The manual-intervention branch was previously uncovered
in its entirety; every line of it is now covered, including both wordings of the
instructions. The remaining misses in that method are the pre-existing proxy-retry
path and the two
MAX_RETRY-exhaustedraises, which this change does not touch.Which tree was measured, printed rather than assumed:
Both test classes also skip, rather than pass vacuously, if
pcapkit.vendor.defaultresolves outside the checkout under test.requests,bs4andhtml5libship in thevendorextra rather thantest, so the moduleis guarded with
importlib.util.find_specplus@unittest.skipUnless, mirroringtests/protocols/test_dispatch_registry_unit.py.Other checks on the changed module:
mypyclean,banditclean,isort --checkclean,
pylint(the repo's own invocation from the Makefile) reports only thethree findings that were already there -- the long
LINEtemplate line and the twopre-existing
missing-timeoutwarnings onrequests.get.Deliberately NOT fixed
a bound would be the wrong thing: pressing ENTER before saving the file is a
normal mistake, the retry notice is human-paced, and a cap would turn a mistimed
keystroke into a failed crawl. Every non-interactive route into the loop is now
closed, which is what the unboundedness was actually costing.
CI_MODE's default was left asfalse. The issue floats deriving it fromnot sys.stdin.isatty(). That would fold two distinct facts into one flag andmake the
exit on CI mode...warning misleading when it fired for want of aterminal, so the interactivity check is a separate decision with its own message.
PCAPKIT_CI_MODEkeeps meaning exactly what it meant.environment where
sys.stdin.isatty()isFalseandinput()neverthelessworks -- a Jupyter kernel is the real example, since
ipykernelpatchesbuiltins.input-- the manual path is now refused where it would previously haveworked. Judged acceptable: a vendor crawler writes generated constant modules
into the package source tree and is a maintainer tool driven by
make vendor, soa notebook is not a plausible host; and the failure is a clear
RequestExceptionwith a warning naming the reason, against a 2.6 GB print loopon the other side of the trade.
requests.get's missingtimeout(pylintW3101 on both call sites,carrying
# nosec: B113). A genuinely separate hang risk from this one, in codethis change does not touch.
MAX_RETRY-exhaustedraises remainuncovered. They belong to the retry logic rather than the prompt, and
tests/vendor/test_user_agent_unit.pyalready owns the proxy branch.get_user_agentandstdin_is_interactivehave noautofunctionentry indocs/source/pcapkit/vendor/default.rst, which lists onlyget_proxies.Pre-existing for
get_user_agent(added in Vendor crawlers: three take a Wikipedia 403 on the default User-Agent, three point at a dead IETF URL #518) and matched here forconsistency rather than fixed, since the docs tree is outside this change.