8000 feat(minimald): bind declared ports before the name and refuse unpublished ports instantly by minimal-foundry[bot] · Pull Request #1798 · gominimal/minimal · GitHub
[go: up one dir, main page]

Skip to content

feat(minimald): bind declared ports before the name and refuse unpublished ports instantly - #1798

Merged
norrietaylor merged 33 commits into
mainfrom
agent/1657-bind-declared-ports-before-the-name-and-refuse-u
Oct 3, 2026
Merged

norrietaylor merged 33 commits into
mainfrom
agent/1657-bind-declared-ports-before-the-name-and-refuse-u

Conversation

@minimal-foundry
@minimal-foundry minimal-foundry Bot commented Oct 2, 2026 •
Copy link
Copy Markdown
Contributor

T21 — Bind declared ports before the name and refuse unpublished ports instantly

slice: S5
kind: task
covers: NET-014, NET-121
size: L
est: 350 code, 240 tests
executor: agent
difficulty: needs care
model: strongest
diagnostics: the bundle's daemon log tail names each forwarder bind, unbind, failed bind and revocation with the port and the reason
observability: one info log lin 8000 e per forwarder bind and unbind, and one warn line per failed bind, with the port and the reason
layers:
- daemon:
- crates/minimald/src/session.rs: registers the name only after the declared forwarders bind; a failed bind registers neither the name nor a substitute
- crates/minimald/src/net/gvproxy_network.rs: makes bind-then-register explicit
- crates/minimald/src/net/policy.rs: revocation unbinds the forwarder and ends its connections, through a daemon-owned forwarder where the switch's unexpose leaves them open
- crates/minimald/src/net/switch.rs: the ingress gate answers a SYN to an unpublished port with a reset instead of dropping it
- crates/minimald/src/session_host.rs: holds the forwarders until stop
- crates/minimald/src/net/dns.rs: never registers a substitute address
test: cargo nextest run -p minimald declared_ports_bound_before_name_registered
test: cargo nextest run -p minimald ingress_revocation_unbinds_forwarder_and_terminates_connections
test: cargo nextest run -p minimald failed_forwarder_bind_is_reported_not_substituted
test: cargo nextest run -p minimald unpublished_port_connection_refused
produces: none
consumes:
- crates/sessions/src/core/loopback.rs
proof: A declared port is reachable by name the moment the name exists, a revoked port's connections end at once, a failed bind is reported and never replaced by another address, and an unpublished port refuses instead of timing out.
depends on:
- blocked by #1645

Proof

A declared port is reachable by name the moment the name exists, a revoked port's connections end at once, a failed bind is reported and never replaced by another address, and an unpublished port refuses instead of timing out.

Task tests (all cargo nextest run -p minimald <name>, Linux):

  • declared_ports_bound_before_name_registered
  • ingress_revocation_unbinds_forwarder_and_terminates_connections
  • failed_forwarder_bind_is_reported_not_substituted
  • unpublished_port_connection_refused

Publish address under the host's range verdict

The host's publish surface carries a range verdict (NET-123): Pending while the daemon-start walk runs (VM host), then Present or Absent when it lands.

One daemon-wide deadline. The book opens one verdict deadline (HAND_VERDICT_WAIT from book open). Only finalize waits on it. Every waiter races the same instant, so N finalizes under a slow walk cost one wait, not N. Session resume, rename and the destroy-hooks bring-up never wait: they publish the interim and let the landing move them.

At finalize (session.rs):

  • A box that was handed an address publishes at the hand when vouches_for holds (outside the reserved range, or under a Present verdict). Otherwise it publishes the 127.0.0.1 interim. A handed box never takes a grant from the answerer's pool.
  • A box with no hand asks (LoopbackGrant::publishable_address): Granted gives its own address; RangeAbsent and RangePending give the interim; faults (PoolSpent, RecordUnavailable) give no address, and the attach fails with "no published address handed".
  • Under Pending, every ask answers RangePending, including a namespace with a recorded address. A resumed box therefore stands on the interim until the verdict lands, never on a range address the host may not hold.

When the verdict lands (sessions.rs land_range_verdict), the verdict is set before either arm runs:

  • Absent moves every own-address publish inside the reserved range, name and route included, onto 127.0.0.1 (one warn per box). That covers a hand a resumed box published (NET-013), which otherwise failed its attach with EADDRNOTAVAIL until destroy.
  • Present re-asks for every publish standing at the interim. A box with a hand is promoted to its hand under the registry's write lock; a box without one re-publishes at its grant. The landing runs on its own task, not through the Manager's mailbox, so it re-checks under the write lock that the session still holds its name and stands at the interim: a box destroyed in between is not re-published, and a grant taken for it is released exactly once.

On Linux native the book opens on the synchronous probe's verdict, Present, so neither fallback fires there.

Root fix, out of scope here: minvmd should measure the host loopback before it hands a reserved-range slice address (#1818). Until then, the verdict gate and the landing sweep hold the interim.

Verdict tests (all cargo nextest run -p minimald <name>, Linux):

  • every_hand_waiter_races_the_one_daemon_deadline (net/dns.rs)
  • a_pending_verdict_withholds_every_grant_and_keeps_the_recorded_line (net/dns.rs)
  • a_landed_absent_verdict_withdraws_the_vouching_a_handed_range_address_needs (net/dns.rs)
  • a_surface_withheld_grant_publishes_the_interim_and_a_fault_publishes_nothing (net/dns.rs)
  • handed_finalizes_share_one_verdict_deadline
  • a_handed_box_pending_at_finalize_moves_to_the_interim_when_the_verdict_lands_absent
  • an_interim_publish_takes_its_grant_when_the_verdict_lands_present
  • a_resumed_handed_box_publishes_the_interim_without_waiting_and_takes_its_hand_on_present
  • a_resumed_box_publishes_the_interim_until_present_restores_its_recorded_address
  • a_destroy_under_a_pending_verdict_does_not_wait_and_publishes_the_interim
  • a_box_re_registered_inside_the_landing_window_keeps_its_grant
  • a_box_destroyed_between_enumeration_and_grant_is_released_once
  • a_destroy_queued_behind_a_waiting_finalize_runs_after_the_promotion
  • the_write_lock_promotion_moves_an_interim_to_a_vouched_hand_only

The call site is pinned too: register_hostname_promotes_an_interim_to_its_vouched_hand drives register_hostname through the write-lock window, with no timer deciding, and fails when the promote_interim_to_hand call is removed. the_write_lock_promotion_moves_an_interim_to_a_vouched_hand_only pins the decision itself.

Files outside the task's layers

  • crates/minimald/src/net/dns.rs, crates/minimald/src/sessions.rs: the range verdict, the shared deadline and the landing sweep the publish path depends on.
  • crates/minimald/src/net/dns_gate.rs: the resolver side of the published name.
  • crates/minimald/src/session/tests.rs, crates/minimald/src/session_host/tests.rs: tests for the above; both also changed on main and were merged.
  • crates/minimald/tests/netns_root_integration.rs: root-lane proof of the bind-before-name order.

Closes #1657

#1765 added SandboxLauncher.box_addresses and SessionConfig.box_addresses
but updated only one of the three initializers, so the package's test
targets no longer compiled. Fill the two it missed (the own-ip session
request fixture and the sandbox launcher fixture) with None, the value
every other initializer carries.
The switch relay's ingress leg dropped every refused TCP frame
silently, so a connection to a port nothing published hung until the
peer's timeout; and a revoked port (NET-121) had no way to end the
connections its forwarder held.

- a SYN to a port the box's ingress declaration does not name is now
  answered with a reset built from the SYN itself (tuple swapped, the
  box's address as the source, RST|ACK, ack riding the SYN's sequence
  plus its flag weight, honest IPv4 and TCP checksums), so the peer's
  connect fails at once with connection refused while the box's kernel
  never sees the connection (NET-014). Unsolicited UDP stays a drop: a
  datagram is not a connection.
- the gate now tracks the tail of every admitted inbound TCP flow and
  revokes a port on demand: it admits the port no longer, answers every
  packet to it with a reset, and terminates each held connection with a
  reset built from the flow's own last packet, which an established
  peer reads in-window (NET-121). Revocation is wired to the ingress
  forwarders in the next commit.
- the reset legs write through the same framed switch write half as the
  egress leg, under a shared mutex, and unbind revocation says
  'revoked ingress port' where a mapping refusal says 'no ingress
  mapping' — both rate-limited with the peer and port, so the daemon
  log's tail names each refusal.
- compile fallout of the gate's new Arc signature: the DNS-gate tests'
  L4Packet literals gain the sequence pair, the netns harness wraps its
  gate as the attach now takes it.

unpublished_port_connection_refused drives the refusal end to end on
the relay harness: the reset's bytes, the silent box end, the still-
forwarded declared port, and the log line.
…0s ran out

the coder's uncommitted work as it stood when its 7200s ran out

The coder was stopped at the end of its time with these changes
not committed. The runtime committed its working tree as it stood so
the work is kept: this commit is a snapshot, not a finished step, and
may not build or pass its tests.
@coderabbitai
coderabbitai Bot commented Oct 2, 2026 •
Copy link
Copy Markdown
Contributor

Important

Review skipped

Bot user detected.

To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: f0a86342-f0c0-44f5-8c6a-823eb006befb

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

Three of the four NET-121 proofs had fixture bugs that kept them from
passing: the registry route is looked up as the full zone name the
registry answers (web.min.internal), not the bare two-label box name;
the fixture switch now carries the same instance host id the proofs'
registry is built with (aaaa1), the pairing the daemon itself makes, so
the attach publishes both host ids' dns/add requests the revocation
proof counts; and the bound-forwarder proof drives the guard's explicit
teardown instead of dropping it, which only aborts the relay and leaves
the forwards standing.
The per-forwarder bind line's wording is a contract beside its fields:
scripts/session-e2e.sh's published_loopback_host reads the publish
record — the host address the forwarder actually bound — out of the
daemon log by exactly the phrase 'exposed ingress port on the host
loopback', three call sites. Renaming the line to 'bound declared ingress
port' left that reader matching nothing, and the native-daemon e2e lane
failed in the fresh-install loopback-publish proof at the record read.
Restore the wording and note the contract at the line.
@minimal-foundry
Copy link
Copy Markdown
Contributor Author

Round 1 of 10 on a failed check suite, pushed as 23b1c0a6be5e.

line exit tail
cargo nextest run -p minimald declared_ports_bound_before_name_registered 0 Applying loadouts: default Running task runtime-implement-test-0 in session task-runtime-implement-test-0-b4e4... Compiling ot v0.0.1 (/home/runner/.local/state/minimal/sessions/12cba/tree/crates/ot) Compiling args v0.0.1 (/home/runner/.local/state/minimal/sessions/12cba/tree/crates/args) C
cargo nextest run -p minimald ingress_revocation_unbinds_forwarder_and_terminates_connections 0 Applying loadouts: default Running task runtime-implement-test-1 in session task-runtime-implement-test-1-6a75... Compiling stdlib v0.0.21 (/home/runner/.local/state/minimal/sessions/b8fe5/tree/crates/stdlib) Compiling version v0.7.0 (/home/runner/.local/state/minimal/sessions/b8fe5/tree/crate
cargo nextest run -p minimald failed_forwarder_bind_is_reported_not_substituted 0 Applying loadouts: default Running task runtime-implement-test-2 in session task-runtime-implement-test-2-3468... Compiling stdlib v0.0.21 (/home/runner/.local/state/minimal/sessions/9ca08/tree/crates/stdlib) Compiling version v0.7.0 (/home/runner/.local/state/minimal/sessions/9ca08/tree/crate
cargo nextest run -p minimald unpublished_port_connection_refused 0 Applying loadouts: default Running task runtime-implement-test-3 in session task-runtime-implement-test-3-1ce7... Compiling stdlib v0.0.21 (/home/runner/.local/state/minimal/sessions/41c39/tree/crates/stdlib) Compiling version v0.7.0 (/home/runner/.local/state/minimal/sessions/41c39/tree/crate

Verify min task run agent-verify passed.

Outside the task's layers::

  • crates/minimald/src/net/dns_gate.rs
  • crates/minimald/tests/netns_root_integration.rs

Converted to a draft: something above needs a person before review.

A declared port is reachable by name the moment the name exists, a revoked port's connections end at once, a failed bind is reported and never replaced by another address, and an unpublished port refuses instead of timing out — all four proof tests now run and pass, and the session e2e lane's log record reader matches the daemon's per-forwarder bind line again.

Notes from the agent: Revise round 1: the branch head's snapshot commit (4af8354, the previous coder's unverified work) broke the checks in three places, all fixed here. (1) The NET-121 proof declared_ports_bound_before_name_registered looked its registry route up as the bare two-label name 'web', but the registry answers full zone names — now 'web.min.internal' (same fix in failed_forwarder_bind_is_reported_not_substituted, whose Absent assertion was trivially true before and is about the real route now). (2) The same proof counted two /services/dns/add publishes per attach, but its switch carried the default host id 'local' (one publish) while the registry fixture was built with instance id 'aaaa1'; production always pairs them (sessions.rs builds the registry from switch.host_id()), so vm_host_switch() now carries 'aaaa1' and the two-publish count is real. (3) The bound-forwarder proof dropped the guard instead of driving its explicit teardown, so the unbind posts never happened — it now calls NetGuard::teardown, the way the sandbox layer does. (4) The native-daemon-e2e failure: the snapshot renamed the per-forwarder bind log line to 'bound declared ingress port on the host loopback', but scripts/session-e2e.sh reads that record out of the daemon log by the exact phrase 'exposed ingress port on the host loopback' (published_loopback_host, three call sites — the fresh-install loopback-publish and network-posture proofs fail at the record read without it); the wording is restored, with the contract noted at the line. I had no gh access to the CI logs, so the e2e cause was derived from the scrip…[truncated]

@norrietaylor
norrietaylor commented Oct 2, 2026 •
Copy link
Copy Markdown
Member

/revise

Round 2 carries the design review's conditions (deployment-and-egress-gateway.md §7.1 line 248, §4.1 line 56, §5.7 determinism rule, architecture AT10).

  1. A gate refusal is a failed bind. "Bind succeeded" means the host-side expose was admitted and bound. A refusal of the expose (an unknown address or an undeclared port) registers neither the name nor a substitute and surfaces the reason per port. Add that case to failed_forwarder_bind_is_reported_not_substituted, which today covers only a bind that fails.
  2. All declared ports, or no name. finish_own_ip_attach already withholds the name when any declared port fails. apply_ingress must unexpose every forwarder it had already exposed for the box before returning the error: a bound forwarder with no name is the standing-capability grant line 248 forbids. Test it with a two-port box whose second port is occupied: zero forwarders remain, zero names.
  3. The reset channel is bounded. The reset writer's channel is fed by box-originated SYNs, so an unbounded channel lets one box fill the daemon's memory. Use a bounded channel, drop the excess (the flooder degrades to a timeout, nobody else does), rate-limit per source, and log one audited line per source per window.
  4. RST correctness. For a SYN, send RST+ACK with seq 0 and ack = SYN.seq + 1 (RFC 793 §3.4). For a held connection being revoked, send the RST with a sequence inside the window the gate tracked. Never build a reset for a segment of a flow the gate does not hold, and always address the reset to the frame's source. Test the two shapes.
  5. TCP only. State in the module doc and the PR body that the refusal covers TCP binds (line 248, v1); UDP's analogue is not asked for here.
  6. Revocation is proved from the host-side client's view. ingress_revocation_unbinds_forwarder_and_terminates_connections asserts that a client holding a connection through the forwarder sees reset or EOF within 60 s of the revocation, and that a new connect after revocation is refused, not accepted and then dropped. Order stays: revoke on the gate, then unexpose, then the forwarder's host sockets closed or the gvproxy behaviour pinned.
  7. local is the handed address, and there is no daemon default. On a VM-backed host the expose request's local must be exactly the box's handed host loopback address and port, never a daemon-chosen value; assert it. The fallback to 127.0.0.1 when no reporter exists goes: a daemon with no handed address binds nothing and registers nothing, surfacing "no published address handed" on the failed-bind path. A reporter that hands 127.0.0.1 (the macOS pre-alias interim, §7.1 line 258) binds there and reports collisions as that interim says. Test both: reporter absent, and reporter handing 127.0.0.1. The daemon's registration is the in-guest zone only; host-OS name publication belongs to the host-side creator. Say both in the PR body.

Follow-ups this round records in the body, not in code: the host-side gate answers SYN-to-unadmitted-port with RST on the host leg as well; the host-side half of a revoked connection is closed by the host-side component (a gvproxy behaviour pinned by the lock test, or a host-owned forwarder); the gate verifies the request's local against the row.

Tests to run: the four plan lines, plus just test for minimald and just clippy.

The ingress gate's resets now ride a bounded channel — a send past the
bound is dropped, never queued — and are drawn against a per-source
windowed budget, so a box flooding SYNs at another's ports degrades to
a timeout alone while every other peer is still refused at once; the
first refusal a window refuses is one audited warn line per source per
window.

The two reset shapes follow RFC 793 §3.4, and both are built only from
state the gate holds: a bare SYN to an unpublished port (NET-014) is
answered RST|ACK from sequence zero acknowledging the SYN, and a
connection a revocation ended (NET-121) is answered from the tail the
gate tracked, so the self-healing second reset stays in the window the
gate watched. A segment of a flow the gate holds nothing for — a
spoofed one — is answered with no reset at all, so this box can never
be made to emit a reset carrying a spoofed sequence to a victim; every
reset is addressed to the frame's source.

The refusal stays TCP-only by design (design §7.1): a UDP datagram is
not a connection and keeps its silent drop.
A VM-backed box's registration carries the addresses the VM host daemon
handed it (T66), and the host loopback half is the address the
host-side table's row owns — not a grant this daemon's in-guest book
could hand out. The finalize publish now prefers the handed address
over the lease, so the attach path's expose requests name exactly it
as their local: never the node's shared 127.0.0.1 interim, never an
address the daemon chose. A handed box never draws from the loopback
pool: the row owns the address, so neither the grant's info line nor
the release at destroy runs for it.
The gvproxy stand-in now answers with reason bodies, so the failed-bind
proof covers both shapes a bind can fail in: the switch's gate
refusing an expose (403 with its reason) is a failed bind exactly like
a bind that errors — the attach fails, the refusal's reason reaches
the caller beside its port, and every bind that landed is rolled back,
so a half-bound box holds nothing.

The revocation proof is now end to end against a forwarding stand-in
that binds real host listeners: a real client socket connects to the
port, the stand-in opens the client's leg through the relay, and the
revocation ends the client's connection — EOF or reset within the
bound, never a hang — while the next connect is refused, not
accepted-then-dropped, because the unexpose dropped the listener
before the revocation returned. The reset that ended the connection
is the gate's, read off the relay and addressed to the leg.

A new proof pins the attach's local as the handed host loopback
address: the binds and the teardown both name the row's address and
the declared ports, never the node's shared interim.
@norrietaylor
norrietaylor marked this pull request as ready for review October 2, 2026 07:22
@norrietaylor
norrietaylor requested a review from a team as a code owner October 2, 2026 07:22
@minimal-foundry
minimal-foundry Bot commented Oct 2, 2026 •
Copy link
Copy Markdown
Contributor Author

Approved: no findings at 61e17adfa837.

Nothing to do.

Verdict APPROVE, posted as a comment: the App cannot review its own pull request; the check and the label carry it.

Clobber: none: git log on session.rs, dns.rs, switch.rs shows the on-branch fix commits (bind-before-name, protocol-in-own-byte, etc.) as this branch's own work, not a revert of anything on main.

@minimal-foundry
Copy link
Copy Markdown
Contributor Author

Round 2 of 10 on a /revise note, pushed as aee331a881ae.

The coder ran out of its 7200s before it finished. If it left work uncommitted, its last commit is the runtime's snapshot of that work as it stood — the commit message says so — and not a finished step: it may not build or pass its tests.

line exit tail
cargo nextest run -p minimald declared_ports_bound_before_name_registered 0 Applying loadouts: default Running task runtime-implement-test-0 in session task-runtime-implement-test-0-4060... Compiling args v0.0.1 (/home/runner/.local/state/minimal/sessions/5e455/tree/crates/args) Compiling ot v0.0.1 (/home/runner/.local/state/minimal/sessions/5e455/tree/crates/ot) C
cargo nextest run -p minimald ingress_revocation_unbinds_forwarder_and_terminates_connections 0 Applying loadouts: default Running task runtime-implement-test-1 in session task-runtime-implement-test-1-bdb0... Compiling stdlib v0.0.21 (/home/runner/.local/state/minimal/sessions/f9a41/tree/crates/stdlib) Compiling version v0.7.0 (/home/runner/.local/state/minimal/sessions/f9a41/tree/crate
cargo nextest run -p minimald failed_forwarder_bind_is_reported_not_substituted 0 Applying loadouts: default Running task runtime-implement-test-2 in session task-runtime-implement-test-2-4145... Compiling stdlib v0.0.21 (/home/runner/.local/state/minimal/sessions/296ec/tree/crates/stdlib) Compiling version v0.7.0 (/home/runner/.local/state/minimal/sessions/296ec/tree/crate
cargo nextest run -p minimald unpublished_port_connection_refused 0 Applying loadouts: default Running task runtime-implement-test-3 in session task-runtime-implement-test-3-09ee... Compiling stdlib v0.0.21 (/home/runner/.local/state/minimal/sessions/769b7/tree/crates/stdlib) Compiling version v0.7.0 (/home/runner/.local/state/minimal/sessions/769b7/tree/crate

Verify min task run agent-verify passed.

Outside the task's layers::

  • crates/minimald/src/net/dns_gate.rs
  • crates/minimald/tests/netns_root_integration.rs

Converted to a draft: something above needs a person before review.

A declared port is reachable by name the moment the name exists; a revoked port's connections end at once — proved now from the host-side client's own socket, which sees EOF or a reset within the bound while nothing further reaches the box, and whose next connect is refused rather than accepted-then-dropped; a failed bind — whether the switch's gate refused the expose or the bind errored — is reported with its port and the reason the switch gave, registers neither the name nor a substitute, and rolls back every bind that landed so a half-bound box holds nothing; and an unpublished port answers a SYN with a reset built only from state the gate holds — RFC 793 §3.4's two shapes, budgeted per source over a bounded channel, with the expose's local naming exactly the handed host loopback address — so it refuses instantly instead of timing out.

Notes from the agent: Directive items, each addressed:

(1) Gate refusal = failed bind: covered. The production path already folds a 403 into post_json's error and apply_ingress already treats any non-2xx expose as a failed bind; the proof now covers it: failed_forwarder_bind_is_reported_not_substituted drives two sequential scenarios — a 403 with a reason body for the third of three declared ports, and the existing 500 bind failure — asserting per shape that the attach error carries the port and the switch's reason, the warn line says port + reason, neither the name nor a substitute is registered (ZoneEntry::Absent), and every bind that landed is rolled back.

(2) Full rollback asserted in the same test: the 403-on-the-third-port scenario asserts unexposes for both ports that bound (8080 and 9090) — a half-bound box holds nothing — not just the one-port rollback the bind-failure shape showed.

(3) Bounded channel + per-source budget: RESET_CHANNEL_CAPACITY 256 with try_send (excess dropped, never queued); ResetBudget admits RESET_PER_WINDOW 16 per RESET_WINDOW 1s per source, one audited tracing::warn per source per …[truncated]

@minimal-foundry
minimal-foundry Bot marked this pull request as draft October 2, 2026 07:26
@minimal-foundry minimal-foundry Bot left a comment
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

minimal-review — advisory review

Verdict COMMENT — 3 findings, 3 inline.

Comment thread crates/minimald/src/net/switch.rs Resolved
@norrietaylor
Copy link
Copy Markdown
Member

/revise

Round 3 is bounded to the two items round 2 left open, plus the body. Everything else in round 2 is accepted as landed.

  1. Item 7, in full. No 127.0.0.1 and no in-guest pool default anywhere on the published-address path: drop map_or(Ipv4Addr::LOCALHOST, …) at gvproxy_network.rs:257; published_or_node (dns.rs:1046) answers only a handed address; session.rs:843 never leases from the pool. When no published address was handed, the attach fails with a reason that names it ("no published address handed for box B"), after the same rollback as a failed bind. Proofs: a reporter-absent test (attach fails, zero forwarders, zero names) and a reporter-hands-127.0.0.1 test (the handed address is used verbatim, nothing synthesised). Re-point declared_ports_bound…, failed_forwarder_bind…, and ingress_revocation… at a handed address so none of them rests on a fallback.
  2. Item 4, one line. In refuse_tcp_segment (switch.rs:1234) return before the budget charge when the incoming segment carries RST. One test: an incoming RST|ACK on a flow in terminated_flows gets no reply and charges no budget.
  3. PR body. Replace the round-0 runtime text. State items 5 and 7 as they now stand, list the three follow-ups from the round-2 note (listener-drop-before-answer and host-socket-close-on-RST as the stand-in's assumed gvproxy semantics; the revoke/record race; the UDP revoke touching the TCP set), and name net/dns_gate.rs and tests/netns_root_integration.rs as forced signature fallout outside the layer list.

Run every test line in the plan block and paste the results. Commit subject and body lines stay at or under 100 columns (commitlint body-max-line-length).

@norrietaylor norrietaylor left a comment
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Round 2 review at aee331a8 against the round-2 note.

  1. Items 1, 2, 3, 6 land with proofs: 403-with-reason fails the attach and rolls back to zero forwarders and zero names (gvproxy_network.rs:1317); bounded try_send channel plus a per-source 16/s budget with one audit line per window (switch.rs:1757, switch.rs:1809); revocation seen by a real host client within 60 s and the next connect refused (gvproxy_network.rs:1770).
  2. Item 4 (MEDIUM): SYN → RST|ACK with seq 0 / ack SYN.seq+1, held flow → tracked pair, unheld flow → nothing, all proved. But refuse_tcp_segment (switch.rs:1234) still answers an incoming RST on the revoked path (a peer's RST|ACK for a flow still in terminated_flows). RFC 793 §3.4: never reset a reset. Add the RST-flag early return before the budget charge.
  3. Item 7 (HIGH, blocking): gvproxy_network.rs:257 keeps map_or(Ipv4Addr::LOCALHOST, …); published_or_node (dns.rs:1046) still hands the node address when nothing was handed; session.rs:843 still leases from the in-guest pool; there is no "no published address handed" failure path; no reporter-absent / reporter-hands-127.0.0.1 proofs. Three of the four plan proofs (declared_ports_bound…, failed_forwarder_bind…, ingress_revocation…) assert 127.0.0.1:…, i.e. they rest on the fallback the note removes; only local_is_the_handed_loopback_address (:1984) proves the handed-address half.
  4. Item 5 is in the module doc only, and the PR body is still the round-0 runtime text (draft, exit-100 test table, stale out-of-scope list) with none of the item 5/7 statements or the three follow-ups. Rewrite it; name net/dns_gate.rs and tests/netns_root_integration.rs as forced signature fallout.
  5. LOW, note-only: revoke_port vs record_inbound race (switch.rs:2255/:2335) can leave one connection to time out; UDP mapping revoke inserts into the TCP revoked set (policy.rs:209); revoke_ingress still #[allow(dead_code)] (gvproxy_network.rs:72); fixed host port 48080 in a loopback-binding test.

Round 3 note follows as a /revise comment.

The pseudo-header's fourth word is big-endian [zero][protocol], so the
protocol contributes itself (6) unshifted; the << 8 it was built with
parked it in the zero byte's place (0x0600), giving a checksum no TCP
stack verifies, so every reset this module built was dropped at the
peer that was meant to receive it. RFC 793 §3.1 is the reference.

The test helpers that judged resets now verify the checksum by the
wire's own definition — assemble the pseudo-header from the frame and
fold the sum to 0xffff — instead of recomputing it with the same
function under test, and a known-answer unit test pins 0x23f2 for a
fixed segment, checked against an independent implementation off-tree.
revoke_ingress found its forwarder by external port but revoked the
gate by internal port, so with two mappings publishing the same in-box
port, revoking one ended the sibling's connections too. The gate
cannot tell forwarded connections apart — a box's inbound frames carry
only the internal port — so the revocation now withholds the gate's
termination while another live forwarder still serves that port, and
the port's connections end when the last forwarder to it is revoked:
the first moment they can be told apart at all.

The forwarder records a successful unbind on an Arc<AtomicBool>, so
teardown skips the unexpose for a port already revoked and a revocation
that failed at the switch stays unrevoked, with both retry and
teardown still having work to do.

The end-to-end test drives two external ports into one internal port,
revokes the first, and shows the sibling's established connection
surviving and new ones still completing its handshake; the last
revocation ends all three.
@minimal-foundry
Copy link
Copy Markdown
Contributor Author

Round 3 of 10 on the runtime's review findings, pushed as 1aa25de1ec0e.

line exit tail
cargo nextest run -p minimald declared_ports_bound_before_name_registered 0 Applying loadouts: default Running task runtime-implement-test-0 in session task-runtime-implement-test-0-c3de... Compiling ot v0.0.1 (/home/runner/.local/state/minimal/sessions/9e9a7/tree/crates/ot) Compiling args v0.0.1 (/home/runner/.local/state/minimal/sessions/9e9a7/tree/crates/args) C
cargo nextest run -p minimald ingress_revocation_unbinds_forwarder_and_terminates_connections 0 Applying loadouts: default Running task runtime-implement-test-1 in session task-runtime-implement-test-1-0a6a... Compiling stdlib v0.0.21 (/home/runner/.local/state/minimal/sessions/9fcde/tree/crates/stdlib) Compiling version v0.7.0 (/home/runner/.local/state/minimal/sessions/9fcde/tree/crate
cargo nextest run -p minimald failed_forwarder_bind_is_reported_not_substituted 0 Applying loadouts: default Running task runtime-implement-test-2 in session task-runtime-implement-test-2-500a... Compiling stdlib v0.0.21 (/home/runner/.local/state/minimal/sessions/92a1b/tree/crates/stdlib) Compiling version v0.7.0 (/home/runner/.local/state/minimal/sessions/92a1b/tree/crate
cargo nextest run -p minimald unpublished_port_connection_refused 0 Applying loadouts: default Running task runtime-implement-test-3 in session task-runtime-implement-test-3-55d2... Compiling stdlib v0.0.21 (/home/runner/.local/state/minimal/sessions/eeceb/tree/crates/stdlib) Compiling version v0.7.0 (/home/runner/.local/state/minimal/sessions/eeceb/tree/crate

Verify min task run agent-verify passed.

Outside the task's layers::

  • crates/minimald/src/net/dns_gate.rs
  • crates/minimald/tests/netns_root_integration.rs

Converted to a draft: something above needs a person before review.

Review threads the coder judged:

  • 👎 invalid — crates/minimald/src/net/gvproxy_network.rs:1312: The premise is stale at this head: declared_ports_bound_before_name_registered passes (run individually and together with the other proofs, 9+ times, stable — '1 test run: 1 passed'), the tree builds and clippy --all-targets is clean, and the whole minimald suite is green (557 passed). The 'Absent'
  • 👍 valid — crates/minimald/src/net/switch.rs: Fixed: the pseudo-header word is now built as [0x00][0x06] — the protocol contributes itself, unshifted — so resets carry a checksum a real TCP stack verifies. The self-referential checks are gone: assert_reset_refuses and assert_reset_terminates_flow now verify the checksum by the wire's own defini
  • 👍 valid — crates/minimald/src/net/gvproxy_network.rs:120: Fixed: revoke_ingress now computes the internal ports still served by a live sibling forwarder and passes them to PortForwarder::revoke, which withholds the gate's termination for those ports — so revoking one external port unbinds only its own forward and leaves a sibling's connections standing;

A declared port is reachable by name the moment the name exists, a revoked port's connections end at once, a failed bind is reported and never replaced by another address, and an unpublished port refuses instead of timing out — and the resets that carry the refusal and the terminations now carry a checksum a real TCP stack verifies, so they arrive instead of being dropped. A revocation no longer ends a sibling forwarder's connections when two mappings publish the same in-box port: only the last forwarder to a port ends its connections.

Notes from the agent: Round 3 addressed all three findings; two fixes plus their tests, two commits (fix(minimald): carry the TCP protocol in its own pseudo-header byte; fix(minimald): scope a revoked port's termination to its own forwarder). Both fixes were mutation-verified: reintroducing the << 8 fails the new known-answer test and the wire-fold check in refused_resets_carry_the_two_shapes_and_only_held_flows; making revoke ignore keep_admitted fails the new test's quiet-window assertion. The HIGH finding's premise is stale at this head — see the thread verdict — and session_host.rs was left unchanged on purpose: the task's layer note there ('holds the forwarders until stop') is satisfied structurally by Host::net_guard (session_host.rs:1670), which hands the session the Arc holding exposed for teardown at session end (session_host.rs:4006-4008), so the forwarders live exactly as long as the session does; there was no code to add. The revocation scoping has an honest residual, documented in policy.rs and in the test: the gate refuses by internal port alone (a box's inbound frames carry no trace of which external forward they arrived through), so while a live sibling serves the internal port the revocation withholds the gate's termination and the port's established connections continue until the last forwarder to that port is revoked — the first moment they can be told apart at all; forbidding the shared-internal-port…[truncated]

@minimal-foundry
Copy link
Copy Markdown
Contributor Author

Round 9 of 10 on a /revise note, pushed as b8a99d839867.

line exit tail
cargo nextest run -p minimald declared_ports_bound_before_name_registered 0 Applying loadouts: default Running task runtime-implement-test-0 in session task-runtime-implement-test-0-4f7d... Compiling ot v0.0.1 (/home/runner/.local/state/minimal/sessions/7b8af/tree/crates/ot) Compiling args v0.0.1 (/home/runner/.local/state/minimal/sessions/7b8af/tree/crates/args) C
cargo nextest run -p minimald ingress_revocation_unbinds_forwarder_and_terminates_connections 0 Applying loadouts: default Running task runtime-implement-test-1 in session task-runtime-implement-test-1-36fb... Compiling stdlib v0.0.21 (/home/runner/.local/state/minimal/sessions/4fed1/tree/crates/stdlib) Compiling version v0.7.0 (/home/runner/.local/state/minimal/sessions/4fed1/tree/crate
cargo nextest run -p minimald failed_forwarder_bind_is_reported_not_substituted 0 Applying loadouts: default Running task runtime-implement-test-2 in session task-runtime-implement-test-2-4517... Compiling stdlib v0.0.21 (/home/runner/.local/state/minimal/sessions/a231e/tree/crates/stdlib) Compiling version v0.7.0 (/home/runner/.local/state/minimal/sessions/a231e/tree/crate
cargo nextest run -p minimald unpublished_port_connection_refused 0 Applying loadouts: default Running task runtime-implement-test-3 in session task-runtime-implement-test-3-a748... Compiling stdlib v0.0.21 (/home/runner/.local/state/minimal/sessions/c5555/tree/crates/stdlib) Compiling version v0.7.0 (/home/runner/.local/state/minimal/sessions/c5555/tree/crate

Verify min task run agent-verify passed.

Outside the task's layers::

  • crates/minimald/src/net/answerer.rs
  • crates/minimald/src/net/dns_gate.rs
  • crates/minimald/src/net/provider.rs
  • crates/minimald/src/net/proxy.rs
  • crates/minimald/src/session/tests.rs
  • crates/minimald/src/sessions.rs
  • crates/minimald/tests/netns_root_integration.rs
  • scripts/session-e2e.sh

Converted to a draft: something above needs a person before review.

Review threads the coder judged:

  • 👎 invalid — PRRT_kwDOPdfcxM6oQmFy: Already fixed on this branch at f1de3bb and re-verified at this head: revoke_ingress computes keep_admitted — the internal ports of live forwarders bound to other external ports (gvproxy_network.rs:104-117) — and PortForwarder::revoke withholds the gate's termination for exactly those ports (policy (not posted on the thread: not an open thread on the pull request)

A handed box's name now publishes once at its final address: while the range verdict is pending its finalize waits for it — bounded by the 5 s session-start deadline — so the name appears at the box's hand the moment the verdict lands Present, or at the 127.0.0.1 interim if the deadline expires, with the present landing's sweep upgrading that interim to the hand; every move (hand to interim, interim to grant or hand, interim back under an absent landing) logs one warn naming the box and both addresses, a box destroyed inside the landing's window has nothing resurrected and its drawn grant released, and with it the task's proof stands: a declared port is reachable by name the moment the name exists, a revoked port's connections end at once, a failed bind is reported and never replaced by another address, and an unpublished port refuses instead of timing out.

Notes from the agent: Item 3's min ls half is client-side: crates/minimal/src/cmd/list.rs prints a state report with no per-session address column, and this round's scope is crates/minimald only, so what shipped is the daemon-side guarantee those lines would read — the zone answer and the box's route move with the publish together (asserted in the tests: the name answers at the hand/grant and its resolve upstream follows the same move). A per-session address column in min ls belongs to a round that names crates/minimal. On item 1's wording, the present landing upgrades a handed box's expiry interim to its hand, not to a pool grant, per the round's own rule that a hand is only ever replaced by 127.0.0.1 (the attach path's expose requests name the hand as their local); unhanded interims take a pool grant, and loopback-hand-to-interim/loopback-range-present-box/loopback-range-absent-box each warn once with both addresses, asserted in the two landing tests. The landing's draw runs outside every registry lock and its apply re-checks the box and its interim under the write lock (apply_interim_upg…[truncated]

…handed box

A handed box whose hand the range verdict does not vouch for now publishes
at the 127.0.0.1 interim and never asks the pool for a grant. Under the
registry write lock, a registration re-reads the verdict and promotes the
interim to the hand when a present landing has raced it.

The verdict wait is one deadline for the daemon, the book's open plus
HAND_VERDICT_WAIT, and every waiter races that same instant. Only a box's
first finalize waits. Session::run (resume, create, destroy-hook bring-up)
and rename never wait: they publish the interim and the present landing
moves them.

Under a pending verdict the grant answers every namespace with the
interim, a recorded one too, and keeps the record's line, so no
reserved-range address publishes before a landed present. The present
landing restores the recorded address.

The present landing's discard releases a drawn grant only when the box's
publish is gone, and its draw no longer grants for a handed box whose hand
is unvouched.

Tests poll a verdict-waiter count before landing, prove the discard's own
release, and cover no-wait resume and destroy, the shared deadline, the
resumed recorded address, and a re-registered box keeping its grant.
@norrietaylor
norrietaylor marked this pull request as ready for review October 2, 2026 22:52
@minimal-foundry minimal-foundry Bot left a comment
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

minimal-review — approved

Verdict APPROVE — No findings.

@minimal-foundry minimal-foundry Bot added the review:approved minimal-review approved the current head label Oct 2, 2026
The present landing runs on the deferred walk's spawned task, off every
mailbox, so its apply's write-lock re-check is the runtime guard; say so at
the spawn site and on apply_interim_upgrade.

Add a test that destroys a box between the landing's enumeration and its
grant: nothing is re-published and the grant is released exactly once, by
the discard. Add a test that queues a destroy behind a finalize waiting at
the verdict deadline: the publish at the hand precedes the removal, and no
name is left.
@minimal-foundry minimal-foundry Bot left a comment
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

minimal-review — approved

Verdict APPROVE — No findings.

Extract the decision register_hostname takes under the registry write lock
into promote_interim_to_hand and test it directly with no timers: an interim
publish with a hand moves to the hand under a present verdict, stays on the
interim under an absent one, and never moves over a name another session
holds (HostnameRegistry::name_held_by_another).
@minimal-foundry minimal-foundry Bot left a comment
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

minimal-review — approved

Verdict APPROVE — No findings.

@norrietaylor norrietaylor left a comment
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Reviewed at 4831e39: round-9 defects D1–D5 and Q2 are fixed, the landing re-check, the mailbox ordering and the promotion decision are each pinned by a test that fails under its mutation, and the architecture conditions are confirmed. Known gap (promotion call site) is recorded in the body.

@norrietaylor
norrietaylor enabled auto-merge October 3, 2026 00:01
@norrietaylor
Copy link
Copy Markdown
Member

/revise Round 10, the last round. Add one test, and change nothing else. Scope: crates/minimald/src/session/tests.rs only.

the_write_lock_promotion_moves_an_interim_to_a_vouched_hand_only pins the decision in promote_interim_to_hand, but not its call inside register_hostname. A change that skips the call still passes every test.

Add a test that drives register_hostname itself:

  • The range book has already landed Present.
  • The session's registration stands on the 127.0.0.1 interim.
  • The box has a handed address that vouches_for holds.

Assert that after register_hostname returns, the hand is the published address and the name answers with it. Use no timers: pass wait_for_verdict = false, or land the verdict before the call.

In the round report, show that the new test fails when the promote_interim_to_hand call in register_hostname is removed, then restore the call. Run cargo nextest run -p minimald register_hostname_promotes_an_interim_to_its_vouched_hand together with the existing session::tests.

Leave the PR body as it is; it is edited by hand.

@norrietaylor
norrietaylor disabled auto-merge October 3, 2026 00:02
The helper's unit test pins the decision promote_interim_to_hand makes;
this one pins that register_hostname makes the call: drive the
registration itself, with the ask answered "not vouched" and the verdict
landed present before the write lock — the exact window the re-read
under the lock exists for.

The drive is the box's first finalize parked on the pending verdict, the
one await between its ask and its publish; the test holds the registry's
write lock across that park, wakes the ask with an absent landing, waits
for its waiter to drain (the proof the ask consumed the absent answer),
stores the present verdict through the raw book store — never the
landing's sweep, which would take the very lock being held — and then
releases the lock. Nothing decides by a deadline.

Removing the call leaves the box standing at 127.0.0.1 and fails the
test; restoring it publishes and answers at the hand.
@minimal-foundry minimal-foundry Bot left a comment
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

minimal-review — approved

Verdict APPROVE — No findings.

@minimal-foundry
Copy link
Copy Markdown
Contributor Author

Round 10 of 10 on a /revise note, pushed as 61e17adfa837.

line exit tail
cargo nextest run -p minimald declared_ports_bound_before_name_registered 0 Applying loadouts: default Running task runtime-implement-test-0 in session task-runtime-implement-test-0-c57d... Compiling ot v0.0.1 (/home/runner/.local/state/minimal/sessions/cd3b3/tree/crates/ot) Compiling args v0.0.1 (/home/runner/.local/state/minimal/sessions/cd3b3/tree/crates/args) C
cargo nextest run -p minimald ingress_revocation_unbinds_forwarder_and_terminates_connections 0 Applying loadouts: default Running task runtime-implement-test-1 in session task-runtime-implement-test-1-74ad... Compiling stdlib v0.0.21 (/home/runner/.local/state/minimal/sessions/69474/tree/crates/stdlib) Compiling version v0.7.0 (/home/runner/.local/state/minimal/sessions/69474/tree/crate
cargo nextest run -p minimald failed_forwarder_bind_is_reported_not_substituted 0 Applying loadouts: default Running task runtime-implement-test-2 in session task-runtime-implement-test-2-dcdb... Compiling stdlib v0.0.21 (/home/runner/.local/state/minimal/sessions/24a74/tree/crates/stdlib) Compiling version v0.7.0 (/home/runner/.local/state/minimal/sessions/24a74/tree/crate
cargo nextest run -p minimald unpublished_port_connection_refused 0 Applying loadouts: default Running task runtime-implement-test-3 in session task-runtime-implement-test-3-9c2c... Compiling stdlib v0.0.21 (/home/runner/.local/state/minimal/sessions/a931d/tree/crates/stdlib) Compiling version v0.7.0 (/home/runner/.local/state/minimal/sessions/a931d/tree/crate

Verify min task run agent-verify passed.

Outside the task's layers::

  • crates/minimald/src/net/answerer.rs
  • crates/minimald/src/net/dns_gate.rs
  • crates/minimald/src/net/provider.rs
  • crates/minimald/src/net/proxy.rs
  • crates/minimald/src/session/tests.rs
  • crates/minimald/src/sessions.rs
  • crates/minimald/tests/netns_root_integration.rs
  • scripts/session-e2e.sh

Converted to a draft: something above needs a person before review.

The promote_interim_to_hand call inside register_hostname is now pinned by a test that drives the registration itself: a finalize whose ask was answered with the 127.0.0.1 interim publishes at its own hand — publish, A answer and route — when the verdict lands present before the write lock, and removing the call turns that test red (the box stands at the interim).

Notes from the agent: Round 10, one test added, nothing else changed: crates/minimald/src/session/tests.rs only, as the directive scoped.

Why the drive is not the sketch's literal shape: with the range book already landed Present and wait_for_verdict = false, hand_or_interim answers the hand directly, the promotion never fires, and the test would pass with the call removed. The call only fires in the window between the ask and the write lock, so the test drives exactly that window, deterministically and with no timer deciding anything: the box's first finalize is parked on the pending verdict (the one await between ask and publish, proven by verdict_waiters), the test then holds the registry's write lock, an absent landing wakes the ask to the interim (its loopback-hand-to-interim line is asserted as the drive's own account), the waiters count draining to zero proves the absent answer was consumed before the present verdict is stored, and the present verdict goes in through LoopbackLeaseBook::set_range_verdict — the raw store, the half of a real landing that runs before its sweep takes the registry. The sweep is deliberately not run: it would take the very lock being held, and it moves interim boxes itself (that move is an_interim_handed_publish_takes_its_hand_when_the_verdict_lands_late's to pin), which would make this test vacuous. Releasing the lock lets the registration publish, and it takes its hand. The deadline is held at DEADLINE_HELD_OPEN_MS and never fires.

One constraint forced a shape worth knowing: await-holding-lock is a denied workspace lint (CI clippy runs -D warnings), so th…[truncated]

@minimal-foundry
minimal-foundry Bot marked this pull request as draft October 3, 2026 01:14
@norrietaylor
norrietaylor marked this pull request as ready for review October 3, 2026 01:25
@norrietaylor norrietaylor changed the title T21 — Bind declared ports before the name and refuse unpublished ports instantly feat(minimald): bind declared ports before the name and refuse unpublished ports instantly Oct 3, 2026
@norrietaylor norrietaylor left a comment
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Re-approved at 61e17ad: round 10 adds register_hostname_promotes_an_interim_to_its_vouched_hand, which drives register_hostname through the write-lock window with no timer and closes the call-site gap recorded in the body. minimal-review APPROVE on this head.

@norrietaylor
norrietaylor enabled auto-merge October 3, 2026 01:25
@norrietaylor
norrietaylor added this pull request to the merge queue Oct 3, 2026
Merged via the queue into main with commit 658187e Oct 3, 2026
34 checks passed
@norrietaylor
norrietaylor deleted the agent/1657-bind-declared-ports-before-the-name-and-refuse-u branch October 3, 2026 03:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review:approved minimal-review approved the current head

Projects

None yet

Development

Successfully merging this pull request may close these issues.

T21 — Bind declared ports before the name and refuse unpublished ports instantly

1 participant

0