Add the in-process API lifecycle suite (Phase 5) - #360
Conversation
tests/functional/test_api_lifecycle.py covers what a subprocess cannot: vncdotool.api's own lifecycle -- connect/op/disconnect, the documented context-manager pattern, sequential reconnects on one reactor, a TimeoutError against a silent listener, and a prompt ConnectError against a closed port -- all against the single known-good tigervnc container. Server compatibility stays with the subprocess grid. This is the one module permitted to touch the api in-process: the module docstring, vncservers.connect()'s docstring, and the design doc all now state the rule and why it is safe inside the shared functional discover (the module sorts first; api.shutdown() in tearDownModule is terminal for the process). The potentially-wedging closed-port case is ordered last and double-bounded (client timeout plus a joined helper thread) so it can never hang the suite. Happy-path calls carry the same timeout headroom the subprocess grid uses, after a 5s-budget flake was observed under load. make test-api runs the module alone. Both fail-loudly cases pass today without expectedFailure: connect() is fire-and-forget by design, the first proxied call raises TimeoutError via queue.get, and a closed port surfaces Twisted's ConnectionRefusedError promptly. Verified: 5/5 module tests green (twice), full functional discover 36 tests green (twice, clean process exit both times), skips-and-exits with the fleet down, unit suite and flake8 clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ecycle Subprocess-only functional modules never touch the in-process reactor, so run order relative to them can't matter -- only the one-module rule does. The earlier wording implied filename sort order was doing safety work it wasn't.
…down A module-level skip meant this whole file's coverage could silently vanish from a green CI run if the port probe ever desynced from the compose healthcheck. setUpModule now raises RuntimeError instead of SkipTest, so unittest reports an ERROR (and a nonzero exit) with the same 'start the servers first' message, rather than a silent skip. Also drop two comments that restated what the next line already says, per the WHAT-vs-WHY comment policy.
| use, reconnection, error propagation, timeouts, ``shutdown()`` cleanliness. | ||
| Server compatibility belongs to the subprocess grid in test_servers.py. | ||
|
|
||
| THIS IS THE ONE MODULE ALLOWED TO CALL api.connect(). |
There was a problem hiding this comment.
gratuitiious, Extraneous comments
There was a problem hiding this comment.
🤖 Claude Code: Trimmed. Dropped the now-stale "ordering matters for the same reason" paragraph (the ordering claim itself is gone, see below) and kept only the one-reactor-per-process rationale, which is still load-bearing.
|
|
||
|
|
||
| def setUpModule() -> None: | ||
| if not port_open(HOST, TIGERVNC.port): |
There was a problem hiding this comment.
better to run against libvncserver rather than tiger?
There was a problem hiding this comment.
🤖 Claude Code: Switched to libvncserver-example -- lighter, more deterministic, and no behavioural reason to prefer tigervnc for a lifecycle-only suite.
| class TestApiLifecycle(unittest.TestCase): | ||
| """Ordered lifecycle cases against the tigervnc:5931 container. | ||
|
|
||
| Names are prefixed to fix run order: well-behaved cases first, the |
There was a problem hiding this comment.
Dependent ordering is a code smell. We should be reslient to failures. If not let's dig into why to establish this is required with evidence rather than assumed.
There was a problem hiding this comment.
🤖 Claude Code: Checked rather than assumed: the closed-port case is already bounded by its own timeout + a joined helper thread, so a wedge there fails only that test. Verified live by running it first (it now sorts first alphabetically) and confirming the tests after it still passed. Dropped the a/b/c/d/z ordering scheme entirely -- it was redundant with protection already in place.
| """ | ||
|
|
||
| def test_a_connect_op_disconnect(self) -> None: | ||
| """connect -> one trivial op -> disconnect: clean, no exception.""" |
There was a problem hiding this comment.
🤖 Claude Code: Removed as a separate test. setUpModule now makes this exact connect/refresh/disconnect itself, which also fixes the reactor-startup ordering dependency (see the ordering thread below) rather than relying on this test happening to run first.
| client = api.connect(f"{HOST}::{TIGERVNC.port}") | ||
| client.timeout = HAPPY_TIMEOUT | ||
| try: | ||
| client.refreshScreen() |
There was a problem hiding this comment.
rather than op let's call it refresh. op feels more like a keypress
There was a problem hiding this comment.
🤖 Claude Code: That exact docstring text is gone now (test_a folded into setUpModule, see above), but noting for the record: agreed, 'refresh' is the clearer word for what refreshScreen() does.
|
|
||
| def test_a_connect_op_disconnect(self) -> None: | ||
| """connect -> one trivial op -> disconnect: clean, no exception.""" | ||
| client = api.connect(f"{HOST}::{TIGERVNC.port}") |
There was a problem hiding this comment.
should connect/disco be part of setup/teardown? Avoids duplicating code?
There was a problem hiding this comment.
🤖 Claude Code: Per-test setUp/tearDown doesn't actually fit here -- test_context_manager_connect, test_sequential_reconnects, and the timeout/closed-port cases each need a different connection pattern or target on purpose, so a shared setUp would just add an unused connection to most of them. Moved the one case that did want a plain connect/refresh/disconnect into setUpModule instead, which removes the duplication AND fixes the reactor-startup-ordering issue in the other thread.
| with connect(TIGERVNC, timeout=HAPPY_TIMEOUT) as client: | ||
| client.refreshScreen() | ||
|
|
||
| def test_d_timeout_raises_timeout_error(self) -> None: |
There was a problem hiding this comment.
🤖 Claude Code: Letter prefixes dropped entirely -- tests are now named for what they check (test_context_manager_connect, test_sequential_reconnects, test_timeout_raises_timeout_error, test_closed_port_raises_promptly), no ordering scheme.
| elapsed = time.monotonic() - start | ||
|
|
||
| # Generous bound: proves the timeout fired, without being a | ||
| # flakiness trap on a loaded CI box. |
There was a problem hiding this comment.
🤖 Claude Code: Agreed on reflection -- the +5.0 was unexamined padding, and nothing established the tighter bound as actually flaky. Tightened to SHORT_TIMEOUT + 1.0 and rewrote the comment to state that reasoning instead of hand-waving 'generous.'
| protocol-level timeout rather than a connection-refused. | ||
| """ | ||
|
|
||
| def __init__(self) -> None: |
There was a problem hiding this comment.
Is there not a more Twisted native version of this?
There was a problem hiding this comment.
🤖 Claude Code: Yes -- replaced the hand-rolled socket+threading listener with a Protocol that does nothing, registered on api.connect()'s own already-running reactor via reactor.callFromThread(reactor.listenTCP, ...) instead of a second, unrelated listener thread.
…n a bound - Target libvncserver-example instead of tigervnc: lighter, more deterministic, and its stdout is already read elsewhere in the fleet for observability. No behavioural reason to prefer tigervnc here. - Drop the a/b/c/d/z ordering scheme. It assumed the closed-port case needed to run last to avoid poisoning the suite, but that case is already bounded by its own timeout and a joined helper thread -- verified live by running it first (it now sorts first alphabetically) and confirming every test after it still passes. Rather than leave connect/disconnect boilerplate duplicated once the ordering rationale was gone, setUpModule now makes the first connection itself (a real connect/refresh/disconnect), which also means the reactor is guaranteed running before any test method needs it, deterministically rather than by being first alphabetically. - Tighten the timeout-path assertion bound from SHORT_TIMEOUT + 5.0 to SHORT_TIMEOUT + 1.0. The wider bound was unexamined padding: it would hide a real timeout-handling regression rather than catch one, and nothing suggested the tighter bound was actually flaky. - Replace the hand-rolled socket+threading silent listener with a Twisted-native one: a Protocol that does nothing, registered on api.connect()'s own already-running reactor via reactor.callFromThread(reactor.listenTCP, ...) rather than a second, unrelated listener thread. Addresses review comments on PR #360.
Three test docstrings opened with a sentence that just restated the method name (test_sequential_reconnects, test_timeout_raises_..., and test_closed_port_raises_promptly) before getting to the actual WHY. Dropped those opening sentences, keeping only the non-obvious rationale. Also folded the module-level ALL-CAPS banner into plain prose -- no other module in the codebase uses that heading style, and the paragraph right after it already carried the weight.
Summary
tests/functional/test_api_lifecycle.pycoversvncdotool.api's lifecycle in-process (connect/op/disconnect, context-manager use, sequential reconnects, TimeoutError, prompt ConnectError) against the tigervnc container -- the one module permitted to callapi.connect(), since a process gets exactly one Twisted reactor.make test-apiruns it alone; it also sorts first in the shared functional discover.make test-apitarget added; design doc andvncservers.connect()docstring updated to state the one-module rule and why it's safe.Test plan
make test-api-- 5/5 greenLast phase of the server testing framework (#347, #348, #350, #351, #352 already merged).
🤖 Generated with Claude Code