The upload feature could never work: four bugs only a browser could see - #293
Merged
Merged
Conversation
Spec 012's upload flow was deployed to beta on 2026-09-22 and nobody had run it. Driving the deployed image with a headless browser -- a private copy of the same image, Turnstile off, against the real ReactomeGSA -- found four bugs, every one past 67 tests, CI, a 15/15 answer sweep and the routing probe. **1. Submission always failed.** Both POST endpoints answer `text/plain`: a bare, unquoted identifier (the swagger says `produces: text/plain`, example `Analysis00371643`). `submit` and `load_public_dataset` called `response.json()` on it. The comment above `_identifier_from` stated the opposite -- "the bare ID as a quoted JSON string" -- which was never measured. Every test stubbed `submit()` at the method level and returned a Python string, so the one line that was wrong was the one line no test ran. Even the test of the parser handed it a dict, because it was written against the same belief. **2. The failure message was untrue.** It said "I could not reach the analysis service. Nothing was run." The service had been reached and had accepted the job with a 200; the failure was in reading the reply, so the analysis was running, orphaned. The generic branch now says only that something went wrong starting it. **3. The whole chat UI died at the moment of success.** For an element given by `path`, Chainlit infers the MIME type with `filetype.guess()`, which reads magic bytes. A TSV has none, so it came out null, and Chainlit only falls back to the extension for URLs. The browser then called `mime.startsWith()` on null and replaced the chat with "Cannot read properties of null (reading 'startsWith')". The server logged the result delivered 11 ms before the page died. `result_file_kwargs` now sets the type explicitly. **4. Progress updated where nobody could see it.** The progress message was sent first, so it was the first message in the thread, and Chainlit scrolls the latest user message to the top. It updated faithfully for minutes above the fold while the user saw "Started." and nothing else. It is now created on the first update, so it lands below "Started.". **Found in review, from real data:** Reactome pathway names contain markdown syntax -- `NOTCH1:M1580_K2555`, `H139Hfs13* PPM1K ...`. Unescaped, paired `_` or `*` become emphasis and a variant identifier loses characters silently, and a `|` shifts Direction and FDR a column right. Names are escaped. Also: `GsaClient` takes an injectable transport, because without one the only way to test it was to stub its methods, which is how #1 shipped. The new `test_gsa_http.py` goes through `httpx.MockTransport` with the service's real response shapes. `chainlit_flow` logs either side of delivering the result, because the first real run went silent after "result written" and websocket frames are not logged: there was no way to tell "sent and not shown" from "never sent". **Found against the real service:** progress read "60% · Permutation 1000 / 1000". ReactomeGSA holds `completed` at 0.6 for the whole permutation phase while its description counts through it, so the percentage is dropped and the service's own description shown. Verified in a browser, first against a fake service replaying the real recorded responses (so an iteration took 11 seconds, not 10 minutes, and showed the delay was never the cause), then against gsa.reactome.org: file attaches, 16 samples read back, labels accepted, analysis starts, progress shows below "Started." and updates in place (one message, six stages against the real service), the result shows 275 of 2,679 pathways significant with a table and the Pathway Browser link, and the table downloads -- HTTP 200, 350 KB, all 2,679 rows and nine columns. Sabotage: `response.json()` back in `submit` fails 7 tests (0 before this change); dropping the MIME type fails its test; dropping the escaping fails 5. Known and left: the user's own uploaded file is deleted once submitted (FR-007, for disk), so the attachment chip in their message becomes a dead link. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Spec 012's upload flow went to beta on 2026-09-22 (#292) and nobody had run it. Driving it with a headless browser — a private copy of the same image, Turnstile off, against the real ReactomeGSA — found four bugs. Every one had passed 67 tests, CI, a 15/15 answer sweep and the routing probe.
The four
1. Submission always failed. Both POST endpoints answer
text/plain— a bare, unquoted identifier (swagger:produces: text/plain, exampleAnalysis00371643).submitandload_public_datasetcalledresponse.json()on it. The docstring above the parser stated the opposite — "the bare ID as a quoted JSON string" — and had never been measured.Every test stubbed
submit()at the method level and returned a Python string, so the one line that was wrong was the one line no test ran. Even the parser's own test handed it a dict, because it was written from the same belief.2. The failure message was untrue. "I could not reach the analysis service. Nothing was run." The service had been reached and had accepted the job with a 200 — the failure was in reading the reply, so the analysis was running, orphaned.
3. The whole chat UI died at the moment of success. For an element given by
path, Chainlit infers the MIME type withfiletype.guess(), which reads magic bytes. A TSV has none, so it came outnull— Chainlit only falls back to the extension for URLs. The browser then calledmime.startsWith()on null and replaced the entire chat with "Cannot read properties of null (reading 'startsWith')". The server logged the result delivered 11 ms before the page died.4. Progress updated where nobody could see it. The progress message was sent first, so it was the first message in the thread, and Chainlit scrolls the latest user message to the top. It updated faithfully, above the fold, while the user saw "Started." and nothing else for minutes.
Found while fixing those
NOTCH1:M1580_K2555,H139Hfs13* PPM1K …. Paired_or*become emphasis and a variant identifier silently loses characters; a|shifts Direction and FDR a column right. Escaped.completedat 0.6 for the whole permutation phase while its description counts through it. The percentage is dropped; the service's own description is shown.What makes it testable now
GsaClienttakes an injectabletransport. Without it the only way to test the client was to stub its methods — which is how Got the embeding generation running! #1 shipped.tests/gsa/test_gsa_http.pygoes throughhttpx.MockTransportwith the service's real response shapes, content type included.chainlit_flowlogs either side of delivering the result. The first real run went silent after "result written", and websocket frames are not logged, so "sent and not shown" and "never sent" looked identical.Verified in a browser
First against a fake service replaying the real recorded responses — an iteration took 11 s instead of 10 min, and showed the delay was never the cause — then against
gsa.reactome.org:.tsvtext/tab-separated-valuesSabotage:
response.json()back insubmitfails 7 tests (0 before this change); dropping the MIME type fails its test; dropping the escaping fails 5.Known, and your call
The user's own uploaded file is deleted as soon as it is submitted (FR-007, for disk). Chainlit still links it from the attachment chip in their message, so clicking it gives a 500 — and the unhandled
FileNotFoundErrorin Chainlit's file route also drops that connection. Harmless unless they click their own upload.The alternative is to leave deletion to Chainlit, which removes a session's files when the session expires (
session_timeout = 3600after disconnect). That costs up to ~20 MB per session for up to an hour, on a host that runs at ~5 GB free. I have left the spec's choice in place.🤖 Generated with Claude Code