Conversation
Three things stood between the test fixture and the async endpoint, none of them related to each other: - The composed job URLs were malformed. configparam.py appends HTTP_PORT to HTTP_URL for any port other than 80/443, and the fixture had the port in both, so the status URL came back as host:port:0. No sync test reads httpurl, which is why this never surfaced. Split the template into TEST_HTTP_HOST and TEST_HTTP_PORT. - A CGI that dies has only stderr to say so, and the fixture handler dropped it, which makes a 500 from the script indistinguishable from a 500 the script meant to send. Surface it on the runner's stderr. - tap.py asks BeautifulSoup for the 'lxml' tree builder by name when it reads a status document (TAP/tap.py:904, 2162), so bs4 alone is not enough for anything that touches an existing job. Add lxml to the test requirements. (setup.py's install_requires is missing it too, along with bs4, xmltodict, astropy and sqlparse; that is a packaging fix for its own PR.) Also strips one trailing space in TAP/vositables.py that came in with the v3.0.1 merge and is currently failing `ruff check .` on develop. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q5cRrMXoPJ6zSiGfKYcqjC
An async submit has to finish an HTTP response now and keep executing the query afterwards. Under CGI those pull against each other: the server completes the response when the script closes stdout, and mod_cgi terminates the script once the request is cleaned up. So the process that runs the query can be neither the one holding stdout nor a member of the request's process group. __printAsyncResponse__ resolved that by sending the 303, sleeping two seconds, and then SIGKILLing os.getppid(). Under CGI that parent is the web server child serving the request. Killing it does end the response, since the body carried no Content-Length, and it does orphan the CGI so the job survives. But the client's connection dies mid-message, and behind a reverse proxy the proxy is the one holding that connection: it marks the backend in error and answers 5xx for the next request or two routed over it. Bare mod_cgi respawned the killed child silently, so the damage only became visible once a proxy was put in front. Fork instead. The parent records the child as the job's runId, writes the status document, sends the 303 and exits, which ends the response the way any other CGI does. The child calls setsid() to leave the request's process group, points its standard streams at /dev/null, and runs the query. Three details are load-bearing: - The child waits on a pipe until the parent has published the status document. Without that, a fast query can write COMPLETED before the parent writes EXECUTING, and the job looks like it regressed. - The child redirects stdio before it waits, so it never holds a dup of the server pipe (which would delay the response) and can never write into a response that has already been sent. - uws:runId now carries the child's pid, because ABORT kills whatever runId names (TAP/tap.py:1125-1136). A stale pid there would either do nothing or, worse, signal an unrelated process. The response is also self-delimiting now: an nph- script gets no Content-Length from the server, and a proxy has no other way to know where the message ends. If fork() fails, the request is answered from this process and the query runs here, which is the old behavior minus the kill: correct response, job tied to the request's lifetime. tests/test_async_flow.py covers the full three round trips (submit, PHASE=RUN, poll to COMPLETED plus a non-empty result on disk), asserts the 303 is framed, and guards the regression at the source. That last one is a source assertion on purpose: the behavioral version is "the process running this suite is still alive", which a suite that has been SIGKILLed cannot make. Before this change, running these tests printed "Killed" and nothing else. tests/test_pyneid_compat.py recorded that pyNEID "hung at step 2 with no observable error in the TAP debug log". Step 2 is PHASE=RUN. Its docstring is corrected to say what the hang actually was. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q5cRrMXoPJ6zSiGfKYcqjC
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Critical async lifecycle issues remain, with additional response-framing and abort-coverage gaps.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 2
Open (2)
What changed in this PR
This pull request replaces SIGKILL-based async detachment with forked workers and adds end-to-end regression coverage.
Changes:
- Adds detached workers, status handshakes, and framed redirects.
- Adds async flow tests and updates pyNEID documentation.
- Fixes fixture configuration, stderr handling, dependencies, and whitespace.
| File | Summary |
|---|---|
tests/test_pyneid_compat.py |
Updates the skipped-test rationale. |
tests/test_async_flow.py |
Adds async submission, execution, polling, and artifact coverage. |
tests/fixtures/TAP.conf.template |
Corrects generated service URLs. |
tests/conftest.py |
Improves fixture configuration and stderr handling. |
TAP/vositables.py |
Removes trailing whitespace. |
TAP/tap.py |
Implements forked async workers and response framing. |
requirements-test.txt |
Adds the lxml test dependency. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Both from Copilot's review, both real. The async block ends in one shared response path, and every phase reaches it. ABORT and unrecognized phases decide their terminal status, hit that path, and then fall through into the query, which overwrites ABORTED (or ERROR) with COMPLETED. So a client that aborted a PENDING job was told ABORTED and got its results anyway. That predates this branch: the old code wrote the status, sent the 303, killed the server and then ran the query in the orphan. The fork made it easier to see, not worse. The dead per-branch response calls still visible in the RUN and ABORT branches show the original intent, hoisting them into a shared tail is what dropped the exit. Terminal phases now write their status, answer the client, and sys.exit(), which is what __writeAsyncError__ and __printError__ already do everywhere else in this file. Only RUN detaches. Second: the parent released the child after sending the response, so a client or proxy that hung up mid-write took the exception path, skipped the release, and the child saw EOF and exited, leaving a published job stuck in EXECUTING forever. The release moves into a finally and is gated on the status write having succeeded: a hung-up client does not make the job go away, but if there is no job document there is nothing for the child to update. Tests cover both terminal phases, including that nothing runs afterwards (phase holds and no result file appears). Both fail against the previous commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q5cRrMXoPJ6zSiGfKYcqjC
|
I ran some tests on https://kvmexoweb.ipac.caltech.edu/ (essentially Centos OPS pre-cutover) and the behavior seems identical to what I'm seeing on RedHat, there are some constraints with the test where you get redirected to RedHat after initiating your query but I wonder how your tests fair with this endpoint? |

What this fixes
Async submits answer the client by killing the web server process that is serving the request.
__printAsyncResponse__sends the 303, sleeps two seconds, then callsos.kill(os.getppid(), signal.SIGKILL). Under CGI that parent is the web server child handling the request. That does end the response (the body carries noContent-Length, so the dying connection is what terminates the message) and it does orphan the CGI so the query survives. There is noforkanywhere intap.py; this kill is the detach mechanism.Bare
mod_cgirespawns the killed child silently, so this was invisible for years. Behind a reverse proxy it is not: the proxy is the one holding the connection that dies mid-response, so it marks the backend in error and answers 5xx for the next request or two routed over it.That matches what we measured against the NEA from outside: a roughly one second window right after
PHASE=RUNin which the job's own resources return 503 while/TAP/availabilitystill returns 200 on another worker, hitting about 2 of 9 attempts. The job itself is always fine, which is why polling through the window recovers.The fix
Fork. The parent records the child as the job's
runId, writes the status document, sends the 303, and exits, which ends the response the way any other CGI does. The child callssetsid()to leave the request's process group, points its standard streams at/dev/null, and runs the query.Three details are load-bearing:
COMPLETEDbefore the parent writesEXECUTING, and the job looks like it went backwards.uws:runIdnow carries the child's pid, because ABORT kills whateverrunIdnames (TAP/tap.py:1125-1136). A stale pid there would either do nothing or signal an unrelated process.setsid()matters on its own:mod_cgiregisters a cleanup that terminates the CGI child when the request pool is destroyed, so a worker that merely closed stdout would still be killed with the request.The 303 is also self-delimiting now (
Content-Type,Content-Length,Connection: close). Annph-script gets noContent-Lengthfrom the server, and a proxy has no other way to know where the message ends.If
fork()fails, the request is answered from this process and the query runs here: the old behavior minus the kill. Correct response, job tied to the request's lifetime.The two second
time.sleep(2.0)is gone with the kill it was covering for, so every async submit returns about two seconds sooner.Tests
tests/test_async_flow.pycovers the three round trips end to end for the first time: submit,PHASE=RUN, poll toCOMPLETED, and a non-empty result artifact on disk. It also asserts the 303 is framed, and guards the regression at the source.That last assertion reads
tap.pyrather than observing behavior, deliberately: the behavioral version is "the process running this suite is still alive", which a suite that has been SIGKILLed cannot make. Before this change, running these tests printedKilledand nothing else. Comments and strings are stripped withtokenizebefore the check so the code can still explain in prose why it no longer does this.The async flow was untestable because of this bug, which is also what
tests/test_pyneid_compat.pyhad recorded without identifying: "pyNEID hung at step 2 with no observable error in the TAP debug log." Step 2 isPHASE=RUN. Its docstring is corrected to say what the hang actually was; the module stays skipped because pyNEID is not a test dependency.Three unrelated things had to be fixed before the fixture could host an async job at all, in a separate first commit:
configparam.pyappendsHTTP_PORTtoHTTP_URLfor any port other than 80/443, and the fixture had the port in both, so the status URL came back ashost:port:0. No sync test readshttpurl, which is why this never surfaced.tap.pyasks BeautifulSoup for thelxmltree builder by name when it reads a status document (TAP/tap.py:904, 2162), sobeautifulsoup4alone is not enough for anything that touches an existing job.lxmlis added torequirements-test.txt.Results, in
python:3.12-slim: 58 passed, 2 skipped (53 passed before this branch),ruff check .clean. Both commits are independently green. The async tests were run five times in a row to check the handshake for flakiness.The first commit also strips one trailing space in
TAP/vositables.pythat came in with the v3.0.1 merge and is currently failingruff check .ondevelop.Follow-ups this deliberately leaves alone
setup.py'sinstall_requireslists only ADQL, spatial_index and configobj. It is missing beautifulsoup4, lxml, xmltodict, astropy and sqlparse, all of whichtap.pyimports at module scope.import cgiatTAP/tap.py:5blocks Python 3.13+, wherecgiwas removed. It is why the CI matrix stops at 3.12.__writeStatusMsg__opens the status file'w+'(truncating) and only then takesfcntl.lockf(LOCK_EX|LOCK_NB), so a concurrent reader can see an empty file. This PR does not add any new status writes, partly for that reason.__printSyncResponse__(TAP/tap.py:2695) is defined but never called, and has the same unframed-303 shape.Note for anyone testing this locally
The suite needs Python 3.12 or older (
import cgi) andgccfor thewriterecsextension.Submitted by Reid (agent)
🤖 Generated with Claude Code
https://claude.ai/code/session_01Q5cRrMXoPJ6zSiGfKYcqjC