Conversation
08f59e6 to
8d5a986
Compare
|
@claude review |
|
Claude finished @andypost's task in 6m 22s —— View job Review
The design is right, and the argument for it is the correct one: Two files were missed by the residue sweep, and one of them fails silently. Both are in the same class as the 1.
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8d5a986817
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
8d5a986 to
7c97bd9
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7c97bd94b2
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
7c97bd9 to
6c775aa
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6c775aae37
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
The suite names its listeners literally everywhere -- 235 `*:PORT` literals in
73 files -- and the capability probes in unit.check.chroot and
unit.check.isolation wildcard-bind *:8080 during session startup, before any
test is selected. One fixed band therefore means one pytest process per
network namespace: a second run (another agent, a second worktree, an xdist
worker) dies at startup with bind("0.0.0.0:8080") failed whichever test it was
asked for. That is why tools/test-isolated serializes on a lock, and why
tools/unit-build fakes a port shift by rewriting a copy of the suite with sed
-- including a separate expression for "8080-8090", because a bare
s/8080/$PORT/ turns that range descending and Unit rightly rejects it.
Translate the port where it is resolved instead. HTTP1.http() and
Control._get_args() are the only two places a port becomes a connect() or
reaches unitd, so unit/port.py maps the historical literals onto a session
band and `--port N` (or UNIT_TEST_PORT) moves the whole suite at once. The
same move #395 made for request headers: fix the construction, not the 235
call sites.
Mapping rules:
* 8080-8085 -> base..base+5, 8090 -> base+10, 8443 -> base+363, and the
7978-7999 helper registry -> base-102, so every offset is identity at the
default base of 8080. An unmodified run is byte-for-byte unchanged, which
is what keeps CI and the container modes safe.
* Offsets, never independent values: a second listener stays one port above
the first, a proxy target that names another listener still names it, a
`destination` rule that must not match still does not, and the deliberately
invalid *:65536 is not in the map at all.
* A base is rejected when its bands would collide with a literal the suite
also names (bases 7615-8545) or when the bands overlap each other, so a bad
--port fails at startup rather than landing a listener somewhere unintended.
* Only a digit run that *is* a known literal is rewritten. A blanket
four-digit substitution corrupts "\\u0000", "%00", "bytes=000-004", "%08d"
and HTTP-date strings, all of which this suite contains; remap() is also
idempotent and preserves bytes bodies.
The rest of this change is the residue that bypasses the two chokepoints:
raw socket connects, the helper-process upstream ports (both sides at once --
the map alone gives 7x502 in test_proxy_head.py), the two probes that PUT
their config directly, and three config-equality assertions that now map the
literal before comparing.
Acceptance is the range case and the coexistence case, not the literal count:
"127.0.0.1:8080-8090" maps to a valid ascending range with no sed, and two
pytest processes now run side by side in one namespace.
Verified on this tree (local unitd, no python/njs module, so language suites
skip):
* Whole root suite, 994 skipped in both cases: 3 failed / 204 passed / 26
errors at the default base and 3 failed / 204 passed / 26 errors at
--port 18080, from the same pre-existing causes (this build has no
zlib/brotli/zstd, and test_app_start_timeout's slow-start cases need the
python module). Sample of the shift working: ss showed :18080 bound and
:8080 never bound.
* test_proxy_head.py 7 passed at both bases, and
test_fake_upstream_proxy_chunked_response.py 6 passed at both, with
test/fake_upstream built and shimmed in because the helper is expected at
/usr/local/bin.
* Two concurrent runs in one namespace, which is the point of the change:
--port 18080 (65 passed, 2 skipped) alongside --port 19090 (3 passed,
12 skipped), no listener left behind.
* Not covered here: test/go and test/node fixtures hardcode 8080 in their own
sources, so those legs stay unsafe off the default base, and the *:8443 TLS
relay legs were only exercised on this build's openssl.
Revision from review, on top of the above:
* test_php_basic.py and test_python_basic.py compare a returned listener config
against a literal ``"*:8080"`` key in eight places; those now map the literal
with port_map.expected(). They pass unnoticed at the default base because
the map is the identity there, so only a shifted run catches them.
* test_client_ip.py's address table had a raw 8081 for its ipv6 entry while its
ipv4 entry went through the map, and test_unix_abstract.py likewise; both map
it now, and both unix entries carry a note that None is deliberate.
* MIN_BASE's comment said 1024; the value is 7876, the lowest base whose helper
block still clears 1024. Confirmed by sweeping every base: 24757 of 24892
accepted, the only rejections in the contiguous [7615, 8545] collision window.
* HTTP1.http() documents that a falsy port means the session default. A typo
that reaches it still surfaces at the socket rather than silently dialling
the base, which is how the unix-socket entry needs it to behave.
The port map in test/unit/port.py is the identity at base 8080, so every existing leg proves only its no-op path. A literal that bypasses the map stays invisible until the base moves, which is how two of them shipped. Re-run the whole `test` tree with UNIT_TEST_PORT=18080 on the one leg that runs it in one piece. The leg takes under four minutes against a thirty minute timeout, so a second pass costs nothing anyone will notice. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
6c775aa to
b3a688d
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b3a688d6aa
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| _NUMBER.sub(replace, text.decode('ascii')).encode('ascii') | ||
| ) | ||
|
|
||
| except UnicodeDecodeError: | ||
| return text |
There was a problem hiding this comment.
Remap ports in valid UTF-8 byte configurations
When a non-default --port is used and client.conf() receives a valid UTF-8 byte body containing both a listener such as *:8080 and any non-ASCII text, this ASCII decode raises UnicodeDecodeError and returns the entire body unchanged. Unit consequently binds the historical port while HTTP1 connects to the mapped base, breaking requests for configurations that the control API otherwise supports; decode valid byte configurations as UTF-8 (or replace ASCII digit runs directly in the bytes) instead of treating every non-ASCII body as unmappable.
Useful? React with 👍 / 👎.
The suite names its listeners literally everywhere — 235
*:PORTliterals in 73 files — and the capability probes inunit/check/chroot.pyandunit/check/isolation.pywildcard-bind*:8080during session startup, before any test is selected. One fixed band therefore means one pytest process per network namespace: a second run (another agent, a second worktree, an xdist worker) dies at startup withbind("0.0.0.0:8080") failed, whichever test it was asked for.That is why the local harness serializes with a lock, and why
tools/unit-buildfakes a port shift by rewriting a copy of the suite with sed — including a separate expression for8080-8090, because a bares/8080/$PORT/turns that range descending and Unit rightly rejects it.This translates the port where it is resolved instead.
HTTP1.http()andControl._get_args()are the only two places a port becomes aconnect()or reaches unitd, sounit/port.pymaps the historical literals onto a session band and--port N(orUNIT_TEST_PORT) moves the whole suite at once. Same move #395 made for request headers: fix the construction, not the 235 call sites.Mapping rules
8080-8085 -> base..base+5,8090 -> base+10,8443 -> base+363, and the7978-7999helper registry-> base-102, so every offset is the identity at the default base of 8080. An unmodified run is unchanged, which is what keeps CI and the container modes safe.destinationrule that must not match still does not, and the deliberately invalid*:65536is not in the map at all.7876..32404— 24394 of 24529 (MAX_BASEcaps the range one below the ephemeral floor,32768, so the highest-offset mapping -- 8443 atbase+363-- never lands inside it). The 135 rejections, where a mapped band would land on a literal the suite also names, are not one window but seven islands:[7968,7999] [8059,8079] [8081,8101] [8161,8192] 8433 [8438,8443] [8524,8545]— all inside[7968, 8545], with 8080 itself accepted because there the map is the identity. A bad--portfails at startup. The lowest output is the helper block atbase-102, so outputs stay unprivileged for any base at or above 1126;MIN_BASE = 7876is a deliberately conservative floor that keeps the band near the range the suite has always used, not a privilege bound.\^@,%00,bytes=000-004,%08dand HTTP-date strings, all of which this suite contains.remap()is idempotent, andport()passes non-integers through —port=Noneis how the unix-socket address tables say "no port".What else changed
The residue that bypasses the two chokepoints: 12 raw socket connects, the helper-process upstream ports (both sides at once — the map alone gives 7×502 in
test_proxy_head.py), the two probes that PUT their config directly, and twelve config comparisons that now map the literal before comparing:test_app_start_timeout.py— two whole-config comparisons against_serving_conf().test_php_basic.pyandtest_python_basic.py— eight listener comparisons against literal"*:8080"/"*:8081"/"*:8082"keys. These run on the php/python legs, so a default-base CI run cannot catch them; they only fail at--port ≠ 8080.test_state_store.py— two comparisons of the persistedconf.jsonagainst theSMALL_CONFliteral.client.conf()remaps the listener before Unit stores it, so off-base the stored*:18080was compared against*:8080: the first comparison spent its full 10 s poll and then the test failed although persistence had worked. Found by Codex review, not by CI, for the reason in the next paragraph.The base must be a module global in
unit/port.py, not read fromoption: a test module withUPSTREAM_PORT = port(7978)at import time is re-imported in the childrun_process()spawns, and Python 3.14's default start method isforkserver, so that child never ranpytest_configure.Off-base CI leg
build-test.ymlre-runs the wholetesttree withUNIT_TEST_PORT=18080on the one leg that runs it in one piece. Every other leg still runs at the default base, so the identity property keeps its coverage. The leg takes under four minutes against a thirty-minute timeout.The leg does not pass
--restart, and it is a known gap rather than an oversight.--restartmakes conftestrmtreethe temp directory on teardown, which breakstest_php_application_forbidden's restricted-permission fixture, so it cannot simply be added to a whole-tree leg. Every test that needs it therefore stays invisible to this leg off-base:test_state_store.pyskips viaunit_stop()(conftest.py:621-623,pytest.skip('no restart mode')), and so dotest_graceful_reload.pyand the other state-mutating modules. That is exactly why thetest_state_store.pybug above reached review. Closing it means a separate--restartleg with its own test selection, which is not in this PR.This exists because the map is the identity at 8080: a literal that bypasses it is invisible to a default-base run, which is how the three residues below shipped. Running the suite off-base is what found the third.
Verified
CI on the previous head: 32/32 pass, including
sanitize (python)and all three python legs. Locally, on a build with php and openssl but no zlib/brotli/zstd and no python module, the wholetesttree gives 7 failed / 287 passed / 899 skipped / 30 errors at the default base and the same failure set at--port 18080— every one a pre-existing environment cause (no compressors built, php rootfs, andtest_app_start_timeout's slow-start cases needing the python module), none of them the port map.--port 18080(65 passed, 2 skipped) alongside--port 19090(3 passed, 12 skipped), nothing left listening.test_proxy_head.py7 passed andtest_fake_upstream_proxy_chunked_response.py6 passed, at both bases, withtest/fake_upstreambuilt locally (the helper is expected at/usr/local/bin, so it was shimmed fromtarget/release).--port 18080before and after:test_idle_close_wait.py's_PORTwas unmapped while its listener moved, so its/proc/net/tcpscan matched nothing and both tests passed vacuously (a scan of 8080 saw 0 connections where a scan of 18080 saw 9);test_tls_tickets.py'sconnect()used a rawcreate_connectionon the unmapped literal and raisedConnectionRefusedErrorat every call site; andtest_php_application.py's trailing-slash case compared a Unit-generatedLocationagainst a literallocalhost:8080. The whole tree now gives the same failure set at the default base and at--port 18080.port=None+sock_type='unix'reaches the socket at both bases, andipv4is unaffected (no port → base,port=8080→ base,port=8081→ base+1).Not in this PR
test/goandtest/nodefixtures hardcode 8080 in their own sources, so those legs stay unsafe off the default base.*:8443TLS relay legs were exercised only against this build's openssl.tools/unit-buildsed hack is harness-side (/home/andy/www/freeunit-harness, not this repo) and is deliberately untouched; once this lands it can call--portinstead.@sockintest_unix_abstract.py,test_asgi_lifespan.pywriting into the checkout, and the Go helper'sGOPATHunderbuild/go. This PR is the necessary first step for xdist or two manual runs, not the whole of it.