Skip to content

test: take the suite off a fixed *:8080 - #406

Open
andypost wants to merge 2 commits into
masterfrom
feat/test-port-map
Open

andypost wants to merge 2 commits into
masterfrom
feat/test-port-map

Conversation

@andypost

@andypost andypost commented Sep 15, 2026

Copy link
Copy Markdown

The suite names its listeners literally everywhere — 235 *:PORT literals in 73 files — and the capability probes in unit/check/chroot.py and unit/check/isolation.py 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 the local harness serializes with 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.

This translates 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. 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 the identity at the default base of 8080. An unmodified run is 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.
  • Accepted bases are 7876..32404 — 24394 of 24529 (MAX_BASE caps the range one below the ephemeral floor, 32768, so the highest-offset mapping -- 8443 at base+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 --port fails at startup. The lowest output is the helper block at base-102, so outputs stay unprivileged for any base at or above 1126; MIN_BASE = 7876 is a deliberately conservative floor that keeps the band near the range the suite has always used, not a privilege bound.
  • Only a digit run that is a known literal is rewritten. A blanket four-digit substitution corrupts \^@, %00, bytes=000-004, %08d and HTTP-date strings, all of which this suite contains. remap() is idempotent, and port() passes non-integers through — port=None is 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.py and test_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 persisted conf.json against the SMALL_CONF literal. client.conf() remaps the listener before Unit stores it, so off-base the stored *:18080 was 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 from option: a test module with UPSTREAM_PORT = port(7978) at import time is re-imported in the child run_process() spawns, and Python 3.14's default start method is forkserver, so that child never ran pytest_configure.

Off-base CI leg

build-test.yml re-runs the whole test tree with UNIT_TEST_PORT=18080 on 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. --restart makes conftest rmtree the temp directory on teardown, which breaks test_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.py skips via unit_stop() (conftest.py:621-623, pytest.skip('no restart mode')), and so do test_graceful_reload.py and the other state-mutating modules. That is exactly why the test_state_store.py bug above reached review. Closing it means a separate --restart leg 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 whole test tree 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, and test_app_start_timeout's slow-start cases needing the python module), none of them the port map.

  • Two concurrent runs in one namespace, which is the point: --port 18080 (65 passed, 2 skipped) alongside --port 19090 (3 passed, 12 skipped), nothing left listening.
  • test_proxy_head.py 7 passed and test_fake_upstream_proxy_chunked_response.py 6 passed, at both bases, with test/fake_upstream built locally (the helper is expected at /usr/local/bin, so it was shimmed from target/release).
  • Off-base residues, each proven by running the test at --port 18080 before and after: test_idle_close_wait.py's _PORT was unmapped while its listener moved, so its /proc/net/tcp scan 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's connect() used a raw create_connection on the unmapped literal and raised ConnectionRefusedError at every call site; and test_php_application.py's trailing-slash case compared a Unit-generated Location against a literal localhost:8080. The whole tree now gives the same failure set at the default base and at --port 18080.
  • The unix path was checked by execution rather than by the badge: port=None + sock_type='unix' reaches the socket at both bases, and ipv4 is unaffected (no port → base, port=8080 → base, port=8081 → base+1).

Not in this PR

  • test/go and test/node fixtures hardcode 8080 in their own sources, so those legs stay unsafe off the default base.
  • *:8443 TLS relay legs were exercised only against this build's openssl.
  • The tools/unit-build sed hack is harness-side (/home/andy/www/freeunit-harness, not this repo) and is deliberately untouched; once this lands it can call --port instead.
  • Three machine-scoped collisions outlive the port change and block concurrent runs for other reasons: the namespace-global abstract socket @sock in test_unix_abstract.py, test_asgi_lifespan.py writing into the checkout, and the Go helper's GOPATH under build/go. This PR is the necessary first step for xdist or two manual runs, not the whole of it.

@andypost
andypost force-pushed the feat/test-port-map branch 2 times, most recently from 08f59e6 to 8d5a986 Compare September 15, 2026 23:29
@andypost
andypost marked this pull request as ready for review September 16, 2026 19:20
@andypost

Copy link
Copy Markdown
Author

@claude review

@claude

claude Bot commented Sep 16, 2026

Copy link
Copy Markdown

Claude finished @andypost's task in 6m 22s —— View job


Review

  • Read repo guidelines (no CLAUDE.md in this repo)
  • Read test/unit/port.py — the new mapping core
  • Audit the two chokepoints (unit/http.py, unit/control.py)
  • Sweep the whole suite for literals that bypass the chokepoints
  • Check the mapping/collision-window claims against the arithmetic
  • Post review

The design is right, and the argument for it is the correct one: HTTP1.http() + Control._get_args() really are the only two resolution points, remap() keying on known literals rather than a digit-width regex is the detail that makes it safe, and the identity-at-8080 property is what makes this landable. The forkserver reasoning for the module global is genuinely non-obvious and correctly reasoned.

Two files were missed by the residue sweep, and one of them fails silently. Both are in the same class as the test_php_basic / test_python_basic comparisons the PR already caught — invisible to a default-base CI run.


1. test_idle_close_wait.py passes vacuously off the default base

test/test_idle_close_wait.py:26

_PORT = 8080

_count_close_wait() (line 57) scans /proc/net/tcp{,6} for fields[1].split(':')[1] == f'{port:04X}' — the local address port. The listener is configured through client.conf({"listeners": {"*:8080": ...}}), so it lands on the base; the scan still looks for hex 1F90.

At --port 18080 nothing is ever bound on 8080, so _count_close_wait returns 0 on the first poll, _wait_for_zero returns 0 immediately, and:

assert remaining == 0, ...

is structurally true. Both tests in the module report green while verifying nothing — a regression in FIN handling would not be caught. This is worse than the hard failure below, because nothing in the output says the test stopped working.

_PORT = port_map.port(8080) fixes it.

Fix this →

2. test_tls_tickets.py connects raw to unmapped literals

test/test_tls_tickets.py:49-50

def connect(ctx=None, session=None, port=8080):
    sock = socket.create_connection(('127.0.0.1', port))

This is a raw create_connection, not HTTP1.http(), so it bypasses the map entirely. The listeners are configured at lines 37-41 (*:8080, *:8081, *:8082) through client.conf and are remapped, so at --port 18080 the server is on 18080/18081/18082 and every call site — lines 87, 91, 97, 104, 114, 121, 127, 133, 136, 140, 152, 159, 160, 162, 165, 169, 180, 181, 183 — connects to the old band and raises ConnectionRefusedError.

set_tickets(port=8080) at line 75 is fine; it builds f'listeners/*:{port}/tls/session', which goes through Control.

Mapping once inside connect covers every caller including the port=8081 / port=8082 ones, since port() is idempotent for accepted bases:

sock = socket.create_connection(('127.0.0.1', port_map.port(port)))

Fix this →

3. The new code path has no CI coverage

This is the root cause of 1 and 2 rather than a separate defect. The change is deliberately the identity at base 8080, and CI never passes --port/UNIT_TEST_PORT, so "32/32 pass on this head" confirms the no-op property and says nothing about the map. Every bug this PR can introduce is invisible to it, which is exactly how the eight test_php_basic/test_python_basic comparisons — and the two above — got as far as they did.

The verification in the PR body was done by hand locally, against a build with no language modules (994 skipped), so the php/python/openssl legs where these two live were never run off-base at all.

Setting UNIT_TEST_PORT on one existing python+openssl leg in build-test.yml would turn all of this into a machine check and cost nothing — the local harness lock is what this PR exists to remove, and a second leg is the thing that keeps it removed.


unit/port.py — docstring contradicts the code

The helper-block offset is _BLOCK_OFFSET = _BLOCK_FIRST - 8080 = -102, i.e. the block maps to base-102 .. base-81. Two places say otherwise:

Line 32 — the mapping table:

7978-7999    base+500 .. base+521

There is no +500 anywhere in the module. Should read base-102 .. base-81.

Lines 61-64 — the MIN_BASE derivation:

# 7876 is the lowest base whose outputs all stay at or above 1024 (the helper
# block sits 1024 below the base, so a lower base would need a privileged port).

The block sits 102 below the base, not 1024. Taken literally, the stated criterion ("lowest base whose outputs stay ≥ 1024") gives MIN_BASE = 1126, not 7876. At the actual MIN_BASE = 7876 the lowest output is 7774 — comfortably unprivileged, but for a different reason than the one written down. 7876 is in fact 7978 - 102, which suggests the bound came from somewhere else entirely.

This matters more than a typo normally would: the whole premise of the PR is that the map is small enough to audit by reading it, and the table is what a reader audits against.

The PR description repeats the same "helper block sits 1024 below the base" claim and should be corrected with it.

Relatedly, the description's "rejections forming one contiguous collision window, [7615, 8545]" doesn't match the stated count. Working the four collision conditions by hand, the rejected bases are several disjoint islands — roughly [7968,7999], [8059,8101], [8161,8192], 8443, [8524,8545] — which sums to ~130, consistent with the "135 rejected" figure but not with a contiguous 931-wide window. (I could not execute Python in this environment to confirm the exact set; the -102 offset above is plain arithmetic and is certain regardless.) Worth restating as "all rejections fall within [7615, 8545]", since the error message tells the user to "pick another base" and a reader may take the window as the range to avoid.

unit/port.py — smaller things

Line 194, remap.replace:

if len(run) not in (4, 5) or int(run) not in _LITERALS:

Every literal is four digits, and five-digit mapped outputs already short-circuit on the _MAPPED_OUTPUTS check two lines above, so the 5 is not load-bearing. What it does admit is a zero-padded run: "08080" is length 5, int() is 8080, and it gets rewritten to "18080" — narrowly re-opening the exact corruption class the docstring spends a paragraph warning about. len(run) != 4 is tighter and matches the intent.

Lines 125-127, set_base:

literals = {
    literal for kind, literal, _ in entries if kind == 'listener'
} | set(range(_BLOCK_FIRST, _BLOCK_LAST + 1))

This reconstructs exactly _LITERALS (frozenset(_BAND) | frozenset(range(_BLOCK_FIRST, _BLOCK_LAST + 1))). Using _LITERALS directly removes the only consumer of the kind field in _mapped_entries(), which could then return plain (literal, value) pairs.

Line 144: option.base_port = base is written and never read anywhere in the suite. The comment describes a consumer ("tests that assert on the port the server was configured with") that does not exist. Either drop it or point at the test that needs it.

Nits

  • test/test_proxy.py:15 — the two blank lines before @pytest.fixture were deleted along with the old SERVER_PORT assignment, so the module-level constant now abuts the decorator (E302).
  • from unit import port as port_map is placed last in the import block in every file, after from unit.applications... / from unit.utils.... isort would sort it first within the unit group. Consistent, so low-value, but it's 26 files of it.

What I verified

  • Swept every socket.connect / create_connection / get_server_certificate / ssl call in test/ — apart from the two above, all are mapped or reach a unix socket.
  • Swept every port= keyword with a literal: all others reach HTTP1.http() or build a listeners/*:N config path, both of which are covered.
  • Confirmed unit/applications/lang/*.py and tls.py "*:8080" all route through Control.conf, so they need no change.
  • Confirmed args_handler JSON-serializes dict/list bodies before _get_args, so remap() only ever sees str/bytes — the bytes branch is needed and the type preservation is correct.
  • Confirmed test_otel.py is safe: it uses _get_free_port() and its "*:8080" goes through client.conf.
  • Confirmed port() double-application is safe (port(port(x)) == port(x)) for any base set_base accepts — several call sites rely on this, including the suggested fix for test_tls_tickets.py.
  • Confirmed port() on the base itself is the identity for all accepted bases (bases inside _BAND or 7978-7999 are all rejected by the collision check), which is what makes port(kwargs.get('port') or base()) in http.py:50 correct.

I did not run the suite — no language modules are built in this environment, and Python execution was not available to me here.
· branch feat/test-port-map

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread test/unit/http.py
Comment thread test/unit/port.py Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread test/unit/port.py Outdated
Comment thread test/unit/control.py

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread test/unit/control.py
andypost and others added 2 commits September 18, 2026 16:55
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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread test/unit/port.py
Comment on lines +210 to +214
_NUMBER.sub(replace, text.decode('ascii')).encode('ascii')
)

except UnicodeDecodeError:
return text

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant