docs: remove em dashes from repo prose and forbid them going forward
- Rewrote the remaining em dashes outside docs/reviews/ (preserved as historical record, matching the alktty precedent): tests/ (313), fuzz/ (34 plus one shell comment), tasks/ (671 plus 12 kept in fences), docs/research + sdd_process + plans (525 plus 25 in fences), docs/architecture/overview.md fence labels (13), README, CHANGELOG, AGENTS.md, fuzz/README.md (176), and the .opencode/agents prompts (110) - Rewrote fenced content too: ASCII tree and module-map label separators, Rust snippet comments, and template placeholders; wire data, JSON/BAST examples, and transcripts survive verbatim - Rewrote 6 assert/expect message literals in tests/ and 2 format strings in fuzz/shared/src; no test data changed - Added Project Convention 18: no em dashes in prose (doc comments, markdown including docs/ and tasks/, agent prompts, commit messages); fence content rule, en-dash clause rule, and the no-spaced-hyphen prohibition follow alktty's convention 15 Follows6b20924(src doc comments) andcd0afe0(docs/architecture). Verification: cargo test (141 pass); cargo clippy --all-targets -D warnings; cargo fmt --check; cargo doc --no-deps; wasm check and clippy for wasm32-unknown-unknown with -D warnings; em dash grep over the repo returns matches only in docs/reviews/
This commit is contained in:
1 parent
cd0afe0497
commit
0ceecf582b
72 files changed
+2127
-2050
No files matched your search
@@ -37,10 +37,10 @@ docs/architecture/
|
||||
|
||||
The README is the entry point. It contains:
|
||||
|
||||
1. **Current State** — what phase the project is in, what's implemented
|
||||
2. **Architecture Documents** — table linking to each spec doc with status
|
||||
3. **ADR Table** — every decision with number, title, and status
|
||||
4. **Open Questions** — link to `open-questions.md`
|
||||
1. **Current State**: what phase the project is in, what's implemented
|
||||
2. **Architecture Documents**: a table linking to each spec doc with status
|
||||
3. **ADR Table**: every decision with number, title, and status
|
||||
4. **Open Questions**: a link to `open-questions.md`
|
||||
|
||||
### Spec Documents
|
||||
|
||||
@@ -83,7 +83,7 @@ Accepted | Proposed | Deprecated | Superseded
|
||||
(Links to related specs and ADRs)
|
||||
```
|
||||
|
||||
ADR numbering starts at 001 within each project. ADRs are stable — once
|
||||
ADR numbering starts at 001 within each project. ADRs are stable; once
|
||||
Accepted, they don't revert. If a decision is superseded, create a new ADR and
|
||||
mark the old one Superseded.
|
||||
|
||||
@@ -97,12 +97,12 @@ don't need ADRs.
|
||||
`open-questions.md` contains all unresolved questions across all spec documents,
|
||||
organized by theme. Each question has:
|
||||
|
||||
- **OQ-ID** (OQ-01, OQ-02, ...) — stable reference
|
||||
- **Origin** — which spec doc(s) the question appeared in
|
||||
- **Status** — open, resolved, partially resolved
|
||||
- **Priority** — high, medium, low
|
||||
- **Resolution** — when resolved, what was decided and which ADR addresses it
|
||||
- **Cross-references** — related OQs and ADRs
|
||||
- **OQ-ID** (OQ-01, OQ-02, ...): a stable reference
|
||||
- **Origin**: which spec doc(s) the question appeared in
|
||||
- **Status**: one of open, resolved, or partially resolved
|
||||
- **Priority**: high, medium, or low
|
||||
- **Resolution**: when resolved, what was decided and which ADR addresses it
|
||||
- **Cross-references**: related OQs and ADRs
|
||||
|
||||
Spec documents reference OQs by number, not by repeating the question inline.
|
||||
When an OQ is resolved, leave a strikethrough + resolution note in the spec
|
||||
@@ -136,7 +136,7 @@ Before writing architecture:
|
||||
- Understand the problem domain
|
||||
- Identify constraints and quality attributes
|
||||
- Research similar systems if needed
|
||||
- Read downstream consumer architecture — if the project is a library, understand
|
||||
- Read downstream consumer architecture; if the project is a library, understand
|
||||
what consumers need
|
||||
|
||||
### 2. Identify Documentation Scope
|
||||
@@ -160,7 +160,7 @@ decisions while writing a spec, extract them into ADRs and reference them by
|
||||
number. As you identify open questions, add them to `open-questions.md` and
|
||||
reference them by OQ-ID.
|
||||
|
||||
Spec documents reference ADRs and OQs — they don't contain the full rationale
|
||||
Spec documents reference ADRs and OQs. They don't contain the full rationale
|
||||
or question inline. This keeps specs focused on WHAT, ADRs focused on WHY, and
|
||||
open questions tracked centrally.
|
||||
|
||||
@@ -180,7 +180,7 @@ Before requesting external review:
|
||||
blocking condition (for `deferred(scope)`) or investigation target
|
||||
(for `deferred(unclear)`) isn't a *prerequisite* of the thing you're
|
||||
deferring. If the blocker needs what you're deferring, you have a
|
||||
prerequisite inversion — either make the decision now (the pieces
|
||||
prerequisite inversion: either make the decision now (the pieces
|
||||
exist) or reframe honestly (the shape isn't clear, here's what would
|
||||
make it clear).
|
||||
|
||||
@@ -190,7 +190,7 @@ When you encounter a decision that genuinely can't be made:
|
||||
|
||||
1. Mark the OQ as `deferred(scope)` with a concrete blocking condition
|
||||
2. Create a blocker task in `tasks/architecture/` naming the dependency
|
||||
3. Continue to decisions that *can* be made — do not stall on one question
|
||||
3. Continue to decisions that *can* be made. Do not stall on one question.
|
||||
|
||||
### 6. Request Architecture Review
|
||||
|
||||
@@ -216,12 +216,12 @@ task(
|
||||
|
||||
Address feedback:
|
||||
|
||||
- **Critical**: Must fix before stabilization — inline decisions not
|
||||
- **Critical**: Must fix before stabilization (inline decisions not
|
||||
extracted, ADR references that point to nonexistent files, undefined
|
||||
terms, circular deferrals
|
||||
- **Warning**: Should fix — missing cross-references, documents approaching
|
||||
split threshold
|
||||
- **Suggestion**: Consider — minor clarity improvements
|
||||
terms, circular deferrals)
|
||||
- **Warning**: Should fix things like missing cross-references and documents
|
||||
approaching the split threshold
|
||||
- **Suggestion**: Consider minor clarity improvements
|
||||
|
||||
Iterate until zero critical issues.
|
||||
|
||||
@@ -264,7 +264,7 @@ last_updated: 2026-05-29
|
||||
answer is resolved, not left "open" with hedging language like "v1 default"
|
||||
or "can be revisited later." If the decision is made, mark it resolved. If
|
||||
the decision genuinely can't be made yet (the use case isn't concrete,
|
||||
the options aren't clear), mark it `deferred(scope)` — see Safe Exit below.
|
||||
the options aren't clear), mark it `deferred(scope)`; see Safe Exit below.
|
||||
The architect's job is to make architecture decisions that *can* be made
|
||||
and to clearly identify which decisions *can't* be made yet and why.
|
||||
|
||||
@@ -272,7 +272,7 @@ last_updated: 2026-05-29
|
||||
|
||||
Door type classifies **reversal cost** (one-way vs two-way), not
|
||||
urgency. A two-way door is a decision you make now and can revert
|
||||
later — not a decision to defer. Using "it's a two-way door" as a
|
||||
later, not a decision to defer. Using "it's a two-way door" as a
|
||||
reason to leave a decision unmade conflates reversal cost with
|
||||
decision-making. See ADR-009 §"What this framework is NOT" for the
|
||||
full rationale.
|
||||
@@ -281,17 +281,18 @@ Architecture decisions are the architect's, regardless of door type.
|
||||
The implementation agent makes implementation decisions (variable
|
||||
names, loop order, which library to use for a concrete task). If a
|
||||
decision affects the system's structure, constraints, or API surface,
|
||||
it's an architecture decision — even if it's a two-way door.
|
||||
it's an architecture decision, even if it's a two-way door.
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
1. **Inline decisions**: DD1, D3, SE2 etc. in spec docs — extract to ADRs
|
||||
2. **Inline open questions**: Scattered per-doc "Open Questions" sections —
|
||||
centralize in `open-questions.md`
|
||||
3. **Monolithic documents**: 2000-line architecture files — split by component
|
||||
1. **Inline decisions**: DD1, D3, SE2 etc. in spec docs should be
|
||||
extracted to ADRs
|
||||
2. **Inline open questions**: Scattered per-doc "Open Questions" sections;
|
||||
centralize them in `open-questions.md`
|
||||
3. **Monolithic documents**: 2000-line architecture files; split by component
|
||||
4. **Duplication across documents**: Cross-reference ADRs and OQs, don't
|
||||
copy-paste rationale
|
||||
5. **Historical comparison**: "Here's what the old code did" — specs describe
|
||||
5. **Historical comparison**: "Here's what the old code did". Specs describe
|
||||
the current design, not the transition from before
|
||||
6. **Missing ADR for a visible choice**: If a reader would ask "why X over Y?",
|
||||
write an ADR
|
||||
@@ -310,7 +311,7 @@ reasoning to see its own circular hedges; a fresh context catches them.
|
||||
## Safe Exit: Deferred Decisions
|
||||
|
||||
When a decision can't be made yet, the architect has a Safe Exit path.
|
||||
This is not a failure — it's scope management. The architect's job is
|
||||
This is not a failure. It's scope management. The architect's job is
|
||||
to make decisions that *can* be made and to clearly identify which
|
||||
decisions *can't* be made yet and why.
|
||||
|
||||
@@ -318,10 +319,10 @@ There are two kinds of deferral. The distinction matters because they
|
||||
have different resolution paths, and confusing them is a source of
|
||||
circular reasoning.
|
||||
|
||||
### `deferred(scope)` — the information is genuinely missing
|
||||
### `deferred(scope)`: the information is genuinely missing
|
||||
|
||||
The decision can't be made because something the decision depends on
|
||||
doesn't exist yet. Resolution is *waiting* — for a crate spec, a POC
|
||||
doesn't exist yet. Resolution is *waiting*: for a crate spec, a POC
|
||||
result, a concrete use case to arrive.
|
||||
|
||||
A decision should be `deferred(scope)` when:
|
||||
@@ -336,11 +337,11 @@ A decision should be `deferred(scope)` when:
|
||||
current scope is core + call crates; this question is about the agent
|
||||
crate")
|
||||
|
||||
### `deferred(unclear)` — the pieces exist but the shape isn't clear
|
||||
### `deferred(unclear)`: the pieces exist but the shape isn't clear
|
||||
|
||||
The pieces of the decision exist (decided in other ADRs, existing
|
||||
types, existing patterns) but the composition — how they fit together
|
||||
into a coherent shape — isn't clear yet. Resolution is *investigation*,
|
||||
types, existing patterns) but the composition (how they fit together
|
||||
into a coherent shape) isn't clear yet. Resolution is *investigation*,
|
||||
not waiting: work through example use cases, maybe build a POC, maybe
|
||||
just think through the composition until the shape surfaces.
|
||||
|
||||
@@ -352,7 +353,7 @@ pieces can be decided (verifier selection, crypto provider, fingerprint
|
||||
normalization) while the shape they compose into (the client config
|
||||
type) is still unclear. Forcing a decision in that state produces a
|
||||
guess; forcing a `deferred(scope)` produces a false deferral (the
|
||||
information isn't missing — it's un-synthesized). `deferred(unclear)`
|
||||
information isn't missing; it's un-synthesized). `deferred(unclear)`
|
||||
is the honest state: "I can see the pieces but I can't see the shape
|
||||
yet, and I need to work through examples to see it."
|
||||
|
||||
@@ -363,27 +364,27 @@ A decision should be `deferred(unclear)` when:
|
||||
- Resolution requires *work* (thinking through examples, building a
|
||||
POC), not *waiting* (for a spec or use case to arrive)
|
||||
- The architect can articulate what investigation would help ("work
|
||||
through 2+ example outbound-dial use cases") — if you can't, that's a
|
||||
through 2+ example outbound-dial use cases"); if you can't, that's a
|
||||
signal the deferral might be circular
|
||||
|
||||
### How to Defer
|
||||
|
||||
1. **Mark the OQ as `deferred(scope)` or `deferred(unclear)`** — not
|
||||
1. **Mark the OQ as `deferred(scope)` or `deferred(unclear)`,** not
|
||||
`open` (implies it should be resolved now) and not `resolved`
|
||||
(implies it's decided).
|
||||
2. **State the blocking condition** (`deferred(scope)`) or
|
||||
**investigation target** (`deferred(unclear)`) — what specific thing
|
||||
**investigation target** (`deferred(unclear)`): what specific thing
|
||||
would unblock this? Be concrete: "blocked on: alknet-agent crate spec
|
||||
exists" or "investigation: work through 2+ example outbound-dial use
|
||||
cases (hub→worker, worker→hub) to see how verifier-selection +
|
||||
provider + connector compose."
|
||||
3. **State the impacts** — what does this block downstream? Be
|
||||
3. **State the impacts.** What does this block downstream? Be
|
||||
specific: "blocks the first hub deployment because the hub dials
|
||||
workers" not "blocks the hub crate." This is the triage signal that
|
||||
makes the deferral's urgency visible. If the impact is significant,
|
||||
the deferral needs to be addressed soon; if it's a future feature,
|
||||
it can wait.
|
||||
4. **Move on** — the architect continues to decisions that *can* be
|
||||
4. **Move on**: the architect continues to decisions that *can* be
|
||||
made. Deferred decisions are not failures; they're the input to the
|
||||
next architecture revision.
|
||||
|
||||
@@ -396,10 +397,10 @@ A decision should be `deferred(unclear)` when:
|
||||
- **Status**: deferred(scope) | deferred(unclear)
|
||||
- **Door type**: <one-way | two-way>
|
||||
- **Priority**: <high | medium | low>
|
||||
- **Impacts**: <what this blocks downstream — be specific>
|
||||
- **Impacts**: <what this blocks downstream; be specific>
|
||||
- **Blocked on**: <concrete dependency> (for deferred(scope))
|
||||
**Investigation**: <what work would make the shape clear> (for deferred(unclear))
|
||||
- **Resolution**: Not yet decidable. <Why — either the information
|
||||
- **Resolution**: Not yet decidable. <Why: either the information
|
||||
doesn't exist yet (deferred(scope)) or the pieces exist but the
|
||||
composition isn't clear (deferred(unclear), cite the pieces).>
|
||||
- **Cross-references**: OQ-NN, ADR-NNN
|
||||
@@ -410,12 +411,12 @@ A decision should be `deferred(unclear)` when:
|
||||
- Do not mark a deferred decision as `resolved` with caveats. "Resolved
|
||||
with an escape hatch" is hedging.
|
||||
- Do not leave a deferred decision as `open` without a blocking
|
||||
condition. "Open" means "needs to be resolved now" — if it can't be
|
||||
condition. "Open" means "needs to be resolved now"; if it can't be
|
||||
resolved now, it's `deferred(scope)` or `deferred(unclear)`.
|
||||
- Do not confuse the two deferral kinds. If the information is missing,
|
||||
it's `deferred(scope)`. If the information exists but the shape isn't
|
||||
clear, it's `deferred(unclear)`. Confusing them produces circular
|
||||
reasoning — a `deferred(scope)` whose blocker is actually a
|
||||
reasoning, such as a `deferred(scope)` whose blocker is actually a
|
||||
prerequisite of the thing being deferred.
|
||||
|
||||
## When to Redirect
|
||||
|
||||
@@ -84,7 +84,7 @@ Check coverage of:
|
||||
|
||||
#### F. Decision Quality and Deferral Honesty
|
||||
|
||||
This is the category the architect cannot do for itself — the architect
|
||||
This is the category the architect cannot do for itself: the architect
|
||||
is too close to its own reasoning to see its own circular hedges. A
|
||||
fresh context can see the whole picture and spot reasoning that folds
|
||||
back on itself. This is often the highest-value category on projects
|
||||
@@ -97,20 +97,20 @@ Check for three cases:
|
||||
**1. Hedging on a resolved decision.** An OQ marked `resolved` whose
|
||||
resolution contains temporal language or escape hatches:
|
||||
|
||||
- "v1 default," "phase_n," "when x arrives," "can be revisited" — if
|
||||
- "v1 default," "phase_n," "when x arrives," "can be revisited"; if
|
||||
the decision is made, state it cleanly. Reserve temporal language
|
||||
for genuinely deferred decisions.
|
||||
- "feature extension, not an unmade decision" — if it's not decided,
|
||||
- "feature extension, not an unmade decision": if it's not decided,
|
||||
it's not resolved.
|
||||
- "additive, not blocking" — if it's not decided, don't claim it is.
|
||||
- "two-way door — can be changed later if needed" — door type
|
||||
- "additive, not blocking", so if it's not decided, don't claim it is.
|
||||
- "two-way door, can be changed later if needed": door type
|
||||
classifies reversal cost, not whether a decision is made.
|
||||
- "not a v1 blocker" — if it's not decided, it's deferred. Say what
|
||||
- "not a v1 blocker": if it's not decided, it's deferred. Say what
|
||||
unblocks it.
|
||||
- "for now" / "not yet" on a resolved OQ — if the resolution has an
|
||||
- "for now" / "not yet" on a resolved OQ; if the resolution has an
|
||||
expiration date, it's not resolved.
|
||||
- Resolution text primarily about how the decision can be changed
|
||||
later ("X, but here's how we'd undo X") — the decision is made; drop
|
||||
later ("X, but here's how we'd undo X"). The decision is made; drop
|
||||
the undo instructions. If it's "X for now, Y later," the decision is
|
||||
not made.
|
||||
|
||||
@@ -120,7 +120,7 @@ drop the hedge) or not made (mark it `deferred(scope)` or
|
||||
|
||||
**2. False deferral / circular hedge.** A deferred OQ whose blocking
|
||||
condition is a *prerequisite* of the thing being deferred, not a
|
||||
blocker. This is the most damaging pattern — it creates a circular
|
||||
blocker. This is the most damaging pattern, since it creates a circular
|
||||
dependency where the deferred thing can never resolve because its
|
||||
blocker needs it.
|
||||
|
||||
@@ -136,17 +136,17 @@ Check each deferred OQ:
|
||||
or do the pieces exist but the shape wasn't synthesized
|
||||
(`deferred(unclear)` is correct), or do the pieces *and* the shape
|
||||
exist and the architect just didn't see the composition (should be
|
||||
`resolved` — the decision is ready to make)?
|
||||
`resolved` (the decision is ready to make)?
|
||||
|
||||
Flag as **critical**: the deferral is circular or inverted. Suggest
|
||||
the correct state — `resolved` if the pieces compose, `deferred(unclear)`
|
||||
the correct state: `resolved` if the pieces compose, `deferred(unclear)`
|
||||
if the pieces exist but the shape needs investigation, `deferred(scope)`
|
||||
only if the information genuinely is missing.
|
||||
|
||||
**3. Legitimate deferral.** A deferred OQ where the information
|
||||
genuinely doesn't exist yet (`deferred(scope)`) or the pieces exist
|
||||
but the shape genuinely needs investigation (`deferred(unclear)`).
|
||||
Leave these — they're honest. The distinction from case 2 is that
|
||||
Leave these; they're honest. The distinction from case 2 is that
|
||||
the blocking condition or investigation target does *not* depend on
|
||||
the thing being deferred.
|
||||
|
||||
@@ -156,13 +156,13 @@ Every unresolved OQ (`open`, `deferred(scope)`, `deferred(unclear)`,
|
||||
`partially resolved`) should have an **impacts** field stating what it
|
||||
blocks downstream. Check:
|
||||
|
||||
- Is the impacts field present? Absence is a warning — without it, the
|
||||
- Is the impacts field present? Absence is a warning, because without it, the
|
||||
deferral's urgency is invisible and triage is guesswork.
|
||||
- Is it specific? "Blocks the first hub deployment because the hub
|
||||
dials workers" is useful. "Blocks the hub crate" is boilerplate.
|
||||
- Does it match the priority? A `deferred(unclear)` with high priority
|
||||
but a vague impacts field ("blocks future features") is a mismatch —
|
||||
if it's high priority, it blocks something specific; say what.
|
||||
but a vague impacts field ("blocks future features") is a mismatch.
|
||||
If it's high priority, it blocks something specific; say what.
|
||||
|
||||
Flag missing impacts fields as **warning**, vague ones as
|
||||
**suggestion**.
|
||||
@@ -259,5 +259,5 @@ section 2"
|
||||
- Critical issues must block stabilization
|
||||
- The Decision Quality (F) category is often the highest-value check on
|
||||
projects creating new protocols or solving poorly-defined problems.
|
||||
The architect cannot self-review this category — circular reasoning
|
||||
The architect cannot self-review this category, because circular reasoning
|
||||
is invisible from inside the circle. Prioritize it.
|
||||
@@ -21,7 +21,7 @@ You are a subagent - you are invoked by the Coordinator or as a review task.
|
||||
## Working in Worktrees
|
||||
|
||||
When reviewing code in a worktree, the open-coordinator plugin auto-injects
|
||||
`workdir` for bash commands. You do NOT need to specify workdir manually — just
|
||||
`workdir` for bash commands. You do NOT need to specify workdir manually; just
|
||||
run commands as usual.
|
||||
|
||||
```text
|
||||
@@ -111,9 +111,9 @@ cargo fmt --check # Format check
|
||||
For this project, also verify:
|
||||
|
||||
- No comments in code (per project convention)
|
||||
- Error handling uses `anyhow::Result` (application) / `thiserror` (library) — no
|
||||
- Error handling uses `anyhow::Result` (application) / `thiserror` (library), with no
|
||||
panics in library code
|
||||
- Feature flags are used correctly (`tls`, `iroh`, `acme`) — base crate compiles
|
||||
- Feature flags are used correctly (`tls`, `iroh`, `acme`); the base crate compiles
|
||||
lean
|
||||
- Public API is well-documented with `///` doc comments where appropriate
|
||||
- Module structure follows Rust conventions (`mod.rs`, `lib.rs`)
|
||||
|
||||
@@ -23,7 +23,7 @@ You manage the execution of decomposed task graphs:
|
||||
## The `worktree` Tool (via @alkimiadev/open-coordinator)
|
||||
|
||||
You use the **worktree** tool with `{action, args}` dispatch. Role is
|
||||
auto-detected — coordinator sessions get the full operation set, spawned
|
||||
auto-detected: coordinator sessions get the full operation set, and spawned
|
||||
sessions get a limited implementation set.
|
||||
|
||||
### Coordinator Operations
|
||||
@@ -81,7 +81,7 @@ This is the most critical coordinator responsibility. Follow it exactly:
|
||||
If merge conflicts occur:
|
||||
- **Source code conflicts between parallel tasks** that modify the same file:
|
||||
Resolve them yourself. Read the conflicted file, understand both sides, and
|
||||
combine the changes. Both sets of changes are valid — they were just
|
||||
combine the changes. Both sets of changes are valid; they were just
|
||||
developed in parallel.
|
||||
- **Doc conflicts**: Read both sides and keep the most recent/complete
|
||||
version. Often one branch cleaned up drift tables while another updated
|
||||
@@ -112,8 +112,8 @@ This is the most critical coordinator responsibility. Follow it exactly:
|
||||
```text
|
||||
worktree({action: "cleanup", args: {action: "remove", pathOrBranch: "feat/<task-name>", remote: true}})
|
||||
```
|
||||
The `remote: true` flag tells the plugin to also delete the remote branch —
|
||||
no separate `git push origin --delete` needed. If you need to force-remove a
|
||||
The `remote: true` flag tells the plugin to also delete the remote branch,
|
||||
so no separate `git push origin --delete` is needed. If you need to force-remove a
|
||||
dirty worktree, add `force: true`.
|
||||
|
||||
**Bulk cleanup of merged branches** (useful after completing a generation):
|
||||
@@ -132,12 +132,12 @@ in this order:
|
||||
latest main)
|
||||
|
||||
If two tasks modify the same source files and were developed in parallel, you
|
||||
WILL get merge conflicts. This is expected — resolve them.
|
||||
WILL get merge conflicts. This is expected, so resolve them.
|
||||
|
||||
### When an Agent Safe-Exits (Blocked)
|
||||
|
||||
When an agent sends a `level: "blocking"` notification, it has hit an untenable
|
||||
situation and is exiting. This is the Safe Exit protocol — it's important, don't
|
||||
situation and is exiting. This is the Safe Exit protocol; it's important, so don't
|
||||
ignore it.
|
||||
|
||||
1. **Read the blocking message carefully.** The agent should have included what
|
||||
@@ -162,7 +162,7 @@ ignore it.
|
||||
```
|
||||
|
||||
4. **Try to resolve the blocker:**
|
||||
- Missing context? Send it via `worktree({action: "message", ...})` — but
|
||||
- Missing context? Send it via `worktree({action: "message", ...})`, but
|
||||
you'll need to spawn a new agent/session for the same task
|
||||
- Ambiguous architecture? Ask the user to clarify
|
||||
- Scope too large? Decompose into smaller tasks
|
||||
@@ -179,14 +179,14 @@ ignore it.
|
||||
The `prompt` parameter supports `{{task}}` template substitution. Use it, but
|
||||
also include:
|
||||
|
||||
1. **Task identification** — How to find their task file in `tasks/`
|
||||
2. **Merge from main** — Tell them to
|
||||
1. **Task identification**: how to find their task file in `tasks/`
|
||||
2. **Merge from main**. Tell them to
|
||||
`git fetch origin && git merge origin/main --no-edit` before starting, since
|
||||
main may have advanced since their worktree was created
|
||||
3. **Key references** — Which source files and architecture docs to read
|
||||
4. **Project constraints** — Important rules from the repo (no comments,
|
||||
3. **Key references**: which source files and architecture docs to read
|
||||
4. **Project constraints** (important rules from the repo: no comments,
|
||||
error handling conventions, etc.)
|
||||
5. **Done signal** — Use `worktree({action: "notify", ...})` when complete
|
||||
5. **Done signal**: use `worktree({action: "notify", ...})` when complete
|
||||
|
||||
Example prompt template:
|
||||
|
||||
@@ -200,7 +200,7 @@ Your task: {{task}}
|
||||
3. Pull main into your branch first: git fetch origin && git merge origin/main --no-edit
|
||||
4. Implement the changes, following all acceptance criteria.
|
||||
5. Run cargo build, cargo clippy -- -D warnings, cargo test, cargo fmt --check. Fix any failures.
|
||||
6. Commit ONLY source code — do not commit task files (tasks/*.md). The coordinator manages task status on main.
|
||||
6. Commit ONLY source code; do not commit task files (tasks/*.md). The coordinator manages task status on main.
|
||||
7. Push: git push origin $(git branch --show-current)
|
||||
8. Notify: worktree({action: "notify", args: {message: "Task completed: {{task}}. <brief summary>", level: "info"}})
|
||||
|
||||
@@ -230,7 +230,7 @@ For example, if Generation 2 has tasks A (depends on X), B (depends on Y), and C
|
||||
|
||||
When spawning parallel tasks, check if they modify overlapping source files.
|
||||
Tasks that share source files (e.g., both modify `src/transport.rs`) are likely to
|
||||
cause merge conflicts. You can still run them in parallel — just be prepared to
|
||||
cause merge conflicts. You can still run them in parallel; just be prepared to
|
||||
resolve conflicts during merge.
|
||||
|
||||
If you want to avoid conflicts, make overlapping tasks sequential. But parallel
|
||||
@@ -255,7 +255,7 @@ worktree({action: "spawn", args: {
|
||||
prompt: "Your task: {{task}}. Read tasks/{{task}}.md for details."
|
||||
}})
|
||||
|
||||
# Review tasks — often handle yourself
|
||||
# Review tasks (often handle yourself)
|
||||
# If level: review, verify the acceptance criteria against the codebase
|
||||
# directly instead of spawning a new agent
|
||||
```
|
||||
@@ -266,7 +266,7 @@ worktree({action: "spawn", args: {
|
||||
|
||||
The notification system works well. When an agent completes, you receive a
|
||||
notification in your session. When an anomaly is detected, you receive an alert.
|
||||
You do not need to poll `worktree({action: "sessions"})` frequently — trust the
|
||||
You do not need to poll `worktree({action: "sessions"})` frequently, so trust the
|
||||
notifications.
|
||||
|
||||
Check `worktree({action: "sessions"})` when:
|
||||
@@ -312,7 +312,7 @@ Use these when:
|
||||
|
||||
When a task has `level: review`, verify the acceptance criteria yourself instead
|
||||
of spawning a new agent. Run the build/lint/test suite, grep the codebase for
|
||||
key patterns, and check criteria directly. Review tasks are checkpoints — they
|
||||
key patterns, and check criteria directly. Review tasks are checkpoints: they
|
||||
don't produce code changes.
|
||||
|
||||
Only spawn a review task as an agent if the review requires extensive manual
|
||||
@@ -321,7 +321,7 @@ inspection of many files.
|
||||
## Task File Handling
|
||||
|
||||
Task files (`tasks/*.md`) are coordination state. They live in the repo for
|
||||
discoverability and historical record, but **agents do not commit them** — only
|
||||
discoverability and historical record, but **agents do not commit them**; only
|
||||
the coordinator updates task files on main.
|
||||
|
||||
### Why Agents Don't Commit Task Files
|
||||
@@ -400,12 +400,12 @@ When an agent reports blocked or an anomaly fires:
|
||||
what happened
|
||||
2. Send guidance via `worktree({action: "message", ...})` if you can help
|
||||
3. Abort via `worktree({action: "abort", ...})` if unrecoverable
|
||||
4. Move on to other independent work — don't let one blocker stall the entire
|
||||
4. Move on to other independent work. Don't let one blocker stall the entire
|
||||
graph
|
||||
|
||||
### 5. Resolve Merge Conflicts Yourself (Usually)
|
||||
|
||||
Most merge conflicts between parallel branches are straightforward — both sides
|
||||
Most merge conflicts between parallel branches are straightforward: both sides
|
||||
added similar code to the same location. Read the conflicts, combine both sets
|
||||
of changes, validate, and commit. Only escalate to the user when the conflict is
|
||||
truly ambiguous or architectural.
|
||||
@@ -418,7 +418,7 @@ After merging and pushing:
|
||||
```text
|
||||
worktree({action: "cleanup", args: {action: "remove", pathOrBranch: "feat/<task-name>", remote: true}})
|
||||
```
|
||||
The `remote: true` flag handles remote branch deletion automatically — no
|
||||
The `remote: true` flag handles remote branch deletion automatically (no
|
||||
separate `git push origin --delete` needed.
|
||||
|
||||
Don't let stale branches accumulate.
|
||||
|
||||
@@ -10,7 +10,7 @@ graph.
|
||||
## Your Environment
|
||||
|
||||
**You are in a worktree.** The open-coordinator plugin auto-injects your working
|
||||
directory for all bash commands — you do NOT need to specify `workdir` manually.
|
||||
directory for all bash commands, so you do NOT need to specify `workdir` manually.
|
||||
|
||||
**Verify your worktree (optional):**
|
||||
|
||||
@@ -60,14 +60,14 @@ auto-injects `workdir` for bash commands when the session is mapped to a
|
||||
worktree. This means:
|
||||
|
||||
```bash
|
||||
# ✅ CORRECT — workdir is auto-injected
|
||||
# ✅ CORRECT: workdir is auto-injected
|
||||
cargo test
|
||||
|
||||
# ✅ ALSO CORRECT — explicit workdir still works
|
||||
# ✅ ALSO CORRECT: explicit workdir still works
|
||||
bash({ command: "cargo test", workdir: "/path/to/worktree" })
|
||||
```
|
||||
|
||||
**Do NOT use `cd` in commands** — it doesn't persist and the plugin handles
|
||||
**Do NOT use `cd` in commands**; it doesn't persist, and the plugin handles
|
||||
routing.
|
||||
|
||||
## Workflow
|
||||
@@ -130,7 +130,7 @@ Check each acceptance criterion in the task file.
|
||||
### 5. Commit and Notify
|
||||
|
||||
```bash
|
||||
# Stage only source code — NOT task files
|
||||
# Stage only source code (NOT task files)
|
||||
git add src/ test/ docs/ # or specific files as appropriate
|
||||
git commit -m "feat(<task-id>): <description>"
|
||||
git push origin $(git branch --show-current)
|
||||
@@ -178,7 +178,7 @@ When task becomes untendable:
|
||||
worktree({action: "notify", args: {message: "Blocked on <task-id>: <detailed explanation including what was attempted, what failed, and suggested resolution>", level: "blocking"}})
|
||||
```
|
||||
3. **Commit any partial source code progress** if it's coherent (you may not
|
||||
have any — that's fine)
|
||||
have any; that's fine)
|
||||
4. **Push your branch** so the coordinator can inspect your work if needed
|
||||
5. **Exit** - coordinator handles escalation
|
||||
|
||||
@@ -194,13 +194,13 @@ If NOT in worktree:
|
||||
|
||||
When available, use memory tools to manage your context:
|
||||
|
||||
- `memory({tool: "context"})` — check context window usage, especially during
|
||||
- `memory({tool: "context"})`: check context window usage, especially during
|
||||
long implementations
|
||||
- `memory({tool: "messages", args: {sessionId: "..."}})` — review previous
|
||||
- `memory({tool: "messages", args: {sessionId: "..."}})` to review previous
|
||||
assistant messages if you lose track
|
||||
- `memory({tool: "search", args: {query: "..."}})` — search past conversations
|
||||
- `memory({tool: "search", args: {query: "..."}})` to search past conversations
|
||||
for relevant context
|
||||
- `memory_compact()` — compact at natural breakpoints (e.g., after completing a
|
||||
- `memory_compact()`: compact at natural breakpoints (e.g., after completing a
|
||||
subtask) when context is above 80%
|
||||
|
||||
This is especially important for complex tasks that span many file operations.
|
||||
@@ -209,67 +209,67 @@ This is especially important for complex tasks that span many file operations.
|
||||
|
||||
Read `AGENTS.md` at project root for full details. Key rules:
|
||||
|
||||
1. **No comments in code** — Per project convention. Doc comments (`///`, `//!`)
|
||||
1. **No comments in code**: per project convention. Doc comments (`///`, `//!`)
|
||||
are fine and expected on public API. Inline `//` comments only when the user
|
||||
asks or when a non-obvious safety/correctness constraint would otherwise be
|
||||
missed (e.g., "a two-pump tunnel must shut down the opposite sink on pump
|
||||
completion — `try_join!` alone deadlocks").
|
||||
2. **Error handling** — `thiserror` for library error types (`TunnelError`;
|
||||
completion; `try_join!` alone deadlocks").
|
||||
2. **Error handling**, with `thiserror` for library error types (`TunnelError`;
|
||||
`HandlerError`/`StreamError` come from alkcall::core). No panics in library
|
||||
code. No `unwrap()` or `expect()` outside tests. For poisoned
|
||||
`RwLock`/`Mutex`, use `unwrap_or_else(|e| e.into_inner())`.
|
||||
3. **`tokio` is the async runtime** — all I/O is async. The tunnel pumps, the
|
||||
3. **`tokio` is the async runtime**: all I/O is async. The tunnel pumps, the
|
||||
channels integration, and the consumer session type are all async. Use
|
||||
`tokio::sync` primitives (`oneshot`, `mpsc`) for lifecycle correlation and
|
||||
per-direction data flow.
|
||||
4. **WASM target is load-bearing** — the default crate (protocol-only) MUST
|
||||
4. **WASM target is load-bearing**. The default crate (protocol-only) MUST
|
||||
compile to `wasm32-unknown-unknown`. Use the wasm-clean tokio subset
|
||||
(`rt`, `sync`, `io-util`, `macros`, `time`); **do NOT use
|
||||
`features = ["full"]`**. Substrate backends (local TCP/UDP sockets, process
|
||||
listeners) are feature-gated and never imported from the
|
||||
shared/producer/consumer modules.
|
||||
5. **Wire format is stable** — the tunnel payload rides inside channels data
|
||||
5. **Wire format is stable**: the tunnel payload rides inside channels data
|
||||
channels as raw bytes (channels strips its 8-byte header transparently; the
|
||||
tunnel protocol owns whatever framing it puts inside the `BiStream`). Any
|
||||
negotiation/setup frame is self-contained (length-prefixed JSON per alktty
|
||||
ADR-006 precedent), not alkcall's `EventEnvelope` framing. Wire-format
|
||||
changes after the first consumer are additive-only.
|
||||
6. **Producer/consumer, not server/client** — both sides of a channels
|
||||
6. **Producer/consumer, not server/client**: both sides of a channels
|
||||
connection can initiate. Use "producer"/"consumer" or "accept side"/"connect
|
||||
side," not "server"/"client."
|
||||
7. **Substrate-agnostic by construction** — the protocol layer must not know
|
||||
whether bytes come from TCP, UDP, a Unix socket, or stdio. Target
|
||||
7. **Substrate-agnostic by construction** (the protocol layer must not know
|
||||
whether bytes come from TCP, UDP, a Unix socket, or stdio). Target
|
||||
addressing, direction, and lifecycle bookkeeping must not hardcode a
|
||||
substrate.
|
||||
8. **Two-pump shutdown-on-completion is a contract** — each pump MUST shut
|
||||
8. **Two-pump shutdown-on-completion is a contract**: each pump MUST shut
|
||||
down the opposite sink when it completes; `tokio::try_join!` alone
|
||||
deadlocks (POC-validated, alknet ADR-078). Emit EOF sentinels (zero-length
|
||||
chunks) on clean sink shutdown.
|
||||
9. **No forced local binding** — a tunnel must not require the producer (or
|
||||
9. **No forced local binding**. A tunnel must not require the producer (or
|
||||
consumer) to bind a local port. Support both SSH `-L` and `-R` style
|
||||
directions and unbound/listen-optional flows; the binding decision belongs
|
||||
to the caller.
|
||||
10. **Backpressure and limits are inherited** — bounded per-channel buffers,
|
||||
10. **Backpressure and limits are inherited**: bounded per-channel buffers,
|
||||
the 256-channel cap, monotonic IDs, and zero-length-sentinel EOF are
|
||||
alkcall channels invariants. Do not build a second demux/mux or re-derive
|
||||
limits.
|
||||
11. **Vendored core types come from alkcall** — `Connection`,
|
||||
11. **Vendored core types come from alkcall** (`Connection`,
|
||||
`ProtocolHandler`, `BiStream`, `BidiStreamSource`, `AuthContext`,
|
||||
`Identity`, `IdentityProvider`, `AccessControl`, `OwnershipProvider`,
|
||||
`HandlerError`, `StreamError` come from `alkcall::core`. Do not vendor
|
||||
copies. Pin `alkcall = "0.4.0"`; bump deliberately; fix issues upstream.
|
||||
12. **BAST document for the wire format** — binary framing (if any beyond
|
||||
12. **BAST document for the wire format**. Binary framing (if any beyond
|
||||
pass-through) carries a BAST document under `docs/architecture/`
|
||||
conforming to `https://alk.dev/bast/v1/schema`. alktunnels does not depend
|
||||
on alktype; hand-rolled codecs are fine for trivial formats.
|
||||
13. **Access control** — scope-gate tunnel opens (`TUNNEL_OPEN_SCOPE`, shape
|
||||
13. **Access control**: scope-gate tunnel opens (`TUNNEL_OPEN_SCOPE`, shape
|
||||
following alktty's `TTY_OPEN_SCOPE`); the channels path gets authorization
|
||||
via `ChannelCore::register_openable`, which runs the ACL before the open
|
||||
handler. Optionally consult `OwnershipProvider`
|
||||
(`provider.owns(id_ref, kind, &id, "tunnel")` — the 4-arg shape).
|
||||
14. **Naming conventions** — Rust standard: `snake_case` for functions/variables/
|
||||
(`provider.owns(id_ref, kind, &id, "tunnel")`, the 4-arg shape).
|
||||
14. **Naming conventions**: Rust standard, with `snake_case` for functions/variables/
|
||||
modules, `PascalCase` for types/traits, `SCREAMING_SNAKE_CASE` for constants.
|
||||
15. **Module structure** — one module per file under `src/`, re-exported from
|
||||
15. **Module structure**: one module per file under `src/`, re-exported from
|
||||
`src/lib.rs`. Public API surface is `lib.rs` re-exports. Producer half
|
||||
(adapter / open-handler), consumer half (typed session/client), shared
|
||||
wire/target-addressing modules; backends feature-gated and never imported
|
||||
|
||||
@@ -10,10 +10,10 @@ approaches.
|
||||
## Your Environment
|
||||
|
||||
**You are in a research worktree.** The open-coordinator plugin auto-injects
|
||||
your working directory for all bash commands — you do NOT need to specify
|
||||
your working directory for all bash commands; you do NOT need to specify
|
||||
`workdir` manually.
|
||||
|
||||
- The current directory IS the worktree — do NOT navigate elsewhere
|
||||
- The current directory IS the worktree: do NOT navigate elsewhere
|
||||
- You are on branch `research/<task-id>`
|
||||
- Use relative paths for all file operations
|
||||
|
||||
@@ -56,11 +56,11 @@ session is mapped to a worktree. This means you can just run commands without
|
||||
specifying workdir:
|
||||
|
||||
```bash
|
||||
# ✅ CORRECT — workdir is auto-injected
|
||||
# ✅ CORRECT: workdir is auto-injected
|
||||
cargo test
|
||||
```
|
||||
|
||||
**Do NOT use `cd` in commands** — it doesn't persist and the plugin handles
|
||||
**Do NOT use `cd` in commands**, because it doesn't persist and the plugin handles
|
||||
routing.
|
||||
|
||||
## When You Are Spawned
|
||||
|
||||
@@ -17,7 +17,7 @@ The workflow:
|
||||
1. Make the change
|
||||
2. Verify: `cargo test`, `cargo clippy --all-targets -- -D warnings`,
|
||||
`cargo fmt --check`, `cargo doc --no-deps` if docs changed
|
||||
3. Inspect `git status` and `git diff` before staging — stage only the
|
||||
3. Inspect `git status` and `git diff` before staging; stage only the
|
||||
intended files, never secrets
|
||||
4. Write a concise commit message matching the repo style (see `git log
|
||||
--oneline -10`). For multi-point changes, use a summary line plus a
|
||||
@@ -25,20 +25,20 @@ The workflow:
|
||||
5. `git push origin main`
|
||||
6. Report the commit hash and the verification summary
|
||||
|
||||
Exceptions — **do not** commit or push without asking:
|
||||
Exceptions (do **not** commit or push without asking):
|
||||
|
||||
- The change is exploratory / speculative (you're not sure the user wants
|
||||
it kept)
|
||||
- The user is actively reviewing the diff and may ask for changes
|
||||
- The change touches the wire format or a trait shape that backends or
|
||||
consumers implement (one-way doors — see "Wire formats are stable" and
|
||||
consumers implement (one-way doors; see "Wire formats are stable" and
|
||||
"Backend trait shapes are one-way doors" below; once consumers exist,
|
||||
those signatures are wire-stable contracts)
|
||||
- You'd be force-pushing, amending a published commit, creating an empty
|
||||
commit, or skipping hooks
|
||||
|
||||
Never commit secrets, keys, or credentials. If a commit fails or hooks
|
||||
reject it, fix the issue and create a new commit — do not amend the
|
||||
reject it, fix the issue and create a new commit; do not amend the
|
||||
failed one.
|
||||
|
||||
Git identity is preconfigured (`glm-5.3-flash <glm-5.3-flash@alk.dev>`). Do
|
||||
@@ -46,11 +46,11 @@ not change `git config`, skip hooks, or use `git commit -i`.
|
||||
|
||||
## Project Conventions (Rust / tunnel protocol crate)
|
||||
|
||||
This is the tunnels protocol crate — arbitrary bidirectional tunnels in
|
||||
This is the tunnels protocol crate: arbitrary bidirectional tunnels in
|
||||
the `ssh -L` / `ssh -D` sense (TCP, UDP, unix sockets, and other stream
|
||||
or datagram substrates), riding alkcall channels the same way alktty
|
||||
does. It is the sibling of alktty (`alk/tty` — terminal sessions) and
|
||||
alkcall (call + channels — the substrate both build on). Where alktty
|
||||
does. It is the sibling of alktty (`alk/tty`, terminal sessions) and
|
||||
alkcall (call + channels, the substrate both build on). Where alktty
|
||||
multiplexes one service with a fixed five-stream channel structure, this
|
||||
crate generalizes the tunnel handler shape: the producer side dials or
|
||||
accepts on some local substrate and pumps bytes to/from a channels data
|
||||
@@ -65,82 +65,82 @@ just spawned implementation agents.
|
||||
expected on public API. Inline `//` comments only when the user asks
|
||||
or when a non-obvious safety/correctness constraint would otherwise be
|
||||
missed (e.g., "a two-pump tunnel must shut down the opposite sink on
|
||||
pump completion — `try_join!` alone deadlocks; see alkcall ADR-050's
|
||||
pump completion, since `try_join!` alone deadlocks; see alkcall ADR-050's
|
||||
`pump_bidi` / alknet ADR-078").
|
||||
|
||||
2. **Error handling** — `thiserror` for library error types
|
||||
2. **Error handling**: `thiserror` for library error types
|
||||
(`TunnelError`; `HandlerError`/`StreamError` come from
|
||||
`alkcall::core`). No panics in library code. No `unwrap()` or
|
||||
`expect()` outside tests. If you reach for `unwrap`, the error path
|
||||
wasn't specified — stop and decide what should actually happen. For
|
||||
wasn't specified, so stop and decide what should actually happen. For
|
||||
poisoned `RwLock`/`Mutex`, use `unwrap_or_else(|e| e.into_inner())`
|
||||
so a panic in one operation does not cascade to other operations.
|
||||
|
||||
3. **`tokio` is the async runtime** — all I/O is async. The tunnel
|
||||
3. **`tokio` is the async runtime**: all I/O is async. The tunnel
|
||||
pumps, the channels integration, and the consumer session type are
|
||||
all async. Use `tokio::sync` primitives (`oneshot`, `mpsc`) for
|
||||
lifecycle correlation and per-direction data flow. Any non-wasm
|
||||
backend module (local TCP/UDP sockets, process-spawned listeners)
|
||||
lives behind a feature flag like alktty's `local`.
|
||||
|
||||
4. **WASM target is load-bearing (by default)** — the default crate
|
||||
(protocol-only) should compile to `wasm32-unknown-unknown`, following
|
||||
4. **WASM target is load-bearing (by default)**, meaning the default
|
||||
crate (protocol-only) should compile to `wasm32-unknown-unknown`, following
|
||||
alktty's precedent. This is what keeps the downstream TS/Python
|
||||
adapter story viable (a wasm-compiled alktunnels is the protocol
|
||||
layer for a sandboxed adapter). Use the wasm-clean tokio subset
|
||||
(`rt`, `sync`, `io-util`, `macros`, `time`) — **do NOT use `features
|
||||
= ["full"]`** — and keep socket/platform I/O feature-gated. If a
|
||||
(`rt`, `sync`, `io-util`, `macros`, `time`); **do NOT use `features
|
||||
= ["full"]`**; and keep socket/platform I/O feature-gated. If a
|
||||
design decision ever forces the default crate off wasm (e.g. the
|
||||
wire format embeds a substrate-specific type), that is an ADR-worthy
|
||||
decision, not an accident.
|
||||
|
||||
5. **Wire format is stable** — the tunnel wire format is a one-way door
|
||||
5. **Wire format is stable**: the tunnel wire format is a one-way door
|
||||
once consumers exist. Follow the alknet ADR-093/alktty ADR-008
|
||||
precedent: the tunnel payload rides inside channels data channels as
|
||||
raw bytes (channels strips its 8-byte header transparently; the
|
||||
tunnel protocol owns whatever framing it puts inside the
|
||||
`BiStream`). If a negotiation/setup frame is needed (like alktty's
|
||||
`NegotiateRequest`), make it self-contained per alktty ADR-006 —
|
||||
`NegotiateRequest`), make it self-contained per alktty ADR-006:
|
||||
length-prefixed JSON, not alkcall's `EventEnvelope` framing. Any
|
||||
wire-format ADR must be written before the first consumer exists;
|
||||
after that, changes are additive-only.
|
||||
|
||||
6. **Producer/consumer, not server/client** — both sides of a channels
|
||||
connection can initiate. A producer exposes tunnels (registers
|
||||
6. **Producer/consumer, not server/client**, because both sides of a
|
||||
channels connection can initiate. A producer exposes tunnels (registers
|
||||
openable channels via `ChannelCore::register_openable`); a consumer
|
||||
opens tunnel channels (`ChannelClient`). Both sides can be both
|
||||
simultaneously — connection direction (who opened it) is independent
|
||||
simultaneously; connection direction (who opened it) is independent
|
||||
of tunnel direction (who dials the target, who serves it). Avoid
|
||||
"server" and "client" framing in docs and API names; use "producer"
|
||||
and "consumer," or "accept side" / "connect side" for the
|
||||
connection-establishment half specifically. See alkcall ADR-022,
|
||||
ADR-037.
|
||||
|
||||
7. **Substrate-agnostic by construction** — the protocol layer must not
|
||||
7. **Substrate-agnostic by construction**: the protocol layer must not
|
||||
know whether the bytes come from TCP, UDP, a Unix socket, or a
|
||||
stdio pipe. The handler sees a `Connection`/`BiStream` from alkcall
|
||||
core and pumps bytes; the substrate-specific types (`TcpStream`,
|
||||
`UdpSocket`, etc.) are confined to backend modules behind feature
|
||||
flags, injected at the assembly layer — the same inversion-point
|
||||
flags, injected at the assembly layer, the same inversion-point
|
||||
pattern as alktty's `TtyBackend`. "Arbitrary tunnels" means the
|
||||
bookkeeping (target addressing, direction, lifecycle) must not
|
||||
hardcode a substrate. See alknet ADR-078 §Where the pattern lives
|
||||
and the channels-adapter spec.
|
||||
|
||||
8. **Two-pump shutdown-on-completion is a contract** — a tunnel is the
|
||||
8. **Two-pump shutdown-on-completion is a contract**: a tunnel is the
|
||||
canonical two-pump handler (one pump per direction). Each pump MUST
|
||||
shut down the opposite sink when it completes; `tokio::try_join!`
|
||||
alone deadlocks. This was POC-validated in the alknet-channels POC
|
||||
(Target 3), pinned as alknet ADR-078, and the helper is pinned
|
||||
upstream as alkcall `channels::pump_bidi` (ADR-050, review 007
|
||||
R-03) — use it; do not hand-roll the two-pump shape. The channels
|
||||
layer drops all per-channel senders on transport EOF — rely on
|
||||
R-03), so use it; do not hand-roll the two-pump shape. The channels
|
||||
layer drops all per-channel senders on transport EOF; rely on
|
||||
that for teardown, and emit EOF sentinels (zero-length chunks) on
|
||||
clean sink shutdown. Await `pump_bidi` inline inside the
|
||||
`OpenHandler`'s task — the returned `JoinHandle` must track the
|
||||
`OpenHandler`'s task, because the returned `JoinHandle` must track the
|
||||
data-plane lifetime (R-02; early return = teardown-at-birth).
|
||||
|
||||
9. **No forced local binding** — a tunnel must not require the producer
|
||||
9. **No forced local binding**: a tunnel must not require the producer
|
||||
(or consumer) to bind a local port. The POC's TCP-tunnel shape
|
||||
dialed a target from the handler; SSH `-R` style flows need the
|
||||
reverse (the remote side asks the local side to dial or listen).
|
||||
@@ -150,7 +150,7 @@ just spawned implementation agents.
|
||||
requirement for the architecture docs, not just an implementation
|
||||
preference.
|
||||
|
||||
10. **Backpressure and limits are inherited, not redefined** — bounded
|
||||
10. **Backpressure and limits are inherited, not redefined**: bounded
|
||||
per-channel buffers, the 256-channel per-connection cap, monotonic
|
||||
channel IDs, and the zero-length-sentinel EOF convention are
|
||||
alkcall channels invariants (see alkcall ADR-040/041/034). The
|
||||
@@ -160,12 +160,12 @@ just spawned implementation agents.
|
||||
backpressure; datagram-boundary preservation, if required, is a
|
||||
wire-format decision for an ADR, not an accident.
|
||||
|
||||
11. **Vendored core types come from alkcall** — `Connection`,
|
||||
11. **Vendored core types come from alkcall**: `Connection`,
|
||||
`ProtocolHandler`, `BiStream`, `BidiStreamSource`, `AuthContext`,
|
||||
`Identity`, `IdentityProvider`, `AccessControl`,
|
||||
`OwnershipProvider`, `HandlerError`, `StreamError` come from
|
||||
`alkcall::core`. Do not vendor copies into this crate. alkcall is
|
||||
v0.10.x — breaking changes are expected at this major-zero stage;
|
||||
v0.10.x, where breaking changes are expected at this major-zero stage;
|
||||
this is an early consumer, so we find and fix issues upstream
|
||||
rather than working around them. Pin `alkcall = "0.10.0"` and bump
|
||||
deliberately. The 0.6.0 establishment surface (ADR-049 + amendment
|
||||
@@ -173,50 +173,50 @@ just spawned implementation agents.
|
||||
`register_openable_with_establisher` so a refused target dial is
|
||||
a typed `channel:open_failed` call error, never a phantom channel,
|
||||
and the establisher returns the dialed handle via
|
||||
`Establishment::new(plan)` (typed-opaque `ChannelPlan` — its
|
||||
`Establishment::new(plan)` (typed-opaque `ChannelPlan`, whose
|
||||
`Send + Sync` bound constrains plan payloads; socket handles carry
|
||||
`+ Sync`) — no side-channel handoff. The 0.7.0 identity surface
|
||||
`+ Sync`); no side-channel handoff. The 0.7.0 identity surface
|
||||
(ledger CF-005/CF-006) is load-bearing for the serving side: a
|
||||
connect-side serving op resolves the caller identity in
|
||||
precedence order — payload `auth_token` >
|
||||
precedence order (payload `auth_token` >
|
||||
`ServingConfig.identity` > transport identity (set via
|
||||
`Connection::set_identity` before dialing) — and the establisher/
|
||||
`Connection::set_identity` before dialing), and the establisher/
|
||||
pump handler receive the per-call opener identity, not the
|
||||
install-time context.
|
||||
|
||||
12. **BAST document for the wire format** — when the tunnel wire format
|
||||
12. **BAST document for the wire format**: when the tunnel wire format
|
||||
gains binary framing (if any beyond pass-through), it carries a
|
||||
BAST (Binary Abstract Syntax Tree) document as its machine-readable
|
||||
spec under `docs/architecture/`, conforming to the BAST meta-schema
|
||||
at `https://alk.dev/bast/v1/schema`. BAST is plain JSON — no
|
||||
at `https://alk.dev/bast/v1/schema`. BAST is plain JSON; no
|
||||
dependency required to author or consume it. alktunnels does not
|
||||
depend on alktype; hand-rolled codecs are fine for trivial formats,
|
||||
with the BAST doc as the contract. Do not roll your own offset map
|
||||
or validator for complex formats — use alktype (as an optional dep)
|
||||
or validator for complex formats: use alktype (as an optional dep)
|
||||
when that surfaces.
|
||||
|
||||
13. **Access control** — the producer's openable channels get
|
||||
13. **Access control**: the producer's openable channels get
|
||||
authorization for free via `ChannelCore::register_openable`, which
|
||||
wires `AccessControl` into the operation spec (the registry runs
|
||||
the ACL before the open handler). Scope-gate tunnel opens (e.g.
|
||||
`TUNNEL_OPEN_SCOPE`, shape following alktty's `TTY_OPEN_SCOPE`) and
|
||||
optionally consult `OwnershipProvider` for resource ownership
|
||||
(`provider.owns(id_ref, kind, &id, "tunnel")` — the 4-arg shape;
|
||||
(`provider.owns(id_ref, kind, &id, "tunnel")`, the 4-arg shape;
|
||||
`OwnershipStore::record` is the 3-arg shape). Tunnels reach local
|
||||
networks — treat the open gate as the security boundary. See
|
||||
networks; treat the open gate as the security boundary. See
|
||||
alknet ADR-024 (registry layering, alkcall ADR-019), alknet
|
||||
ADR-050 (ownership, alkcall ADR-011).
|
||||
|
||||
14. **Feature flags** — substrate backends may be feature-gated if the
|
||||
14. **Feature flags**: substrate backends may be feature-gated if the
|
||||
need arises. The base crate should compile lean (no socket/platform
|
||||
deps unless the feature is on). Verify both `cargo test` (default)
|
||||
and `cargo test --all-features` pass if features are added.
|
||||
|
||||
15. **Naming** — Rust standard: `snake_case` for functions/variables/
|
||||
15. **Naming**: Rust standard, `snake_case` for functions/variables/
|
||||
modules, `PascalCase` for types/traits, `SCREAMING_SNAKE_CASE` for
|
||||
constants.
|
||||
|
||||
16. **Module structure** — one module per file under `src/`, re-exported
|
||||
16. **Module structure**: one module per file under `src/`, re-exported
|
||||
from `src/lib.rs`. Public API surface is `lib.rs` re-exports. The
|
||||
shape is pinned by the architecture spec (ADR-004, ADR-005): a
|
||||
producer half (adapter / open-handler for `alk/tunnel`-family
|
||||
@@ -225,12 +225,28 @@ just spawned implementation agents.
|
||||
modules are feature-gated and never imported from the
|
||||
shared/producer/consumer modules.
|
||||
|
||||
17. **ALPN naming** — this crate owns the `alk/tunnel`-family ALPN(s).
|
||||
17. **ALPN naming**: this crate owns the `alk/tunnel`-family ALPN(s).
|
||||
One ALPN per protocol per alkcall ADR-004; keep the `alk/` prefix
|
||||
convention. If multiple tunnel flavors need distinct ALPNs (e.g.
|
||||
stream vs datagram), decide via ADR before the first consumer —
|
||||
stream vs datagram), decide via ADR before the first consumer;
|
||||
ALPN strings are wire-stable once published.
|
||||
|
||||
18. **No em dashes in prose.** Do not use the em dash (U+2014) in
|
||||
source doc comments, markdown docs (including `docs/` and `tasks/`),
|
||||
agent prompts under `.opencode/agents/`, or commit messages.
|
||||
Restructure instead: split the sentence, use parentheses, commas, a
|
||||
colon, or a semicolon, whichever reads naturally, and vary the
|
||||
choice rather than converting every instance the same way. A spaced
|
||||
hyphen (` - `) is not an acceptable substitute; it reads as a
|
||||
different but equally recognizable writing tell. Inside fenced code
|
||||
blocks the rule applies to prose-y content only: line comments,
|
||||
ASCII-diagram label separators, and template placeholders are
|
||||
rewritten too, while wire data, JSON/BAST examples, and tool
|
||||
transcripts survive verbatim. The same rule covers en dashes used as
|
||||
clause separators, though en dashes inside numeric ranges keep their
|
||||
normal meaning. Hyphens in compound words (`wasm-clean`,
|
||||
`tty-wire`, `alk/tunnel`) are untouched.
|
||||
|
||||
## Verification Commands
|
||||
|
||||
Run these before committing. All must pass.
|
||||
@@ -247,70 +263,70 @@ cargo publish --dry-run --allow-dirty # before a release
|
||||
```
|
||||
|
||||
The wasm check is the structural guard for the "default crate is
|
||||
wasm-clean" invariant (same as alktty) — run it whenever a
|
||||
wasm-clean" invariant (same as alktty), and run it whenever a
|
||||
non-backend module changes.
|
||||
|
||||
## Architecture Context
|
||||
|
||||
- `docs/architecture/` — the authoritative spec (Phase 1 of the SDD
|
||||
- `docs/architecture/` holds the authoritative spec (Phase 1 of the SDD
|
||||
process, complete): overview/producer/consumer/wire spec docs, ADRs
|
||||
001..006 (all Accepted), the wire-format BAST document, and
|
||||
`open-questions.md` (the authoritative OQ tracker). Read it before
|
||||
non-trivial changes. Phase 0 research remains in `docs/research/`
|
||||
(its OQ ledger is superseded by the tracker).
|
||||
- The prior art for this crate:
|
||||
- **alknet-channels POC** —
|
||||
- **alknet-channels POC**, with its summary at
|
||||
`/workspace/@alkdev/alknet/docs/research/alknet-channels/poc-summary.md`
|
||||
(§POC Target 3: tunnel handler). Validates the demux→Connection→
|
||||
handler→mux path and the two-pump tunnel shape over a stand-in
|
||||
transport. POC code at `/workspace/alknet-channels-poc/`.
|
||||
- **alkcall** — `/workspace/@alkdev/alkcall` (v0.10.x, crates.io).
|
||||
- **alkcall** (`/workspace/@alkdev/alkcall`, v0.10.x, crates.io).
|
||||
The substrate: call protocol + channels multiplexing. The tunnel
|
||||
crate consumes `alkcall::core` types and the channels
|
||||
`ChannelCore`/`ChannelClient`/`register_openable` surface.
|
||||
- **alktty** — `/workspace/@alkdev/alktty` (v0.1, crates.io). The
|
||||
- **alktty**, found at `/workspace/@alkdev/alktty` (v0.1, crates.io). The
|
||||
sibling pattern to follow: a producer/consumer protocol crate on
|
||||
alkcall channels, with the backend inversion point, wasm-clean
|
||||
default crate, and feature-gated local backend.
|
||||
- Key upstream ADRs that inform this crate's design (alknet numbers;
|
||||
alkcall's ports live in `/workspace/@alkdev/alkcall/docs/architecture/
|
||||
decisions/`):
|
||||
- alknet ADR-093 / alkcall ADR-035 — channels pure channel
|
||||
- alknet ADR-093 / alkcall ADR-035: channels pure channel
|
||||
multiplexing (8-byte header, no `stream_type`); the tunnel payload
|
||||
is raw bytes inside the `BiStream`
|
||||
- alknet ADR-078 / alkcall ADR-050 — two-pump shutdown-on-completion
|
||||
- alknet ADR-078 / alkcall ADR-050: two-pump shutdown-on-completion
|
||||
(the tunnel handler pattern); the helper is pinned upstream as
|
||||
`alkcall::channels::pump_bidi` — use it, do not hand-roll
|
||||
- alkcall ADR-049 (amendment 2) — the establishment phase; the
|
||||
`alkcall::channels::pump_bidi` (use it, do not hand-roll)
|
||||
- alkcall ADR-049 (amendment 2): the establishment phase; the
|
||||
establisher returns the dialed handle via `Establishment::new`
|
||||
(typed-opaque `ChannelPlan`), replacing the POC's side-channel
|
||||
handoff
|
||||
- alknet ADR-074 / alkcall ADR-038 — `ChannelConnection` as a
|
||||
- alknet ADR-074 / alkcall ADR-038: `ChannelConnection` as a
|
||||
`BidiStreamSource`; every handler (TTY, tunnel, call) receives a
|
||||
`Connection`
|
||||
- alknet ADR-075 / alkcall ADR-039 — `ChannelsAdapter` /
|
||||
- alknet ADR-075 / alkcall ADR-039: `ChannelsAdapter` /
|
||||
`ChannelManager` (substrate-agnostic demux; `params` is
|
||||
ALPN-specific — for tunnels, the target resource)
|
||||
- alknet ADR-071 / alkcall ADR-034 — channels wire format (one-way
|
||||
ALPN-specific, i.e. for tunnels the target resource)
|
||||
- alknet ADR-071 / alkcall ADR-034: channels wire format (one-way
|
||||
door)
|
||||
- alkcall ADR-037 — channel lifecycle operations (`channel/open` on
|
||||
- alkcall ADR-037: channel lifecycle operations (`channel/open` on
|
||||
channel 0; `params` carries the tunnel target)
|
||||
- alkcall ADR-042 — hub relay (byte-for-byte data-channel forwarding
|
||||
with ID rewrite — tunnels traverse relays transparently)
|
||||
- alkcall ledger CF-005/CF-006 (0.7.0) — the connect-side serving
|
||||
- alkcall ADR-042: hub relay (byte-for-byte data-channel forwarding
|
||||
with ID rewrite, so tunnels traverse relays transparently)
|
||||
- alkcall ledger CF-005/CF-006 (0.7.0), the connect-side serving
|
||||
identity seam: caller identity precedence (token >
|
||||
`ServingConfig.identity` > transport), per-call opener identity
|
||||
on the open-op hooks (the reverse-flow POC's W1, resolved)
|
||||
- alknet ADR-085 — workspace scope: `alknet/tunnel` was flagged
|
||||
- alknet ADR-085, workspace scope: `alknet/tunnel` was flagged
|
||||
"POC-validated, minimal spec needed, not yet specced"; this crate
|
||||
is that spec
|
||||
- What the POCs settled (Phase 0 complete — 2026-09-07): both POCs
|
||||
- What the POCs settled (Phase 0 complete as of 2026-09-07): both POCs
|
||||
ran clean (forward UDP POC + reverse-flow POC; see
|
||||
`docs/research/poc-summary.md` and `reverse-poc-summary.md`). Phase 1
|
||||
is complete (2026-09-07): the OQ ledger is promoted, and the params,
|
||||
ALPN, codec, and API-surface decisions are pinned by ADR-001..006
|
||||
(including consumer-session teardown ownership — reverse POC W3).
|
||||
(including consumer-session teardown ownership from reverse POC W3).
|
||||
Phase 2 (implementation) is decomposed in `tasks/`.
|
||||
- If a TODO references a design direction that an ADR has since decided
|
||||
against, the TODO is stale — remove it and align with the ADR. Do not
|
||||
against, the TODO is stale; remove it and align with the ADR. Do not
|
||||
implement the rejected design.
|
||||
+60
-60
@@ -8,13 +8,13 @@ this crate adheres to [Semantic Versioning](https://semver.org/).
|
||||
|
||||
### Added
|
||||
|
||||
- **The forwarded-op establisher variant (`register_tunnel_forwarded_openable_with_establisher` + `ForwardedGate` + `ForwardedPlan`, ADR-008 Amendment 2)** — the serving side of `channels/tunnel/forwarded` gains an establishment phase: the assembly injects a correlation gate (`ForwardedGate` — a sync closure over the parsed `TunnelForwardedParams`) and the crate wraps it into the alkcall `OpenEstablisher` dance, so a stale/missing `listen` token fails the open as a typed `channel:open_failed` with `details.reason == "unknown_resource"` BEFORE any channel is born (no phantom channel — ADR-049's posture extended to the forwarded op's serving side; the wire vocabulary is unextended). An establisher parse failure is likewise typed (`handler_error`) — an improvement over the base shape's success-then-silent-birth-teardown for malformed ingress. Gate order pinned: the registry runs the `tunnel:forwarded` ACL before the establisher (pinned by test with a gate-invocation witness). The base `register_tunnel_forwarded_openable` stays byte-for-byte compatible as the compatibility + residual-race-fallback posture (the narrow gate-pass→handler-run `correlation-miss` race keeps teardown-at-birth); the establisher variant is the recommended shape and new assemblies take it by default. The wire delta is empty. Five gate tests pin the typed miss/no-phantom/no-handler, the unchanged gate-pass serving flow (full byte round-trip), the residual-race reap, the scope-gate ordering, and the malformed-params delta.
|
||||
- **The forwarded-op establisher variant (`register_tunnel_forwarded_openable_with_establisher` + `ForwardedGate` + `ForwardedPlan`, ADR-008 Amendment 2)**: the serving side of `channels/tunnel/forwarded` gains an establishment phase: the assembly injects a correlation gate (`ForwardedGate`, a sync closure over the parsed `TunnelForwardedParams`) and the crate wraps it into the alkcall `OpenEstablisher` dance, so a stale/missing `listen` token fails the open as a typed `channel:open_failed` with `details.reason == "unknown_resource"` BEFORE any channel is born (no phantom channel, extending ADR-049's posture to the forwarded op's serving side; the wire vocabulary is unextended). An establisher parse failure is likewise typed (`handler_error`), an improvement over the base shape's success-then-silent-birth-teardown for malformed ingress. Gate order pinned: the registry runs the `tunnel:forwarded` ACL before the establisher (pinned by test with a gate-invocation witness). The base `register_tunnel_forwarded_openable` stays byte-for-byte compatible as the compatibility + residual-race-fallback posture (the narrow gate-pass→handler-run `correlation-miss` race keeps teardown-at-birth); the establisher variant is the recommended shape and new assemblies take it by default. The wire delta is empty. Five gate tests pin the typed miss/no-phantom/no-handler, the unchanged gate-pass serving flow (full byte round-trip), the residual-race reap, the scope-gate ordering, and the malformed-params delta.
|
||||
|
||||
## [0.4.1] - 2026-10-22
|
||||
|
||||
The dependency-patch release: the alkcall substrate bumped 0.10.0 →
|
||||
0.10.1 (the adoption-window ordering fix). No alktunnels API change,
|
||||
no wire change — the only file-level contacts with the bump are
|
||||
no wire change; the only file-level contacts with the bump are
|
||||
`Cargo.toml`/`Cargo.lock`.
|
||||
|
||||
### Changed
|
||||
@@ -32,7 +32,7 @@ no wire change — the only file-level contacts with the bump are
|
||||
channels through the same adopt path), though no change in this
|
||||
crate's own surfaces was needed. No API, wire-format
|
||||
(`EventEnvelope`, the 8-byte chunk header), or documented-cap
|
||||
behavior change upstream — the fix surface is private; the behavior
|
||||
behavior change upstream: the fix surface is private; the behavior
|
||||
only strengthens. 136 tests pass; wasm-clean verified (check +
|
||||
clippy on `wasm32-unknown-unknown`).
|
||||
|
||||
@@ -40,7 +40,7 @@ no wire change — the only file-level contacts with the bump are
|
||||
|
||||
The dependency-major release: the alkcall substrate bumped 0.9.0 →
|
||||
0.10.0 (the gateway ownership-seam release). No alktunnels API
|
||||
change, no wire change; zero source changes — the only file-level
|
||||
change, no wire change, and zero source changes; the only file-level
|
||||
contacts with the bump are `Cargo.toml`/`Cargo.lock`. Minor rather
|
||||
than patch for the 0.3.0 reason: the semver-incompatible dependency
|
||||
bump plus this crate's public re-exports of alkcall types
|
||||
@@ -52,11 +52,11 @@ the producer API) make it semver-visible to downstream consumers.
|
||||
- **alkcall 0.9.0 → 0.10.0** (crates.io). The gateway
|
||||
ownership-seam release (review 012): `GatewayDispatch` gains
|
||||
`with_ownership_provider` / `with_static_ownership_fallback`, and
|
||||
the previously-silent static fallback becomes loud — an unwired
|
||||
the previously-silent static fallback becomes loud: an unwired
|
||||
spine refuses (an error envelope, `FORBIDDEN`, pre-dispatch) an op
|
||||
whose spec carries `access_control.resource_type`. None of it is on
|
||||
this crate's paths: alktunnels does not use the `gateway` feature
|
||||
or `GatewayDispatch` — tunnel opens serve via
|
||||
or `GatewayDispatch`; tunnel opens serve via
|
||||
`ChannelCore::register_openable`/`register_openable_with_establisher`
|
||||
registry dispatch on the native wire, which 0.10.0 leaves
|
||||
untouched. All of this crate's alkcall contact points
|
||||
@@ -72,7 +72,7 @@ the producer API) make it semver-visible to downstream consumers.
|
||||
The dependency-major release: the alkcall substrate bumped 0.8.1 →
|
||||
0.9.0 (the stub-sweep release). No alktunnels API change, no wire
|
||||
change; the only code contact with the 0.9.0 breaking surface was
|
||||
test-side (`Establishment` no longer derives `Debug` — three
|
||||
test-side (`Establishment` no longer derives `Debug`; three
|
||||
`expect_err`/`panic!` sites restructured), which is why this is a
|
||||
minor bump rather than a patch: the semver-incompatible dependency
|
||||
bump plus this crate's public re-exports of alkcall types make it
|
||||
@@ -90,7 +90,7 @@ ride on 0.2.0).
|
||||
enumerator use, no generic `op/register` announcements), so no
|
||||
behavior change here. Of the 0.9.0 breaking list,
|
||||
`Establishment` dropping its `Debug` derive was the only surface
|
||||
this crate touched — two `bindfirst_establisher` unit tests'
|
||||
this crate touched: two `bindfirst_establisher` unit tests'
|
||||
`expect_err` calls and one integration test's untyped
|
||||
`panic!("{other:?}")` fall-through restructured (the error type
|
||||
itself remains `Debug`; the `Ok` arm now formats via `panic!("got
|
||||
@@ -108,7 +108,7 @@ alkcall substrate bumped 0.8.0 → 0.8.1. No API break, no wire change.
|
||||
|
||||
### Added
|
||||
|
||||
- **Fuzzing (`fuzz/` workspace, `docs/research/fuzzing.md`)** — three
|
||||
- **Fuzzing (`fuzz/` workspace, `docs/research/fuzzing.md`)**: three
|
||||
cargo-fuzz targets over this crate's own attacker-reachable surfaces
|
||||
(the envelope/chunk/manager layer is upstream and fuzzed there per
|
||||
alkcall 0.8.1): `datagram_codec` (the ADR-003 codec: no-panic,
|
||||
@@ -120,7 +120,7 @@ alkcall substrate bumped 0.8.0 → 0.8.1. No API break, no wire change.
|
||||
exec, the `TunnelSession` data planes mirrored, no-wedge +
|
||||
shutdown-on-completion + lossless p2c + EOF-sentinel teardown + the
|
||||
`pending` backlog bound asserted). Invariant logic lives in
|
||||
`fuzz/shared/` — a stable-toolchain crate replayed as plain
|
||||
`fuzz/shared/`: a stable-toolchain crate replayed as plain
|
||||
`cargo test`, so the committed corpora (96 + 8 seeds, plus the
|
||||
campaign-promoted `seed-900`) stay executable without nightly.
|
||||
`fuzz/` is excluded from the workspace and from the published
|
||||
@@ -128,12 +128,12 @@ alkcall substrate bumped 0.8.0 → 0.8.1. No API break, no wire change.
|
||||
waves 1–2, zero crashes/hangs/OOMs; all findings were
|
||||
harness-model defects, no crate bugs.
|
||||
|
||||
- **`TunnelSession::datagram_backlog()`** — the UDP `pending` count
|
||||
- **`TunnelSession::datagram_backlog()`**: the UDP `pending` count
|
||||
(the datagrams decoded from a chunk beyond the one returned), the
|
||||
wave-2 harness's asserted backlog bound made caller-visible;
|
||||
`None` for stream sessions or after the halves were taken.
|
||||
|
||||
- **`DatagramReader::retained_bytes()`** — the decoder-state byte
|
||||
- **`DatagramReader::retained_bytes()`**: the decoder-state byte
|
||||
count (leftover buffer + the in-flight partial datagram); post-feed
|
||||
bounded by one frame (≤ `MAX_DATAGRAM_LEN` + 2), pinned by the
|
||||
`datagram_codec` target.
|
||||
@@ -143,7 +143,7 @@ alkcall substrate bumped 0.8.0 → 0.8.1. No API break, no wire change.
|
||||
- **alkcall 0.8.0 → 0.8.1** (crates.io). The bug-fix release: a
|
||||
duplicate `adopt_channel`/`open_channel` no longer destroys the
|
||||
live channel it collided with (found by alkcall's own
|
||||
`manager_routing` fuzz target) — directly relevant to this crate's
|
||||
`manager_routing` fuzz target), directly relevant to this crate's
|
||||
reverse-path adopt flow. No alktunnels API or wire change in this
|
||||
bump; the full suite passes unchanged.
|
||||
|
||||
@@ -155,98 +155,98 @@ alksocks (the SOCKS5 layer) and the planned `alknodes` assembly.
|
||||
|
||||
### Added
|
||||
|
||||
- **`TunnelListener` — the listen-flow consumer session (ADR-008 §4)** —
|
||||
- **`TunnelListener`: the listen-flow consumer session (ADR-008 §4)**,
|
||||
the listen-flow sibling of `TunnelSession`: `TunnelListener::open`
|
||||
(rides `ChannelClient::open_channel_with_reply`, alkcall 0.8.0's
|
||||
U-2 surface — the full reply arrives and the bind-first `bound`/
|
||||
U-2 surface: the full reply arrives and the bind-first `bound`/
|
||||
`listen` fields project through the lenient
|
||||
`listen_reply_fields`; adopt-free construction — the client already
|
||||
adopted the channel), `bound()`/`listen()` (the Option posture —
|
||||
`listen_reply_fields`; adopt-free construction, since the client already
|
||||
adopted the channel), `bound()`/`listen()` (the Option posture:
|
||||
`None` for dial/accept-first producers and old producers), and the
|
||||
listen channel's teardown ownership (ADR-005's posture extended to
|
||||
the listen flow): `close` (the graceful EOF sentinel via
|
||||
`shutdown`, then the reap) and `Drop` (the held write half drops →
|
||||
the mux pump's implicit-EOF chunk → the far accept dispatcher's
|
||||
teardown signal; sync reap — never leaks). The held `BiStream`'s
|
||||
teardown signal; sync reap, so nothing leaks). The held `BiStream`'s
|
||||
write half stays IDLE and private (a listen channel has no
|
||||
data-plane role; pumping one is meaningless). No `Clone`
|
||||
(compile-asserted). Forwarded sessions are NOT owned by the
|
||||
listener — they arrive as ordinary adopted-side `TunnelSession`s on
|
||||
listener; they arrive as ordinary adopted-side `TunnelSession`s on
|
||||
the opener's manager (`adopt` gains nothing, pinned by test); the
|
||||
assembly correlates them via the `listen()` token (the receipt
|
||||
queue is assembly-side bookkeeping).
|
||||
|
||||
- **The `local` feature's bind-first substrate half (ADR-008 §1,
|
||||
shape 3)** — the real-socket sources behind the committed
|
||||
shape 3)**, the real-socket sources behind the committed
|
||||
`BindFn`/`BindFirstAcceptLoop` contracts (behind `local`; the
|
||||
default crate stays wasm-clean): `bind_tcp_first` (port 0 resolved
|
||||
via `local_addr` — the `bound` reply field's data) +
|
||||
`TcpListenerHalves::accept_pair` (one accept → `(peer, halves)` —
|
||||
via `local_addr`, the `bound` reply field's data) +
|
||||
`TcpListenerHalves::accept_pair` (one accept → `(peer, halves)`;
|
||||
the peer rides `accept()`'s return, the forwarded op's `peer`
|
||||
carrier); unix bind-first parity shipped (OQ-TN-14): `bind_unix` +
|
||||
`UnixListenerHalves` (`local_addr`, the queue-feeding `accept_loop`,
|
||||
the bind-first `accept_pair` — an unnamed (autobind) peer fails
|
||||
the bind-first `accept_pair`, where an unnamed (autobind) peer fails
|
||||
loud: the forwarded `peer` param requires an address) +
|
||||
`bind_unix_first`; the assembly conveniences `local_bind_first()`
|
||||
(tcp/unix dispatch; **udp fails loud — a UDP socket has no accept,
|
||||
(tcp/unix dispatch; **udp fails loud: a UDP socket has no accept,
|
||||
the bind-first shape is not defined there, never a fake listener**)
|
||||
and `local_bindfirst_accept_loop()` (downcasts the plan's opaque
|
||||
listener to the module's wrappers; a foreign listener type fails
|
||||
loud as `handler_error`). The accept-FIRST shapes (`accept_loop` +
|
||||
`AcceptQueue`) are untouched. The direct-op `DialFn` reuse is
|
||||
verified + pinned: `local_dial` is shared unchanged (the UDP framed
|
||||
wrap happens inside the closure — review 001 U-1); a
|
||||
wrap happens inside the closure, review 001 U-1); a
|
||||
`TunnelSession::open_direct` + `Substrate::Udp` test drives a REAL
|
||||
UDP echo socket through the direct op's establisher path.
|
||||
|
||||
- **The forwarded op's SERVING side + the lenient reply projection
|
||||
(ADR-008 §3 as amended)** — the listen-opener half, the receipt
|
||||
(ADR-008 §3 as amended)**, the listen-opener half, the receipt
|
||||
counterpart to the bind-first producer (the opener SERVES the op;
|
||||
the producer CALLS it per accept): `ForwardedHandler` (the injected
|
||||
assembly handler — the parsed params + the channel `BiStream`; the
|
||||
assembly handler taking the parsed params + the channel `BiStream`; the
|
||||
assembly owns the pump, since the local halves are assembly state)
|
||||
+ `make_forwarded_op_handler` (the built-in wrapper: deny-unknown
|
||||
params parse (a malformed open fails LOUD → the wrapper returns
|
||||
early → teardown-at-birth reaps the channel — no phantom, the
|
||||
early → teardown-at-birth reaps the channel; no phantom, the
|
||||
correct shape for a malformed ingress), `accept_bi` FIRST (R-02
|
||||
accepted-flag telemetry), then wrapper→assembly bridge, the
|
||||
injected handler's `JoinHandle` awaited inline); +
|
||||
`register_tunnel_forwarded_openable` (`register_openable` — no
|
||||
`register_tunnel_forwarded_openable` (`register_openable`, so no
|
||||
establisher, the data plane starts immediately; scope gate
|
||||
`tunnel:forwarded`, ingress — a separate grant from
|
||||
`tunnel:forwarded`, ingress; a separate grant from
|
||||
`tunnel:open`/`tunnel:direct`, never implied by either). Plus
|
||||
`listen_reply_fields` (the lenient, infallible session-surface
|
||||
projection of the bind-first reply — unknown extra reply fields
|
||||
projection of the bind-first reply: unknown extra reply fields
|
||||
tolerated, a malformed shape logs loud and surfaces both-`None`;
|
||||
shared with `TunnelListener`). Correlation-miss teardown semantics
|
||||
documented + test-pinned: the assembly holds the map, its error
|
||||
path drops the stream, the channel reaps (the producer's accept
|
||||
loop drops the accepted connection on the open's failure — the
|
||||
loop drops the accepted connection on the open's failure, and the
|
||||
shapes mirror).
|
||||
|
||||
- **The bind-first establisher + accept dispatcher (shape 3, ADR-008
|
||||
as amended)** — the `-R`/ingress establisher whose establishment
|
||||
as amended)**, the `-R`/ingress establisher whose establishment
|
||||
phase BINDS (the address-first-vs-accept-first deadlock dissolves;
|
||||
the accept wait lives in the teardown-governed pump phase):
|
||||
`BindFn` (the substrate bind function — the platform boundary, the
|
||||
`BindFn` (the substrate bind function, the platform boundary; the
|
||||
protocol never binds) + `BoundListener` (the opaque listener + the
|
||||
real OS-chosen bound address); `bindfirst_establisher` (registry
|
||||
namespace gate → token mint → bind → `bound` + `listen` reply
|
||||
fields via `Establishment::with_reply_field` → the `BindFirstPlan`
|
||||
plan payload; the token mint is the pinned discipline — non-zero-
|
||||
plan payload; the token mint is the pinned discipline: non-zero-
|
||||
seeded `AtomicU32`, per-open fresh, per-producer unique, token 0
|
||||
never minted); `BindFirstAcceptLoop` (the type-erased accept
|
||||
closure — receives the opaque listener, yields `(peer, halves)` per
|
||||
accept); `ForwardedOpener` (the per-accept forwarded-open caller —
|
||||
closure; it receives the opaque listener, yields `(peer, halves)` per
|
||||
accept); `ForwardedOpener` (the per-accept forwarded-open caller,
|
||||
the call plane: `call_with_payload` on channel 0 +
|
||||
`manager.adopt_channel`, the reverse POC's shape);
|
||||
`make_bindfirst_accept_handler` (the dispatcher: `accept_bi` first
|
||||
per R-02, the idle listen-stream EOF cancels the loop and tears the
|
||||
listener down, per-accept `pump_bidi` tasks tracked by the handler's
|
||||
`JoinHandle` — a forwarded pump never outlives its listener;
|
||||
forwarded-open failures are per-accept isolated — log + drop, the
|
||||
`JoinHandle`, meaning a forwarded pump never outlives its listener;
|
||||
forwarded-open failures are per-accept isolated: log + drop, the
|
||||
loop continues, v1 no retry); `register_tunnel_bindfirst_openable`
|
||||
(same spec + `tunnel:open` scope gate as the dial/listen variants —
|
||||
(same spec + `tunnel:open` scope gate as the dial/listen variants,
|
||||
the per-registration establisher posture; the injected seams ride
|
||||
`BindFirstInjections {bind, accept_loop, open_forwarded}` + the
|
||||
optional CF-006 identity witness). The opener-side forwarded-op
|
||||
@@ -254,36 +254,36 @@ alksocks (the SOCKS5 layer) and the planned `alknodes` assembly.
|
||||
`tunnels/forwarded-receipt`.
|
||||
|
||||
- **The forwarded op + the bind-first params surface (ADR-008 as
|
||||
amended)** — the wire-stable types pin first (the ordering
|
||||
amended)**: the wire-stable types pin first (the ordering
|
||||
`tunnels/params` used for the base op): `OP_TUNNEL_FORWARDED` /
|
||||
`TUNNEL_FORWARDED_SCOPE` constants; `TunnelForwardedParams
|
||||
{listen, substrate, peer}` (deny-unknown; `listen` is the
|
||||
producer-minted correlation token, positive by wire contract —
|
||||
schema `minimum: 1` + a serde-level pin) with a per-substrate
|
||||
producer-minted correlation token, positive by wire contract,
|
||||
i.e. schema `minimum: 1` + a serde-level pin) with a per-substrate
|
||||
`peer` sub-schema (malformed peers fail the registry's schema gate
|
||||
before the opener-side handler); `tunnel_forwarded_spec()`
|
||||
(`Sub`-typed, External, `alk/tunnel` ALPN marker, no establisher —
|
||||
(`Sub`-typed, External, `alk/tunnel` ALPN marker, no establisher, so
|
||||
the forwarded data plane starts immediately); `TunnelListenReply` +
|
||||
`parse_listen_reply` (the typed projection of the bind-first
|
||||
listen open's additive `bound`/`listen` reply fields — unknown
|
||||
listen open's additive `bound`/`listen` reply fields: unknown
|
||||
reply fields tolerated, malformed ones fail loud via the new
|
||||
`TunnelError::MalformedReply`); `BindFirstPlan` (the opaque
|
||||
assembly-typed listener + the token — the plan payload the
|
||||
assembly-typed listener + the token, the plan payload the
|
||||
bind-first establisher/accept-dispatcher task consumes).
|
||||
|
||||
- **The direct op (`channels/tunnel/direct`, ADR-007)** — dynamic-
|
||||
- **The direct op (`channels/tunnel/direct`, ADR-007)**, dynamic-
|
||||
target egress (the ssh `direct-tcpip` / SOCKS5 CONNECT shape),
|
||||
riding the same `alk/tunnel` ALPN: `SubstrateAddr` (the untagged
|
||||
substrate-shaped address object, also the forwarded op's `peer`
|
||||
shape) + `TunnelDirectParams {substrate, target}` with a
|
||||
per-substrate `input_schema` (malformed targets fail the registry's
|
||||
schema gate — `INVALID_INPUT` — before the establisher),
|
||||
schema gate as `INVALID_INPUT` before the establisher),
|
||||
`tunnel_direct_spec()`, `direct_establisher` (+ witness variant;
|
||||
no registry lookup — `unknown_resource` can never fire; targets
|
||||
no registry lookup, so `unknown_resource` can never fire; targets
|
||||
render to the same backing-string form the registry backings use
|
||||
and ride the SAME injected `DialFn`), `register_tunnel_direct_
|
||||
openable` (same pump handler + plan-flow as the base op), and
|
||||
`TunnelSession::open_direct` (separate constructor — `tunnel:direct`
|
||||
`TunnelSession::open_direct` (separate constructor; `tunnel:direct`
|
||||
is a separate grant from `tunnel:open`, never implied by it; the
|
||||
session type, data planes, and teardown API are identical). An old
|
||||
producer rejects the unknown op loudly (`NOT_FOUND`).
|
||||
@@ -293,20 +293,20 @@ alksocks (the SOCKS5 layer) and the planned `alknodes` assembly.
|
||||
- **alkcall 0.7.1 → 0.8.0** (crates.io). The two upstream asks the
|
||||
graduation ADRs depend on landed (alkcall review 008): the
|
||||
establisher → reply projection (`Establishment::with_reply_field`,
|
||||
ADR-049 amendment 3 — the bind-first `bound` carrier) and
|
||||
flavor-form open-op ids in discovery (ADR-047 amendment 3 —
|
||||
ADR-049 amendment 3, the bind-first `bound` carrier) and
|
||||
flavor-form open-op ids in discovery (ADR-047 amendment 3:
|
||||
`channels/tunnel/direct`-shaped ops reconstruct their
|
||||
`channel_open` marker through discovery, so hub relay wraps them as
|
||||
channels, not plain forwarding stubs). Plus the bonus:
|
||||
`ChannelRelay` + `HubLegTemplate` (ADR-051) — the hub re-produce
|
||||
`ChannelRelay` + `HubLegTemplate` (ADR-051), the hub re-produce
|
||||
machinery the graduation's re-produce story assumed would be
|
||||
assembly-layer is now in-tree, with `bound` flowing end-to-end
|
||||
assembly-layer, is now in-tree, with `bound` flowing end-to-end
|
||||
per-hop-truthful (ADR-051 §6, ADR-008 §6's sentence realized).
|
||||
No alktunnels API or wire change in this bump; 71/91 tests pass,
|
||||
wasm-clean verified.
|
||||
|
||||
- **`BindFirstPlan` gains `substrate: Substrate` (review 002 M-1
|
||||
remediation)** — the accept dispatcher's silent
|
||||
remediation)**; the accept dispatcher's silent
|
||||
`unwrap_or(Substrate::Tcp)` fallback (the second
|
||||
`TunnelParams` re-parse of wire input) is removed: the establisher
|
||||
stamps the parsed substrate into the plan (one parse, one source
|
||||
@@ -333,8 +333,8 @@ complete (Phase 2, review 001 findings remediated).
|
||||
- **Data-plane codec** (ADR-003): raw pass-through for stream
|
||||
substrates (`tcp`, `unix`); mandatory `[len: u16 BE]` boundary-
|
||||
preserving framing for `udp` (`len = 0` a legal empty datagram,
|
||||
never EOF; oversize rejected at frame time; truncation fails loud
|
||||
— OQ-TN-13). The BAST contract: `docs/architecture/bast.md`.
|
||||
never EOF; oversize rejected at frame time; truncation fails loud per
|
||||
OQ-TN-13). The BAST contract: `docs/architecture/bast.md`.
|
||||
- **Producer half** (ADR-004): `ResourceRegistry`,
|
||||
`tunnel_establisher` (dial shape) and `listen_establisher` (the
|
||||
`-R` far-side accept shape) over alkcall's establishment surface
|
||||
@@ -342,7 +342,7 @@ complete (Phase 2, review 001 findings remediated).
|
||||
contract, alkcall ADR-050), and the
|
||||
`register_tunnel_openable` / `register_tunnel_listen_openable`
|
||||
registration helpers.
|
||||
- **Consumer half** (ADR-005): `TunnelSession` — `open` (forward) /
|
||||
- **Consumer half** (ADR-005): `TunnelSession` with `open` (forward) /
|
||||
`adopt` (reverse), substrate-shaped data planes (`stream_halves`,
|
||||
`take_halves`, `send_datagram`/`recv_datagram`), pump ownership
|
||||
(`pump_against`), teardown ownership (`close`/`join`/`Drop`);
|
||||
@@ -350,9 +350,9 @@ complete (Phase 2, review 001 findings remediated).
|
||||
- **`local` feature** (non-wasm): `dial_tcp`, `bind_tcp` +
|
||||
`TcpListenerHalves` accept loop, `connect_udp` wrapped in the
|
||||
FRAMED `UdpHalf` adapter (the codec composes at the substrate
|
||||
boundary — review 001 U-1), `dial_unix`, and the all-substrate
|
||||
boundary, per review 001 U-1), `dial_unix`, and the all-substrate
|
||||
`local_dial` closure. Stdio intentionally out of scope (alktty's
|
||||
pipe mode owns process stdio — OQ-TN-14).
|
||||
pipe mode owns process stdio; OQ-TN-14).
|
||||
- **Identity seam** (alkcall 0.7.0 CF-005/CF-006): payload-token >
|
||||
`ServingConfig.identity` > transport identity precedence proven
|
||||
end-to-end; the per-call opener identity reaches the establisher.
|
||||
|
||||
@@ -4,8 +4,8 @@ Arbitrary bidirectional tunnels over [alkcall](https://crates.io/crates/alkcall)
|
||||
channels: TCP, UDP, and unix-socket tunnels in the `ssh -L` / `ssh -R`
|
||||
sense (and the `-D`/SOCKS flavor via composition), without SSH. A
|
||||
producer/consumer protocol crate riding alkcall channels the same way
|
||||
[alktty](https://crates.io/crates/alktty) does — no networking, no
|
||||
transport dependencies; the transport (QUIC, websocket, stdio, ...)
|
||||
[alktty](https://crates.io/crates/alktty) does. It ships no networking
|
||||
and no transport dependencies; the transport (QUIC, websocket, stdio, ...)
|
||||
is whatever alkcall runs over.
|
||||
|
||||
Downstream crates compose on top of it: wire your substrate's sockets
|
||||
@@ -20,7 +20,7 @@ bind-first establisher + the
|
||||
|
||||
## Quick start
|
||||
|
||||
### Producer — register produced resources and serve the open op
|
||||
### Producer: register produced resources and serve the open op
|
||||
|
||||
```rust
|
||||
use std::sync::Arc;
|
||||
@@ -32,7 +32,7 @@ use alktunnels::params::Substrate;
|
||||
use alktunnels::producer::{register_tunnel_openable, ResourceRegistry};
|
||||
|
||||
// The producer's registry: (resource, substrate) → backing. The
|
||||
// backing string is whatever your dial closure understands — a
|
||||
// backing string is whatever your dial closure understands: a
|
||||
// `host:port`, a socket path, an in-process id. It never rides the
|
||||
// wire; the consumer only ever names the resource.
|
||||
let registry = ResourceRegistry::new();
|
||||
@@ -52,7 +52,7 @@ let core = ChannelCore::new(manager, default_policy());
|
||||
// registry's ACL (scope `tunnel:open`) gates the open op, the
|
||||
// establisher dials the backing, and `pump_bidi` bridges the channel
|
||||
// to the dialed halves. Wire `core` + `op_registry` into your
|
||||
// channels serving stack (the alkcall recipe) — this call is the
|
||||
// channels serving stack (the alkcall recipe); this call is the
|
||||
// tunnel-specific step:
|
||||
register_tunnel_openable(
|
||||
&core,
|
||||
@@ -64,7 +64,7 @@ register_tunnel_openable(
|
||||
)?;
|
||||
```
|
||||
|
||||
### Producer — listen shape (the `-R` far side accepts)
|
||||
### Producer: listen shape (the `-R` far side accepts)
|
||||
|
||||
The same open op with a listen establisher: instead of dialing a
|
||||
target, the establisher pops an already-accepted local connection.
|
||||
@@ -82,7 +82,7 @@ let accept: AcceptFn = /* pops from the queue (or your own source) */;
|
||||
register_tunnel_listen_openable(&core, ®istry, &op_registry, auth, accept)?;
|
||||
```
|
||||
|
||||
### Producer — bind-first listen shape (`-R` with address + peer fidelity)
|
||||
### Producer: bind-first listen shape (`-R` with address + peer fidelity)
|
||||
|
||||
The same open op with a bind-first establisher: establishment BINDS
|
||||
(the reply carries the real OS-chosen `bound` address + a correlation
|
||||
@@ -114,7 +114,7 @@ register_tunnel_forwarded_openable(
|
||||
)?;
|
||||
```
|
||||
|
||||
### Consumer — open a tunnel channel (the `-L` path)
|
||||
### Consumer: open a tunnel channel (the `-L` path)
|
||||
|
||||
```rust
|
||||
use alkcall::channels::client::ChannelClient;
|
||||
@@ -131,13 +131,13 @@ let mut session = TunnelSession::open(
|
||||
TunnelParams { resource: "postgres-primary".into(), substrate: Substrate::Tcp },
|
||||
).await?;
|
||||
|
||||
// Stream substrates (tcp/unix): raw pass-through — the halves ARE
|
||||
// Stream substrates (tcp/unix): raw pass-through; the halves ARE
|
||||
// the tunnel, zero tunnel-level framing.
|
||||
let (read, write) = session.stream_halves().expect("stream session");
|
||||
// ... use like any tokio duplex half (or take_halves() for owned) ...
|
||||
|
||||
// Datagram substrate (udp): boundary-preserving, framed
|
||||
// (`[len: u16 BE]`, ADR-003) — an empty datagram is legal and is
|
||||
// (`[len: u16 BE]`, ADR-003); an empty datagram is legal and is
|
||||
// NOT EOF.
|
||||
let payload = session.recv_datagram().await?; // Some(Bytes) | None on EOF
|
||||
|
||||
@@ -146,7 +146,7 @@ let payload = session.recv_datagram().await?; // Some(Bytes) | None on EOF
|
||||
let (client_to_target, target_to_client, reaped) = session.join().await;
|
||||
```
|
||||
|
||||
### Consumer — reverse flow (the `-R` path)
|
||||
### Consumer: reverse flow (the `-R` path)
|
||||
|
||||
The consumer initiates (it may hold the hub's call surface while the
|
||||
producer serves); the producer-allocated channel ID is adopted into a
|
||||
@@ -173,14 +173,14 @@ let session = TunnelSession::adopt(
|
||||
session = session.pump_against(accepted_local_stream).await;
|
||||
```
|
||||
|
||||
### Consumer — dynamic target (the direct op, `ssh -W`/CONNECT shape)
|
||||
### Consumer: dynamic target (the direct op, `ssh -W`/CONNECT shape)
|
||||
|
||||
```rust
|
||||
use alktunnels::params::SubstrateAddr;
|
||||
use alktunnels::TunnelSession;
|
||||
|
||||
// `tunnel:direct` is a SEPARATE grant from `tunnel:open` (never
|
||||
// implied by it) — the caller states which capability it holds. A
|
||||
// implied by it); the caller states which capability it holds. A
|
||||
// malformed target fails the schema gate; an old producer rejects
|
||||
// the unknown op loudly (NOT_FOUND).
|
||||
let mut session = TunnelSession::open_direct(
|
||||
@@ -190,25 +190,25 @@ let mut session = TunnelSession::open_direct(
|
||||
).await?;
|
||||
```
|
||||
|
||||
### Consumer — the listen flow (bind-first reply fields)
|
||||
### Consumer: the listen flow (bind-first reply fields)
|
||||
|
||||
```rust
|
||||
use alktunnels::TunnelListener;
|
||||
|
||||
// The same `channels/tunnel/sub` open op — the establisher shape is
|
||||
// the producer's per-registration choice, invisible here. A bind-first
|
||||
// The same `channels/tunnel/sub` open op: the establisher shape
|
||||
// is the producer's per-registration choice, invisible here. A bind-first
|
||||
// producer's reply carries `bound` (the real OS-chosen address) and a
|
||||
// correlation token; both are `None` for dial/accept-first producers
|
||||
// or old producers (the lenient, additive posture).
|
||||
let listener = TunnelListener::open(&client, params).await?;
|
||||
let bound = listener.bound().cloned(); // Option<SubstrateAddr> — informational, NOT addressing
|
||||
let token = listener.listen(); // Option<u32> — the forwarded op's correlation key
|
||||
let bound = listener.bound().cloned(); // Option<SubstrateAddr>; informational, NOT addressing
|
||||
let token = listener.listen(); // Option<u32>, the forwarded op's correlation key
|
||||
|
||||
// Teardown: `close` sends the EOF sentinel (the far accept dispatcher
|
||||
// tears its listener down), then reaps; `Drop` is the sync best-effort
|
||||
// form. Forwarded sessions arrive as separate adopted `TunnelSession`s
|
||||
// on the manager — the assembly correlates them via the token; the
|
||||
// listener does not own them.
|
||||
// on the manager; the assembly correlates them via the token, and
|
||||
// the listener does not own them.
|
||||
listener.close().await;
|
||||
```
|
||||
|
||||
@@ -219,12 +219,12 @@ both sides of a connection can be either; role follows the resource):
|
||||
|
||||
| Role | Via `alk/channels` |
|
||||
|------|--------------------|
|
||||
| **Producer** (owns the backing: local ports, sockets, in-process services) | [`register_tunnel_openable`](https://docs.rs/alktunnels/latest/alktunnels/producer/fn.register_tunnel_openable.html) / [`register_tunnel_listen_openable`](https://docs.rs/alktunnels/latest/alktunnels/producer/fn.register_tunnel_listen_openable.html) / [`register_tunnel_bindfirst_openable`](https://docs.rs/alktunnels/latest/alktunnels/producer/fn.register_tunnel_bindfirst_openable.html) — the open op (`channels/tunnel/sub`) with a **dial establisher** (target dialing), an **accept-first listen establisher** (pops an already-accepted connection), or a **bind-first establisher** (binds; the reply carries `bound` + token, per-accept forwarded opens carry the peer); the pump handler bridges the channel via [`alkcall::channels::pump_bidi`](https://docs.rs/alkcall/latest/alkcall/channels/pump/fn.pump_bidi.html) |
|
||||
| **Producer** (dynamic-target egress) | [`register_tunnel_direct_openable`](https://docs.rs/alktunnels/latest/alktunnels/producer/fn.register_tunnel_direct_openable.html) — the direct op (`channels/tunnel/direct`, ADR-007) with its own scope (`tunnel:direct`); the target rides the params, no registry lookup |
|
||||
| **Consumer** (wants the bytes) | [`TunnelSession`](https://docs.rs/alktunnels/latest/alktunnels/consumer/struct.TunnelSession.html) — [`open`](https://docs.rs/alktunnels/latest/alktunnels/consumer/struct.TunnelSession.html#method.open) (forward) / [`open_direct`](https://docs.rs/alktunnels/latest/alktunnels/consumer/struct.TunnelSession.html#method.open_direct) (dynamic target) / [`adopt`](https://docs.rs/alktunnels/latest/alktunnels/consumer/struct.TunnelSession.html#method.adopt) (reverse), substrate-shaped data planes, teardown ownership; [`TunnelListener`](https://docs.rs/alktunnels/latest/alktunnels/consumer/struct.TunnelListener.html) — the listen flow with the `bound`/`listen` reply fields |
|
||||
| **Producer** (owns the backing: local ports, sockets, in-process services) | [`register_tunnel_openable`](https://docs.rs/alktunnels/latest/alktunnels/producer/fn.register_tunnel_openable.html) / [`register_tunnel_listen_openable`](https://docs.rs/alktunnels/latest/alktunnels/producer/fn.register_tunnel_listen_openable.html) / [`register_tunnel_bindfirst_openable`](https://docs.rs/alktunnels/latest/alktunnels/producer/fn.register_tunnel_bindfirst_openable.html): the open op (`channels/tunnel/sub`) with a **dial establisher** (target dialing), an **accept-first listen establisher** (pops an already-accepted connection), or a **bind-first establisher** (binds; the reply carries `bound` + token, per-accept forwarded opens carry the peer); the pump handler bridges the channel via [`alkcall::channels::pump_bidi`](https://docs.rs/alkcall/latest/alkcall/channels/pump/fn.pump_bidi.html) |
|
||||
| **Producer** (dynamic-target egress) | [`register_tunnel_direct_openable`](https://docs.rs/alktunnels/latest/alktunnels/producer/fn.register_tunnel_direct_openable.html), registering the direct op (`channels/tunnel/direct`, ADR-007) with its own scope (`tunnel:direct`); the target rides the params, no registry lookup |
|
||||
| **Consumer** (wants the bytes) | [`TunnelSession`](https://docs.rs/alktunnels/latest/alktunnels/consumer/struct.TunnelSession.html), with [`open`](https://docs.rs/alktunnels/latest/alktunnels/consumer/struct.TunnelSession.html#method.open) (forward) / [`open_direct`](https://docs.rs/alktunnels/latest/alktunnels/consumer/struct.TunnelSession.html#method.open_direct) (dynamic target) / [`adopt`](https://docs.rs/alktunnels/latest/alktunnels/consumer/struct.TunnelSession.html#method.adopt) (reverse), substrate-shaped data planes, teardown ownership; [`TunnelListener`](https://docs.rs/alktunnels/latest/alktunnels/consumer/struct.TunnelListener.html), the listen flow with the `bound`/`listen` reply fields |
|
||||
|
||||
One connection hosts many tunnels (one per channel); sessions are
|
||||
independent. Both roles are callable from either connection side —
|
||||
independent. Both roles are callable from either connection side;
|
||||
`-L` and `-R` are the same open op with the entry point on different
|
||||
machines (OQ-TN-03; the reverse POC validated the worker-serves /
|
||||
hub-initiates shape).
|
||||
@@ -241,9 +241,9 @@ address-free:
|
||||
The producer's registry maps the resource to its backing; the consumer
|
||||
never learns an address. Rich addressing (SOCKS5 ATYP, per-datagram
|
||||
remotes) enters only through the `-D`/dynamic composition path,
|
||||
speaking its protocol inside the tunnel payload — never in the wire.
|
||||
speaking its protocol inside the tunnel payload, never in the wire.
|
||||
|
||||
The exception is the direct op (ADR-007) — a *different capability*
|
||||
The exception is the direct op (ADR-007), a *different capability*
|
||||
on its own op + scope, so the ACL separation is structural:
|
||||
|
||||
```json
|
||||
@@ -251,25 +251,25 @@ on its own op + scope, so the ACL separation is structural:
|
||||
```
|
||||
|
||||
Scope-gated separately (`tunnel:direct` ≠ `tunnel:open` ≠
|
||||
`tunnel:forwarded` — never implied by each other); dial-only (no
|
||||
`tunnel:forwarded`, never implied by each other); dial-only (no
|
||||
listen/direct-BIND form); the same `DialFn` the base op uses. The
|
||||
composed `-R` metadata flow rides the forwarded op
|
||||
(`channels/tunnel/forwarded`, `{listen, substrate, peer}` — the peer
|
||||
(`channels/tunnel/forwarded`, `{listen, substrate, peer}`; the peer
|
||||
is producer-asserted metadata, not transport truth).
|
||||
|
||||
### Data plane
|
||||
|
||||
- **Stream substrates (`tcp`, `unix`)**: raw pass-through — the
|
||||
- **Stream substrates (`tcp`, `unix`)**: raw pass-through. The
|
||||
channel's read/write halves are the tunnel; zero tunnel-level
|
||||
framing, zero-length reads are EOF (ADR-003).
|
||||
- **Datagram substrate (`udp`)**: mandatory `[len: u16 BE]`
|
||||
per-datagram framing (boundary preservation; `len = 0` is a legal
|
||||
empty datagram, never EOF — the F-2 layering; >65535 fails at frame
|
||||
empty datagram, never EOF, per the F-2 layering; >65535 fails at frame
|
||||
time). The machine-readable contract is the BAST document at
|
||||
`docs/architecture/bast.md`.
|
||||
- **Sentinels and backpressure** are alkcall channels invariants
|
||||
(zero-length-chunk EOF, bounded buffers, the 256-channel cap) —
|
||||
this crate re-derives none of them.
|
||||
(zero-length-chunk EOF, bounded buffers, the 256-channel cap).
|
||||
This crate re-derives none of them.
|
||||
|
||||
The `local` feature's [`UdpHalf`](https://docs.rs/alktunnels/latest/alktunnels/local/struct.UdpHalf.html)
|
||||
composes the codec at the substrate boundary: the pump-facing side
|
||||
@@ -278,38 +278,38 @@ datagrams. Truncation fails loud, never silent (OQ-TN-13).
|
||||
|
||||
### WASM target
|
||||
|
||||
The default crate (no features) compiles to `wasm32-unknown-unknown`
|
||||
— the protocol layer for a sandboxed TS/Python adapter. The `local`
|
||||
The default crate (no features) compiles to `wasm32-unknown-unknown`,
|
||||
the protocol layer for a sandboxed TS/Python adapter. The `local`
|
||||
feature (real sockets) needs a real OS and is non-wasm by design;
|
||||
enabling it on wasm is a compile error.
|
||||
|
||||
## Version 0.2 — the graduation
|
||||
## Version 0.2: the graduation
|
||||
|
||||
0.2 rides alkcall 0.8.0 and graduates the proxy surface
|
||||
(`docs/research/tunnels-graduation.md`, ADRs 007/008):
|
||||
|
||||
- **The direct op** (`channels/tunnel/direct`, scope `tunnel:direct`)
|
||||
— dynamic-target egress; `TunnelSession::open_direct`.
|
||||
- **The direct op** (`channels/tunnel/direct`, scope `tunnel:direct`),
|
||||
dynamic-target egress; `TunnelSession::open_direct`.
|
||||
- **The bind-first establisher + the forwarded op**
|
||||
(`channels/tunnel/forwarded`, scope `tunnel:forwarded`) — the
|
||||
(`channels/tunnel/forwarded`, scope `tunnel:forwarded`), the
|
||||
composed `-R` metadata flow: `bound` + correlation token in the
|
||||
listen reply, `{listen, substrate, peer}` per accept;
|
||||
`TunnelListener` + `parse_listen_reply`/`listen_reply_fields`.
|
||||
- **alkcall 0.7.1 → 0.8.0** — `Establishment::with_reply_field` +
|
||||
- **alkcall 0.7.1 → 0.8.0** brings `Establishment::with_reply_field` +
|
||||
`open_channel_with_reply` (the bind-first carriers) and
|
||||
flavor-form op ids in discovery (hub relay wraps them as channels);
|
||||
bonus: in-tree `ChannelRelay`/`HubLegTemplate` (alkcall ADR-051).
|
||||
|
||||
0.2.1 is the hardening release: the `fuzz/` workspace (three
|
||||
cargo-fuzz targets over the datagram codec, the params parsers, and a
|
||||
stateful session-pump target; corpora replay as plain `cargo test`) —
|
||||
`docs/research/fuzzing.md` — and the alkcall bump 0.8.0 → 0.8.1
|
||||
(duplicate adopt/open no longer destroys the live channel — relevant
|
||||
stateful session-pump target; corpora replay as plain `cargo test`,
|
||||
documented in `docs/research/fuzzing.md`) and the alkcall bump 0.8.0 → 0.8.1
|
||||
(duplicate adopt/open no longer destroys the live channel, relevant
|
||||
to the reverse-path adopt flow). Two additive diagnostics:
|
||||
`TunnelSession::datagram_backlog()` and
|
||||
`DatagramReader::retained_bytes()`. No API break, no wire change.
|
||||
|
||||
0.3.0 rides the alkcall bump 0.8.1 → 0.9.0 (the stub-sweep release —
|
||||
0.3.0 rides the alkcall bump 0.8.1 → 0.9.0 (the stub-sweep release:
|
||||
the four registered-but-always-failing stubs became real
|
||||
implementations; none were on this crate's paths). No alktunnels API
|
||||
change, no wire change; minor rather than patch because the
|
||||
@@ -317,14 +317,14 @@ semver-incompatible dependency bump + this crate's alkcall type
|
||||
re-exports make it semver-visible downstream.
|
||||
|
||||
0.4.0 rides the alkcall bump 0.9.0 → 0.10.0 (the gateway
|
||||
ownership-seam release — the loud ownership-wiring default on the
|
||||
ownership-seam release; the loud ownership-wiring default on the
|
||||
`GatewayDispatch` spine, behind the opt-in `gateway` feature; none of
|
||||
it is on this crate's paths, which serve via
|
||||
`ChannelCore::register_openable*` dispatch). No alktunnels API
|
||||
change, no wire change; same minor-not-patch reasoning as 0.3.0.
|
||||
|
||||
0.4.1 rides the alkcall bump 0.10.0 → 0.10.1 (the adoption-window
|
||||
ordering fix — a chunk routed inside the `adopt_channel` install→drain
|
||||
ordering fix, where a chunk routed inside the `adopt_channel` install→drain
|
||||
window can no longer overtake earlier-parked early arrivals). No
|
||||
alktunnels API change, no wire change; patch because the dependency
|
||||
bump is backwards-compatible.
|
||||
@@ -333,10 +333,10 @@ bump is backwards-compatible.
|
||||
|
||||
| Feature | Contents | wasm |
|
||||
|---------|----------|------|
|
||||
| *(default)* | params, wire codec, open-op specs (`sub`/`direct`/`forwarded`), establishers (dial + accept-first listen + bind-first), `TunnelSession`, `TunnelListener`, registration helpers — protocol only | yes |
|
||||
| *(default)* | params, wire codec, open-op specs (`sub`/`direct`/`forwarded`), establishers (dial + accept-first listen + bind-first), `TunnelSession`, `TunnelListener`, registration helpers (protocol only) | yes |
|
||||
| `local` | [`dial_tcp`](https://docs.rs/alktunnels/latest/alktunnels/local/fn.dial_tcp.html) / [`bind_tcp`](https://docs.rs/alktunnels/latest/alktunnels/local/fn.bind_tcp.html) / [`connect_udp`](https://docs.rs/alktunnels/latest/alktunnels/local/fn.connect_udp.html) (the framed `UdpHalf`) / [`dial_unix`](https://docs.rs/alktunnels/latest/alktunnels/local/fn.dial_unix.html) / [`local_dial`](https://docs.rs/alktunnels/latest/alktunnels/local/fn.local_dial.html), the TCP accept loop; the bind-first halves: [`bind_tcp_first`](https://docs.rs/alktunnels/latest/alktunnels/local/fn.bind_tcp_first.html) / [`bind_unix_first`](https://docs.rs/alktunnels/latest/alktunnels/local/fn.bind_unix_first.html) + [`local_bind_first`](https://docs.rs/alktunnels/latest/alktunnels/local/fn.local_bind_first.html) + [`local_bindfirst_accept_loop`](https://docs.rs/alktunnels/latest/alktunnels/local/fn.local_bindfirst_accept_loop.html) | no |
|
||||
|
||||
Stdio is intentionally out of scope — a spawned process's stdio is
|
||||
Stdio is intentionally out of scope: a spawned process's stdio is
|
||||
alktty's pipe mode (exit codes + signals are tty semantics); remote
|
||||
command execution composes via alktty (OQ-TN-14).
|
||||
|
||||
|
||||
@@ -108,17 +108,17 @@ establisher + the forwarded op (ADR-008).
|
||||
## Dependencies
|
||||
|
||||
```
|
||||
alktunnels (default — wasm-clean)
|
||||
alktunnels (default: wasm-clean)
|
||||
├── alkcall 0.10.1 (core types, channels: ChannelCore/ChannelClient/manager,
|
||||
│ pump_bidi, ServingConfig identity seam — CF-005/006,
|
||||
│ pump_bidi, the ServingConfig identity seam (CF-005/006),
|
||||
│ establisher reply projection + flavor-form discovery
|
||||
│ derivation + ChannelRelay/HubLegTemplate — review 008/ADR-051)
|
||||
│ derivation + ChannelRelay/HubLegTemplate (review 008/ADR-051))
|
||||
├── tokio (wasm-clean subset: rt, sync, io-util, macros, time)
|
||||
├── bytes, futures, serde/serde_json, thiserror, tracing, async-trait
|
||||
└── (no backend deps — sockets/process live in feature-gated modules)
|
||||
└── (no backend deps: sockets/process live in feature-gated modules)
|
||||
|
||||
alktunnels (local feature) — non-wasm by design
|
||||
└── adds: tokio/net (TCP dial, UDP connect, unix sockets — unix per OQ-TN-14)
|
||||
alktunnels (local feature): non-wasm by design
|
||||
└── adds: tokio/net (TCP dial, UDP connect, unix sockets; unix per OQ-TN-14)
|
||||
```
|
||||
|
||||
Same posture as alktty: the default crate is protocol-only and
|
||||
@@ -138,22 +138,22 @@ though it is trivial here, since the codec is a 2-byte length prefix).
|
||||
|
||||
```
|
||||
src/
|
||||
lib.rs — re-exports; crate docs
|
||||
params.rs — TunnelParams {resource, substrate} + open-op input schema (ADR-001);
|
||||
lib.rs:re-exports; crate docs
|
||||
params.rs:TunnelParams {resource, substrate} + open-op input schema (ADR-001);
|
||||
TunnelTarget {substrate, target} + direct-op schema (ADR-007);
|
||||
TunnelForwardedParams {listen, substrate, peer} (ADR-008 as amended);
|
||||
the three scope constants
|
||||
wire.rs — the data-plane codec: frame_datagram / DatagramReader (ADR-003)
|
||||
producer.rs — the open ops: tunnel_open_spec + direct/forwarded spec builders
|
||||
wire.rs:the data-plane codec: frame_datagram / DatagramReader (ADR-003)
|
||||
producer.rs:the open ops: tunnel_open_spec + direct/forwarded spec builders
|
||||
(scope-gated), establisher shapes (dial + listen + bind-first),
|
||||
pump handler (pump_bidi / accept dispatcher),
|
||||
register_tunnel_*_openable (ADR-004/007/008 shapes)
|
||||
consumer.rs — TunnelSession: open / open_direct / adopt / stream_halves /
|
||||
consumer.rs:TunnelSession: open / open_direct / adopt / stream_halves /
|
||||
take_halves / send_datagram / recv_datagram / pump_against /
|
||||
close / join / Drop (ADR-005); TunnelListener: open / bound /
|
||||
forwarded-receipt / close / Drop (ADR-008)
|
||||
error.rs — TunnelError + the typed open-error surface (ADR-049 §4)
|
||||
local/ — the `local` feature module: real socket halves functions
|
||||
error.rs:TunnelError + the typed open-error surface (ADR-049 §4)
|
||||
local/:the `local` feature module: real socket halves functions
|
||||
```
|
||||
|
||||
Public API surface is `lib.rs` re-exports (AGENTS.md convention 16).
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# Plan — resolve review 003: the correlation-miss arrival class
|
||||
# Plan: resolve review 003 (the correlation-miss arrival class)
|
||||
|
||||
Status: W1 landed (2026-11-03) — ADR-008 Amendment 2, the
|
||||
Status: W1 landed (2026-11-03), covering ADR-008 Amendment 2, the
|
||||
`…_with_establisher` surface, the gate tests, and the review/checkpoint
|
||||
+ doc sync done. W2 (alkssh) / W3 (alksocks) are open: sibling-repo
|
||||
sessions, unticked until those repos land. Resolution of
|
||||
`docs/reviews/003-correlation-miss-arrival-class-alkssh-input.md`
|
||||
(also kept: it records the tri-vocabulary decision's ground),
|
||||
including the verification caveats found in this session (see §Verified
|
||||
findings — they extend the review, do not contradict it).
|
||||
findings, which extend the review and do not contradict it).
|
||||
|
||||
## Verified findings (the ground the plan stands on)
|
||||
|
||||
@@ -28,17 +28,17 @@ the resolution scope:
|
||||
the producer-side pump EOFs with no log at all. The generic arm
|
||||
(`src/producer.rs:838-854`) only fires for the
|
||||
connection-gone/NOT_FOUND/ACL-refused subclass.
|
||||
- All three are the phantom-channel shape — the ADR-049 posture
|
||||
- All three are the phantom-channel shape, i.e. the ADR-049 posture
|
||||
exception ("a refused open is a typed call error, never a phantom
|
||||
channel") that the forwarded op's serving side currently rides.
|
||||
|
||||
## Decisions (settled this session, pending ADR ink)
|
||||
|
||||
1. **Route the miss through an establisher** — the serving side gains
|
||||
1. **Route the miss through an establisher**: the serving side gains
|
||||
an establisher variant so a no-route token returns a typed
|
||||
`channel:open_failed` before the channel is born. The residual
|
||||
post-open decline (a token missing between establisher pass and
|
||||
handler run — a narrow race) keeps teardown-at-birth as the
|
||||
handler run, a narrow race) keeps teardown-at-birth as the
|
||||
fallback shape.
|
||||
2. **Reuse `unknown_resource`** as the typed reason for a stale/missing
|
||||
token. The waiting session *is* a resource that is gone; the wire
|
||||
@@ -46,8 +46,8 @@ the resolution scope:
|
||||
already pins it. No wire-vocabulary extension, no reason-class ADR
|
||||
(framing option 2's "new class" question is moot).
|
||||
3. **Adopt `correlation-miss` as the canonical event name** for the
|
||||
residual post-open decline class (alkssh's shipped vocabulary wins —
|
||||
it is published); alksocks' prose variant retires. Level posture:
|
||||
residual post-open decline class (alkssh's shipped vocabulary wins,
|
||||
as it is published); alksocks' prose variant retires. Level posture:
|
||||
assembly-side declines at `debug`; the producer-side generic arm
|
||||
stays `warn` (it now carries a typed reason instead of a prose one).
|
||||
4. **Scope of the change is additive-only** in this crate: a new
|
||||
@@ -59,7 +59,7 @@ the resolution scope:
|
||||
|
||||
## Work items
|
||||
|
||||
### W1 — alktunnels: ADR + additive API (this repo; ~1 session)
|
||||
### W1 (alktunnels): ADR + additive API (this repo; ~1 session)
|
||||
|
||||
- ADR-008 amendment (or a small successor ADR): the forwarded op's
|
||||
serving side gains an establishment phase; misses are typed failures
|
||||
@@ -79,11 +79,11 @@ the resolution scope:
|
||||
`unknown_resource`, no channel allocated; gate-pass → existing
|
||||
serving flow unchanged; the residual race shape (gate passes, route
|
||||
gone at handler time) still reaps cleanly; scope gate unchanged
|
||||
(`tunnel:forwarded`, runs before the establisher — pin the order).
|
||||
(`tunnel:forwarded`, runs before the establisher; pin the order).
|
||||
- Update the in-code docs that describe the miss as assembly-side-only
|
||||
(`make_forwarded_op_handler` doc, `Register…forwarded` doc).
|
||||
|
||||
### W2 — alkssh: adopt the establisher (dedicated session; alkssh repo)
|
||||
### W2 (alkssh): adopt the establisher (dedicated session; alkssh repo)
|
||||
|
||||
- Move `pump_forwarded_arrival`'s route lookup into the new gate
|
||||
(the `ListenRouter` lookup becomes the establisher's check); the
|
||||
@@ -91,13 +91,13 @@ the resolution scope:
|
||||
the narrow post-open race only.
|
||||
- Verify the fabric-side silence fix: a stale-token arrival now fails
|
||||
the open (alkssh-side typed error; producer-side generic arm fires
|
||||
with a parseable reason) — the race window no longer produces a
|
||||
with a parseable reason), so the race window no longer produces a
|
||||
zero-signal pump.
|
||||
- Fold alkssh's em-dash doc cleanup into its release for this change
|
||||
("cleanup rides the code-update release" — the user-owned task, out
|
||||
("cleanup rides the code-update release"; the user-owned task, out
|
||||
of this plan).
|
||||
|
||||
### W3 — alksocks: adopt the establisher (dedicated session; alksocks repo)
|
||||
### W3 (alksocks): adopt the establisher (dedicated session; alksocks repo)
|
||||
|
||||
- Move `CorrelationRegistry` lookup into the new gate; retire the
|
||||
prose warn (the miss becomes a typed failure at the open; residual
|
||||
@@ -133,10 +133,10 @@ and is otherwise out of scope here.
|
||||
- `docs/architecture/decisions/008-listen-metadata-forwarded-op.md` §3
|
||||
(the serving shape this amends); alkcall ADR-049 (the establisher
|
||||
posture being extended); `docs/architecture/wire.md` §The Open Ops
|
||||
(the pinned `details.reason` vocabulary — unextended by this plan)
|
||||
(the pinned `details.reason` vocabulary, unextended by this plan)
|
||||
- `src/producer.rs` (the generic arm; the forwarded-op serving half);
|
||||
alkssh `src/tunnel_map.rs` (`pump_forwarded_arrival`,
|
||||
`ListenRouter`); alksocks `src/composed/mod.rs`
|
||||
(`make_forwarded_handler`, `CorrelationRegistry`)
|
||||
- alkssh `docs/plans/implementation.md` ("Still booked with their
|
||||
owners" — the booking this filing unblocks)
|
||||
owners" (the booking this filing unblocks)
|
||||
+109
-100
@@ -1,28 +1,28 @@
|
||||
# Fuzzing alktunnels: Proposal and Plan
|
||||
|
||||
**Status:** adopted — wave-1 complete (`datagram_codec` +
|
||||
**Status:** adopted; wave-1 complete (`datagram_codec` +
|
||||
`params_parse` implemented, seeds committed, two 10-min campaigns
|
||||
clean, two harness-model findings fixed — see §7); wave-2
|
||||
(`session_pumps`) implemented and campaigning — see §3.3/§7.
|
||||
Campaigns run via the detached runner (§6) — **never as a foreground
|
||||
clean, two harness-model findings fixed; see §7); wave-2
|
||||
(`session_pumps`) implemented and campaigning (see §3.3/§7).
|
||||
Campaigns run via the detached runner (§6), **never as a foreground
|
||||
child of an agent session** (§5).
|
||||
**Date:** 2026-09-28
|
||||
**Upstream precedent:** `/workspace/@alkdev/alkcall/docs/research/fuzzing.md`
|
||||
(0.8.0 — 0.8.1 campaign; one real finding: duplicate adopt/open destroying
|
||||
(0.8.0 to 0.8.1 campaign; one real finding: duplicate adopt/open destroying
|
||||
the live channel, fixed in 0.8.1). Tooling, corpus policy, runner, and the
|
||||
no-hosted-CI posture are inherited from that doc wholesale — this doc does
|
||||
no-hosted-CI posture are inherited from that doc wholesale: this doc does
|
||||
not re-litigate them; it scopes them to this crate.
|
||||
|
||||
---
|
||||
|
||||
## 1. Should this crate be fuzzed?
|
||||
|
||||
**Yes — but scoped smaller than alkcall's.** The decision follows from
|
||||
**Yes, but scoped smaller than alkcall's.** The decision follows from
|
||||
three lines, all pointing the same way:
|
||||
|
||||
1. **alkcall's fuzz doc already assigned this to us.** Its §5
|
||||
"non-surfaces (deliberately)" note ends: *the channels layer carries
|
||||
opaque `Bytes` and the handler owns sub-stream framing (ADR-035) —
|
||||
opaque `Bytes` and the handler owns sub-stream framing (ADR-035);
|
||||
those downstream parsers are downstream crates' fuzz targets.* Our
|
||||
`wire.rs` datagram codec IS that named downstream target. Fuzzing it
|
||||
completes alkcall's fuzz scope rather than duplicating it.
|
||||
@@ -30,11 +30,11 @@ three lines, all pointing the same way:
|
||||
2. **We own one hand-rolled parse surface of exactly the covered class.**
|
||||
`DatagramReader::feed` (`src/wire.rs:100`) is an incremental
|
||||
length-prefix state machine reassembling `[len: u16 BE][payload]`
|
||||
frames across arbitrary chunk boundaries — length arithmetic and
|
||||
frames across arbitrary chunk boundaries: length arithmetic and
|
||||
boundary handling, the dominant rust-fuzz trophy-case classes. It is
|
||||
sync, pure, allocation-light, and infallible **by contract** (the
|
||||
decode direction cannot fail; oversize is rejected at frame time) —
|
||||
a fuzz target is how that contract is *demonstrated* rather than
|
||||
decode direction cannot fail; oversize is rejected at frame time),
|
||||
and a fuzz target is how that contract is *demonstrated* rather than
|
||||
believed.
|
||||
|
||||
3. **The stateful DoS class applies to us directly.** One pre-fuzz
|
||||
@@ -43,19 +43,19 @@ three lines, all pointing the same way:
|
||||
(`src/consumer.rs:78`) queues every datagram decoded from a chunk
|
||||
beyond the one returned to the caller. A single 16 MiB chunk of
|
||||
`[00 00]` pairs = ~8.4 M empty datagrams → ~8.4 M `VecDeque`
|
||||
entries (≈ 400+ MiB) from ONE peer chunk — a ~25–50× memory
|
||||
entries (≈ 400+ MiB) from ONE peer chunk, a ~25–50× memory
|
||||
amplification riding a bounded channel buffer. Example-based tests
|
||||
structurally cannot find this; a byte-sequence target with a
|
||||
pending-bytes assertion will.
|
||||
|
||||
**The honest caveat** (mirroring alkcall §1): the highest-value alkcall
|
||||
targets — envelope framing, chunk header, demux, manager — are upstream
|
||||
targets (envelope framing, chunk header, demux, manager) are upstream
|
||||
and now fuzzed (0.8.1). Our params parsing is serde + registry-side
|
||||
jsonschema (upstream's `spec_parse` target covers attacker-shaped
|
||||
schemas compiling at registration). Expected yield here: datagram-codec
|
||||
pathologies, the pending-queue bound (§1.3), serde/untagged-enum edge
|
||||
cases in params, and lifecycle invariants in the two-pump shape. That
|
||||
is worthwhile, not five-targets-worth of new machinery on day one —
|
||||
is worthwhile, not five-targets-worth of new machinery on day one,
|
||||
hence the two-wave scope in §4. If the first campaigns find nothing,
|
||||
that is the successful-negative-result posture alkcall's §1 codified:
|
||||
"we think the codec is robust" becomes a demonstrated property, and the
|
||||
@@ -69,7 +69,7 @@ nightly confined to the `fuzz/` subtree via `fuzz/rust-toolchain.toml`,
|
||||
the crate itself stays stable at MSRV 1.88. AFL optional/deferred;
|
||||
bolero deferred (same async-friction argument); libafl/honggfuzz/OSS-Fuzz
|
||||
not adopted. Sanitizer: `--sanitizer=address` with debug assertions
|
||||
(cargo-fuzz defaults) — no MSAN (no `unsafe` in this crate).
|
||||
(cargo-fuzz defaults); no MSAN (no `unsafe` in this crate).
|
||||
|
||||
**Layout (the alkcall pattern, load-bearing):**
|
||||
|
||||
@@ -81,7 +81,7 @@ fuzz/
|
||||
│ ├── datagram_codec.rs
|
||||
│ ├── params_parse.rs
|
||||
│ └── session_pumps.rs
|
||||
├── shared/ alktunnels-fuzz-shared — STABLE-toolchain library
|
||||
├── shared/ alktunnels-fuzz-shared, the STABLE-toolchain library
|
||||
│ └── src/… invariant logic + corpus replay (§3)
|
||||
├── corpus/<target>/ committed hand-made seeds (§3)
|
||||
├── gen_fuzz_seeds.py deterministic seed generator (quiche pattern)
|
||||
@@ -90,16 +90,16 @@ fuzz/
|
||||
```
|
||||
|
||||
- **Invariant logic lives in `fuzz/shared/`**, not inline in the target
|
||||
binaries — the stable-toolchain corpus replay
|
||||
binaries: the stable-toolchain corpus replay
|
||||
(`cargo test --manifest-path fuzz/shared/Cargo.toml`) runs the SAME
|
||||
invariant functions the fuzzer runs, on stable, in the release
|
||||
verification checklist. This is CI tier-3's deliverable (alkcall
|
||||
§7.2) minus automation; per alkcall §7.9 this repo has no hosted CI
|
||||
and will not get one (same gitea posture) — corpus replay as plain
|
||||
and will not get one (same gitea posture); corpus replay as plain
|
||||
`cargo test` is the fuzz gate.
|
||||
- **Root `Cargo.toml` needs the explicit `[workspace]` table**
|
||||
(`members = ["."]`, `exclude = ["fuzz"]`) and `"fuzz/"` on the
|
||||
publish exclude list — without it cargo auto-discovers `fuzz/shared/`
|
||||
publish exclude list; without it cargo auto-discovers `fuzz/shared/`
|
||||
into the main workspace and the MSRV toolchain tries to build the
|
||||
nightly-consumed dev-deps (the exact failure alkcall hit).
|
||||
- **Corpus policy:** commit hand-made seeds, gitignore grown corpora
|
||||
@@ -120,12 +120,12 @@ arbitrary). Drives `DatagramReader::feed` + `frame_datagram`
|
||||
|
||||
- **no-panic on any byte sequence** (the baseline; the one
|
||||
`unreachable!` in `feed` is a structural invariant of the state
|
||||
machine — the target is how it stays true).
|
||||
- **infallibility**: `feed` never errors (contract — oversize is
|
||||
machine; the target is how it stays true).
|
||||
- **infallibility**: `feed` never errors (contract: oversize is
|
||||
rejected at frame time; the decode direction is infallible by
|
||||
design, `src/wire.rs` docs).
|
||||
- **datagram-size bound**: every yielded datagram is
|
||||
`<= MAX_DATAGRAM_LEN` (u16 frame field makes this structural — the
|
||||
`<= MAX_DATAGRAM_LEN` (u16 frame field makes this structural; the
|
||||
target pins it against future refactors).
|
||||
- **no partial yields**: a datagram completes only when exactly
|
||||
`declared` payload bytes are buffered (`is_mid_datagram` observable
|
||||
@@ -144,15 +144,15 @@ arbitrary). Drives `DatagramReader::feed` + `frame_datagram`
|
||||
`MAX_DATAGRAM_LEN`, echoing the sizes.
|
||||
- **pending-queue bound (the §1.3 finding-class assertion)**: total
|
||||
bytes retained across `buf` + `current` + `pending` after a feed is
|
||||
bounded by the fed chunk size plus the one in-flight frame —
|
||||
bounded by the fed chunk size plus the one in-flight frame, so
|
||||
an adversarial peer chunk cannot force unbounded queue growth in
|
||||
the decoder state. (NOTE: this is the *decoder-state* bound; the
|
||||
consumer-side `DatagramHalves.pending` backlog between chunk-read
|
||||
and `recv_datagram` pulls is a session-shape question tracked in
|
||||
§4/§8 — the wave-2 stateful target, or a code fix if the bound is
|
||||
§4/§8 (the wave-2 stateful target, or a code fix if the bound is
|
||||
judged unacceptable. The fuzzer documents the amplification factor;
|
||||
the remediation decision is ours. **Wave-2 resolution (§3.3):** the
|
||||
session-shape backlog is now an asserted invariant — the harness's
|
||||
session-shape backlog is now an asserted invariant: the harness's
|
||||
per-op drain holds the pending queue to the routed-batch remainder
|
||||
(≤ one chunk's worth of batched datagrams, drained by
|
||||
`recv_datagram` pulls), so the amplification question is bounded
|
||||
@@ -162,7 +162,7 @@ arbitrary). Drives `DatagramReader::feed` + `frame_datagram`
|
||||
### 3.2 `params_parse` (wave 1)
|
||||
|
||||
Input: raw bytes → `serde_json` (with alkcall's bounded
|
||||
`arbitrary_value` approach — depth/width-capped `Value` generation) →
|
||||
`arbitrary_value` approach, depth/width-capped `Value` generation) →
|
||||
`TunnelParams` / `TunnelDirectParams` / `TunnelForwardedParams` /
|
||||
`SubstrateAddr` deserialization + `parse_listen_reply` /
|
||||
`listen_reply_fields` (`src/params.rs`). Invariants:
|
||||
@@ -176,7 +176,7 @@ Input: raw bytes → `serde_json` (with alkcall's bounded
|
||||
constructed params of every substrate.
|
||||
- **deny-unknown posture**: unknown top-level fields reject;
|
||||
untagged-variant leniency (extra fields inside a `SubstrateAddr`
|
||||
variant) is the documented asymmetry — asserted, not "fixed".
|
||||
variant) is the documented asymmetry (asserted, not "fixed").
|
||||
- **the `listen: 0` pin**: zero rejects at serde level.
|
||||
- **projection agreement**: `listen_reply_fields` yields both-`Some`
|
||||
iff `parse_listen_reply` is `Ok`; it never panics and never refuses
|
||||
@@ -186,16 +186,16 @@ Input: raw bytes → `serde_json` (with alkcall's bounded
|
||||
|
||||
### 3.3 `session_pumps` (wave 2)
|
||||
|
||||
Input: op sequences (`#[derive(Arbitrary)]` `SessionSequence` — the
|
||||
Input: op sequences (`#[derive(Arbitrary)]` `SessionSequence`, the
|
||||
`manager_routing` pattern) over one live channels stack per exec:
|
||||
MuxRunner → raw chunk stream → the real `run_demux_loop_for_client`
|
||||
demux → `ChannelManager` per side (producer Connect / consumer
|
||||
Accept over one duplex transport — the reverse POC's topology). Ops:
|
||||
Accept over one duplex transport, the reverse POC's topology). Ops:
|
||||
`Route` (producer→consumer framed datagrams / raw stream bytes via
|
||||
`route_payload`), `AdoptDatagram` / `AdoptStream` (the
|
||||
`TunnelSession` data-plane mirrors — codec reader + `pending`, and
|
||||
`TunnelSession` data-plane mirrors: codec reader + `pending`, and
|
||||
`pump_bidi` against in-process peer halves), `Pump` (peer→channel;
|
||||
`len == 0` drops the p2c source — the EOF path), `Teardown`,
|
||||
`len == 0` drops the p2c source, the EOF path), `Teardown`,
|
||||
`ClearAll`. Invariants:
|
||||
|
||||
- **no-panic / no-wedge on any op sequence** (the baseline; the
|
||||
@@ -203,23 +203,24 @@ Accept over one duplex transport — the reverse POC's topology). Ops:
|
||||
and §5's `-timeout=25` fork containment is the outer net).
|
||||
- **two-pump shutdown-on-completion (convention 8)**: after the p2c
|
||||
source drops, the pump's channel-side shutdown must propagate and
|
||||
the pump must complete once the teardown EOFs the c2p source —
|
||||
the pump must complete once the teardown EOFs the c2p source,
|
||||
pinned by the `reconcile` join with the 5 s bound.
|
||||
- **lossless p2c copy**: the pump's `p2c` count equals exactly what
|
||||
the harness pushed into the peer half (copy never truncates).
|
||||
- **EOF-sentinel teardown semantics**: after a sentinel (teardown /
|
||||
`clear_all`), the reassembled stream drains its queue before
|
||||
reporting EOF — every routed datagram consumable, nothing lost;
|
||||
reporting EOF, with every routed datagram consumable and nothing
|
||||
lost;
|
||||
the consumer map empties; the codec's `len == 0` sentinel layering
|
||||
holds at the session layer (a routed empty datagram is data; the
|
||||
channels EOF is a separate mechanism).
|
||||
- **FIFO reconciliation**: every complete frame routed through the
|
||||
real demux path arrives as exactly one datagram of the declared
|
||||
length, in order (the model walks the framing as the codec does —
|
||||
the wave-1 F-2 walk, reused at the session layer).
|
||||
length, in order (the model walks the framing as the codec does,
|
||||
the wave-1 F-2 walk reused at the session layer).
|
||||
- **pending-queue bound (the §1.3 finding-class assertion)**: the
|
||||
session-shape `DatagramHalves.pending` backlog never exceeds the
|
||||
unrouted remainder — i.e. the backlog holds only datagrams decoded
|
||||
unrouted remainder (i.e. the backlog holds only datagrams decoded
|
||||
from routed chunks beyond the ones consumed; with the harness's
|
||||
per-op drain the queue returns to zero after every op, so an
|
||||
amplification factor > 1× chunk size would trip the assert. (The
|
||||
@@ -230,12 +231,12 @@ Accept over one duplex transport — the reverse POC's topology). Ops:
|
||||
|
||||
Bounds: 128 ops, ≤ 512-byte payloads (generator-clamped; the codec's
|
||||
`MAX_DATAGRAM_LEN` stays the wire bound). Even-id masking (ADR-047
|
||||
§5 — the consumer is the Accept side; the producer allocates even).
|
||||
§5: the consumer is the Accept side; the producer allocates even).
|
||||
|
||||
## 4. Scope: two waves
|
||||
|
||||
**Wave 1 (complete):** the two targets above. Both drive already-
|
||||
`pub` APIs — no `#[cfg(fuzzing)]` exposure needed (matching alkcall's
|
||||
`pub` APIs, with no `#[cfg(fuzzing)]` exposure needed (matching alkcall's
|
||||
experience where none of five targets needed it).
|
||||
|
||||
**Wave 2 (this session):** `session_pumps`, the stateful op-sequence
|
||||
@@ -244,7 +245,7 @@ style (`#[derive(Arbitrary)]` op enum; live mux + demux runners; the
|
||||
alkcall harness lessons applied: even-id masking, drained-state
|
||||
asserts). Drives: the `TunnelSession` data-plane shapes (the datagram
|
||||
codec plane + the `pump_bidi` stream plane) over a REAL channels
|
||||
stack — the two-pump shutdown-on-completion contract (AGENTS.md
|
||||
stack: the two-pump shutdown-on-completion contract (AGENTS.md
|
||||
convention 8), the EOF-sentinel teardown semantics, and the §1.3
|
||||
backlog bound as byte-accounting invariants (§3.3). The
|
||||
`forwarded_routing` correlation target stays deferred until the
|
||||
@@ -265,40 +266,44 @@ run is precisely the workload shape that kills its own host:
|
||||
|
||||
- **OOM-killer shared fate**: the target's worst case IS unbounded
|
||||
allocation. If libFuzzer's soft RSS guard loses the race with the
|
||||
kernel, the OOM killer fires — and it picks the largest-RSS process
|
||||
kernel, the OOM killer fires, and it picks the largest-RSS process
|
||||
in the cgroup, which can be the opencode server hosting the session.
|
||||
That is *recoverable* (sessions are recorded in a DB) but hugely
|
||||
derailing — the session's live environment access dies mid-task.
|
||||
derailing, since
|
||||
the session's live environment access dies mid-task.
|
||||
An OOM in a fuzz target must cost the fuzzer, never the session.
|
||||
- **Fork bombs / CPU saturation**: `-fork=N` workers; a runaway
|
||||
campaign starves the session host.
|
||||
- **Wedged targets**: a deadlock in an async harness would hang a
|
||||
foreground bash call forever.
|
||||
|
||||
Three layers of defense, cheapest first — **all mandatory**:
|
||||
Three layers of defense, cheapest first, **all mandatory**:
|
||||
|
||||
1. **libFuzzer soft limits (always on)**: `-rss_limit_mb=2048`
|
||||
`-malloc_limit_mb=2048` make libFuzzer exit cleanly (artifact
|
||||
recorded) before the kernel gets involved. Note the RSS limit is
|
||||
*soft* (polls `/proc/self/statm`); a fast huge allocation can beat
|
||||
it — that is what layers 2–3 are for. Never `ulimit -v` with ASAN
|
||||
it, and that is
|
||||
what layers 2–3 are for. Never `ulimit -v` with ASAN
|
||||
(it reserves terabytes of virtual address space); the ASAN-safe
|
||||
hard-limit recipe is `-fork=1`.
|
||||
2. **Fork mode as blast-radius containment (default)**: `-fork=1`
|
||||
(or a small number) runs each input in a short-lived child — a
|
||||
(or a small number) runs each input in a short-lived child, so a
|
||||
crash/timeout/OOM/leak kills only that child; the harness survives,
|
||||
records the artifact, keeps fuzzing. Also the deadlock answer: the
|
||||
per-input timeout kills the child, not the session's bash call.
|
||||
3. **Process detachment from the session tree (MANDATORY for agent-run
|
||||
campaigns)**: never run fuzzing as a foreground child of the
|
||||
session. The runner script wraps `cargo fuzz run` in `setsid` +
|
||||
`nohup` + full redirection to a log file: (a) removes the fuzzer
|
||||
`nohup` + full redirection to a log file, which (a) removes the
|
||||
fuzzer
|
||||
from the session's controlling terminal and signal relation, (b)
|
||||
survives the session ending, (c) gives the agent a pollable log +
|
||||
artifact directory instead of a blocking call. Combined with fork
|
||||
mode, an OOM-class finding costs one child process.
|
||||
|
||||
**The runner** (`fuzz/run-detached.sh`, CWD-independent — resolves its
|
||||
**The runner** (`fuzz/run-detached.sh`, CWD-independent, since it
|
||||
resolves its
|
||||
own repo root):
|
||||
|
||||
```bash
|
||||
@@ -323,12 +328,12 @@ echo "pid=$! log=$log"
|
||||
```
|
||||
|
||||
**Agent polling protocol** (never `wait` on the detached process from a
|
||||
tool call — poll instead):
|
||||
tool call; poll instead):
|
||||
|
||||
- `tail -n 50 fuzz/artifacts/<target>-*.log` — campaign progress.
|
||||
- `ls fuzz/artifacts/` — `crash-*` / `oom-*` / `timeout-*` / `leak-*`
|
||||
- `tail -n 50 fuzz/artifacts/<target>-*.log`: campaign progress.
|
||||
- `ls fuzz/artifacts/`: `crash-*` / `oom-*` / `timeout-*` / `leak-*`
|
||||
artifacts are the findings.
|
||||
- `pgrep -f "cargo fuzz run <target>"` — still running?
|
||||
- `pgrep -f "cargo fuzz run <target>"`: still running?
|
||||
- One detached campaign per target at a time; log filenames carry the
|
||||
UTC timestamp.
|
||||
|
||||
@@ -338,29 +343,29 @@ the params target (alkcall's `json.dict` transfers as-is).
|
||||
|
||||
## 6. No hosted CI
|
||||
|
||||
Per alkcall §7.9 — the standing policy applies to this repo identically:
|
||||
Per alkcall §7.9, the standing policy applies to this repo identically:
|
||||
no hosted CI will exist; do not add Actions/Gitea-Actions workflow
|
||||
files. **Corpus replay is the fuzz gate as plain `cargo test`**:
|
||||
`cargo test --manifest-path fuzz/shared/Cargo.toml` replays every
|
||||
committed seed through the invariant functions — add it to the release
|
||||
committed seed through the invariant functions; add it to the release
|
||||
verification checklist (AGENTS.md). Campaigns run manually via the
|
||||
detached runner (§6) — e.g. before a release, or after touching
|
||||
detached runner (§6), e.g. before a release, or after touching
|
||||
`src/wire.rs`, the consumer data planes, or `src/params.rs`.
|
||||
|
||||
## 7. Implementation log
|
||||
|
||||
- **2026-09-28 — proposal adopted** (this doc). Wave-1 targets
|
||||
- **2026-09-28, proposal adopted** (this doc). Wave-1 targets
|
||||
`datagram_codec` + `params_parse`; wave-2 deferred (§4).
|
||||
- **2026-09-28 — `alkcall` bumped 0.8.0 → 0.8.1.** Bug-fix release, no
|
||||
- **2026-09-28, `alkcall` bumped 0.8.0 → 0.8.1.** Bug-fix release, no
|
||||
API/wire change; the fix (duplicate adopt/open no longer destroying
|
||||
the live channel) is directly relevant to this crate's
|
||||
`TunnelSession::adopt` path — the changelog's "open-op response
|
||||
`TunnelSession::adopt` path: the changelog's "open-op response
|
||||
replayed / id re-announced" scenario is our reverse path. Verification:
|
||||
full suite (135 passed), clippy, fmt, wasm check (§8 commands).
|
||||
- **2026-09-28 — wave-1 implemented.** `fuzz/` workspace laid out per
|
||||
- **2026-09-28, wave-1 implemented.** `fuzz/` workspace laid out per
|
||||
§2 (nightly-pinned subtree, stable `fuzz/shared` library, runner,
|
||||
dict, generator); `datagram_codec` + `params_parse` targets over
|
||||
already-`pub` APIs — no `#[cfg(fuzzing)]` exposure needed. One new
|
||||
already-`pub` APIs, with no `#[cfg(fuzzing)]` exposure needed. One new
|
||||
public diagnostic: `DatagramReader::retained_bytes()` (the
|
||||
decoder-state byte count the §3.1 bound asserts against, in the
|
||||
`is_mid_datagram` diagnostic style). Seeds committed: 27 +
|
||||
@@ -368,99 +373,103 @@ detached runner (§6) — e.g. before a release, or after touching
|
||||
input promoted to `datagram_codec/seed-900` (see the finding below).
|
||||
Stable side: corpus replay green, shared crate clippy/fmt clean;
|
||||
main suite untouched and green; `cargo fuzz build` clean.
|
||||
- **2026-09-28 — first campaign finding: a HARNESS-model bug, not a
|
||||
- **2026-09-28, first campaign finding: a HARNESS-model bug, not a
|
||||
crate bug.** The `datagram_codec` campaign crashed in its first
|
||||
minute on input `00 2b 01 00 00 05` (`fuzz/artifacts/datagram_codec/
|
||||
crash-e9ba284e…`, exit 77, fork-mode containment worked as
|
||||
designed — the harness parent survived and persisted the artifact).
|
||||
Analysis: the codec is CORRECT — `00 2b` declares a 43-byte
|
||||
designed: the harness parent survived and persisted the artifact).
|
||||
Analysis: the codec is CORRECT, since `00 2b` declares a 43-byte
|
||||
datagram; `01 00 00 05` is its in-flight payload; nothing is
|
||||
yielded. The invariant that tripped ("an empty datagram must come
|
||||
only from a [00 00] length header") was mis-modeled: it counted
|
||||
`00 00` byte pairs anywhere in the RAW stream, but a `00 00` pair
|
||||
sitting INSIDE a frame's payload region is payload, not a length
|
||||
header — exactly the same class as alkcall §7.7's probe (the
|
||||
header, which is exactly the same class as alkcall §7.7's probe (the
|
||||
harness claimed `4 + len` when the prefix counts only the body).
|
||||
Fix: the count now walks the framing as the decoder does (complete
|
||||
frames from the start; the walk stops where the decoder would be
|
||||
stuck mid-frame). The crash input is committed as seed-900 and
|
||||
replays clean; the campaign relaunched clean.
|
||||
- **2026-09-28 — second harness finding (caught by corpus replay,
|
||||
- **2026-09-28, second harness finding (caught by corpus replay,
|
||||
pre-campaign): `establishment_reason` is a pass-through extractor,
|
||||
not a validated table.** The params.rs doc names the pinned reason
|
||||
strings, but the upstream implementation (alkcall
|
||||
`channels/client.rs` `establishment_reason`) extracts
|
||||
`details.reason` AS-IS whenever the code is `channel:open_failed` —
|
||||
`details.reason` AS-IS whenever the code is `channel:open_failed`,
|
||||
so
|
||||
a made-up reason is surfaced as-is by design, and validation is the
|
||||
consumer's job. The harness first asserted a closed set of reason
|
||||
strings and corpus replay tripped it on `made_up_reason` before any
|
||||
campaign ran. Invariant corrected to assert the extraction shape
|
||||
(Some iff code == `channel:open_failed` and reason is a string).
|
||||
- **2026-09-28 — second harness finding (same replay pass):
|
||||
- **2026-09-28, second harness finding (same replay pass):
|
||||
`is_mid_datagram` means a partial DATAGRAM, not buffered header
|
||||
bytes.** A dangling 1-byte length prefix at input end is buffered
|
||||
header state (no frame in flight, `is_mid_datagram == false`);
|
||||
the harness first asserted `true` for that case. Corrected:
|
||||
mid-datagram iff the framing walk leaves a remainder ≥ 2 bytes
|
||||
(a parsed-but-unfinished payload), not `!= 0`.
|
||||
- **2026-09-28 — wave-1 campaigns run (10-min detached runs, §6 runner;
|
||||
- **2026-09-28, wave-1 campaigns run (10-min detached runs, §6 runner;
|
||||
`-fork=1 -rss_limit_mb=2048 -malloc_limit_mb=2048 -timeout=25`;
|
||||
codec with `-max_len=65536`, params with `-dict=json.dict
|
||||
-use_value_profile=1`). Both exited 0.**
|
||||
- `params_parse` — 3.14M execs (~5.3k exec/s), 33 fork jobs,
|
||||
- `params_parse`: 3.14M execs (~5.3k exec/s), 33 fork jobs,
|
||||
`oom/timeout/crash: 0/0/0` throughout. Coverage 2049 edges /
|
||||
15.6k features; in-memory corpus 69 committed seeds → 3018
|
||||
entries — the serde/untagged-enum shape space was still expanding
|
||||
entries; the serde/untagged-enum shape space was still expanding
|
||||
at budget end, so longer campaigns keep paying here.
|
||||
- `datagram_codec` (post-harness-fix) — 827k execs, 33 fork jobs,
|
||||
- `datagram_codec` (post-harness-fix): 827k execs, 33 fork jobs,
|
||||
`oom/timeout/crash: 0/0/0` throughout. Coverage saturated at 221
|
||||
edges (ft 1198) — the 2-byte-header state machine is fully
|
||||
edges (ft 1198); the 2-byte-header state machine is fully
|
||||
enumerated, matching alkcall §7.7's `chunk_header` saturation
|
||||
profile. Exec/s reads low (~400) because each exec replays the
|
||||
input through a ~35-feed 3-byte-sliced sequence alongside the
|
||||
whole-input feed; the state space is small and exhausted early.
|
||||
- The pre-fix `datagram_codec` run (the §7 finding above) crashed at
|
||||
job 4 and exited 77; fork-mode containment behaved as designed —
|
||||
the harness survived, persisted the artifact, and the run wrapped
|
||||
up. That artifact is the promoted seed-900.
|
||||
job 4 and exited 77; fork-mode containment behaved as designed,
|
||||
with the harness surviving, persisting the artifact, and the run
|
||||
wrapping up. That artifact is the promoted seed-900.
|
||||
- Grown corpora (3018 params entries) were **not** merged into the
|
||||
committed seeds — per the §2 policy, grown corpora stay
|
||||
committed seeds, since
|
||||
per the §2 policy, grown corpora stay
|
||||
gitignored.
|
||||
- Operational note for future sessions: `cargo fuzz run` returns
|
||||
before its last fork-mode children fully exit; a `pkill -f
|
||||
"cargo fuzz run <target>"` followed immediately by another
|
||||
`pgrep` of the same pattern can wedge a bash tool call on its own
|
||||
process match — prefer `pgrep -af` to LIST, kill by explicit pid
|
||||
process match. Prefer `pgrep -af` to LIST, kill by explicit pid
|
||||
from the runner's echoed `pid=`, and never pattern-kill-and-recheck
|
||||
in one call.
|
||||
- **NO crate bugs found by wave 1.** The two findings this session
|
||||
were both harness-model defects (§7 above), each invisible to
|
||||
example-based review and caught by fuzzing/replay — the alkcall §7.7
|
||||
example-based review and caught by fuzzing/replay: the alkcall §7.7
|
||||
"fuzzer as a harness-checker" pattern, confirmed. The §1.3
|
||||
decoder-state bound held everywhere: `retained_bytes()` never
|
||||
exceeded fed + one frame on any adversarial input. The session-shape
|
||||
`DatagramHalves.pending` amplification remains a documented
|
||||
§1.3/§4 note (wave-2's motivation), not a campaign finding — the
|
||||
§1.3/§4 note (wave-2's motivation), not a campaign finding, since
|
||||
the
|
||||
wave-1 targets assert the decoder, not the session pump.
|
||||
- **2026-09-28 — wave-2 implemented: `session_pumps` (the stateful
|
||||
- **2026-09-28, wave-2 implemented: `session_pumps` (the stateful
|
||||
two-pump/EOF-sentinel target, §3.3).** The harness mirrors
|
||||
`TunnelSession`'s data-plane shapes over one REAL channels stack
|
||||
per exec (MuxRunner → raw chunk stream → the real
|
||||
`run_demux_loop_for_client` demux → `ChannelManager` per side over
|
||||
one duplex transport; producer Connect / consumer Accept — the
|
||||
one duplex transport; producer Connect / consumer Accept, the
|
||||
reverse POC's topology). Ops: Route/AdoptDatagram/AdoptStream/
|
||||
Pump/Teardown/ClearAll; invariants per §3.3. One diagnostic
|
||||
addition to the crate: `TunnelSession::datagram_backlog()` (the
|
||||
`DatagramHalves.pending` count the §3.3 bound asserts against, in
|
||||
the `retained_bytes()` diagnostic style) + a suite test pinning
|
||||
its rest states. Seeds: 8 committed (deterministic decode-scan
|
||||
hex patterns, the `manager_routing` precedent — arbitrary ships
|
||||
hex patterns, the `manager_routing` precedent, since
|
||||
arbitrary ships
|
||||
no encoder) + 2 in-source semantic fixtures (the §1.3 batched-
|
||||
backlog probe; the W4 half-close shape). Two harness-model
|
||||
findings caught by corpus replay pre-campaign:
|
||||
- **the p2c-EOF pump-join wedge is the DESIGNED half-close
|
||||
shape, not a wedge.** The first `reconcile` draft awaited the
|
||||
pump after dropping only the p2c source — but the c2p pump's
|
||||
pump after dropping only the p2c source, but the c2p pump's
|
||||
source (the channel read half) EOFs only via teardown, so the
|
||||
join legitimately blocked (the 5 s bound tripped). `pump_bidi`
|
||||
completes when BOTH pumps finish; the half-close contract is
|
||||
@@ -468,46 +477,46 @@ detached runner (§6) — e.g. before a release, or after touching
|
||||
observable), and the pump completes once the c2p source EOFs
|
||||
too. The harness now models exactly that: drop p2c source →
|
||||
teardown the entry → join with the 5 s bound. (The alkcall §7.7
|
||||
harness-checker pattern again — the fuzzer harness's first
|
||||
harness-checker pattern again: the fuzzer harness's first
|
||||
campaign-found crash in wave 1 and this replay catch are both
|
||||
harness-model defects, not crate bugs.)
|
||||
- **the stream-plane c2p accounting is asynchronous** — the pump
|
||||
- **the stream-plane c2p accounting is asynchronous**: the pump
|
||||
writes channel→peer into the peer duplex while a far-end reader
|
||||
task counts; the reconcile join (after the pump completes) is
|
||||
when the count is final. First draft read it early. Fixed by
|
||||
reading after the reader task's join.
|
||||
- **2026-09-28 — third harness finding (campaign-found, fork-mode
|
||||
- **2026-09-28, third harness finding (campaign-found, fork-mode
|
||||
containment as designed): duplicate adopt is a LEGAL manager
|
||||
outcome, not a finding.** The first campaign crashed at ~49 s on
|
||||
`AdoptDatagram { channel_id: 0 }` after the id had already been
|
||||
adopted — `ChannelExists` is the manager's documented
|
||||
adopted. `ChannelExists` is the manager's documented
|
||||
id-collision error, and my adopt arms treated every error as a
|
||||
finding. Fix: both adopt arms model the documented error table
|
||||
(`ChannelExists` / `TooManyChannels` are no-ops, exactly the
|
||||
alkcall `manager_routing` harness's treatment). The crash input
|
||||
is committed as seed-900 and replays clean; the campaign
|
||||
relaunched clean.
|
||||
- **2026-09-28 — fourth harness finding (second campaign): the
|
||||
- **2026-09-28, fourth harness finding (second campaign): the
|
||||
harness modeled the producer→consumer path through the WRONG
|
||||
primitive.** The second campaign crashed at ~5 s on `Route`:
|
||||
raw-id addressing (`even_id` masked arbitrary ids; collisions +
|
||||
an `UnknownChannel`-class state the model didn't track), `len ==
|
||||
0` raw routes doubling as EOF sentinels mid-model, and — the
|
||||
structural one — `route_payload` was used as the producer's
|
||||
0` raw routes doubling as EOF sentinels mid-model, and
|
||||
(the structural one) `route_payload` was used as the producer's
|
||||
forward write. `route_payload` routes into the LOCAL manager's
|
||||
channel entry (the demux direction); the producer pump handler
|
||||
writes the channel's SEND half (the mux pump frames onto the
|
||||
transport). Bytes never reached the consumer. Rewrite (the real
|
||||
protocol shape, matching `manager_routing`'s op-vocabulary
|
||||
lesson): producer `Open` (`open_channel` allocates the id, the
|
||||
harness holds the send half — the pump handler's write side),
|
||||
harness holds the send half, the pump handler's write side),
|
||||
session-INDEX addressing (no masking, no collisions), `Adopt`
|
||||
consumes the producer-allocated id (the early-arrival drain is
|
||||
the real adopt-race path), `Route` writes the producer send half
|
||||
(the real wire path: mux pump → transport → consumer demux →
|
||||
reassembly), `Pull` (the `recv_datagram` pull pops the backlog +
|
||||
the model), and a STATEFUL framing model (partial frames persist
|
||||
across ops — the wave-1 walk carried across ops). EOF sentinels
|
||||
across ops, with the wave-1 walk carried across ops). EOF sentinels
|
||||
now arise only from teardown/sender-drop (the real mechanisms),
|
||||
never from `len == 0` routes (F-2: a routed empty datagram is
|
||||
DATA). All committed session_pumps seeds regenerated by the
|
||||
@@ -515,22 +524,22 @@ detached runner (§6) — e.g. before a release, or after touching
|
||||
the semantic fixtures re-pinned. Verification of the fix: the
|
||||
campaign's 0-exec crash went away; batched frames now reconcile
|
||||
FIFO through the real two-manager stack.
|
||||
- **2026-09-28 — fifth + sixth harness findings (third campaign's
|
||||
- **2026-09-28, fifth + sixth harness findings (third campaign's
|
||||
first minutes): the two ClearAll modeling defects.** (5) A
|
||||
double-adopt of a live session panicked the harness's own guard —
|
||||
double-adopt of a live session panicked the harness's own guard,
|
||||
but a re-adopt of a LIVE id is the manager's documented
|
||||
`ChannelExists` outcome (a legal no-op; alkcall 0.8.1's
|
||||
insert-replace fix leaves the live entry intact). Modeled as a
|
||||
no-op. (6) A `ClearAll` followed by `Route` on the same session
|
||||
tripped the backlog reconciliation — the harness retired only the
|
||||
tripped the backlog reconciliation. The harness retired only the
|
||||
sessions whose ids appeared in `clear_all`'s RETURN value, but
|
||||
that value is the OPENER-LEDGER records (ADR-047 §7): adopted
|
||||
channels carry no ledger entry, so the set is empty while the map
|
||||
itself empties. The alkcall §7.7 lesson ("`drained.len() ==
|
||||
open_count()` is not an invariant"), learned again at this
|
||||
crate's layer — the map-empties assert is the ground truth; the
|
||||
crate's layer: the map-empties assert is the ground truth; the
|
||||
harness now retires EVERY session on `ClearAll`.
|
||||
- **2026-09-28 — wave-2 campaign run (10-min detached run, §6
|
||||
- **2026-09-28, wave-2 campaign run (10-min detached run, §6
|
||||
runner; `-fork=1 -rss_limit_mb=2048 -malloc_limit_mb=2048
|
||||
-timeout=25 -max_len=4096 -use_value_profile=1`): exited 0.**
|
||||
228,676 execs (~380 exec/s; each exec replays up to 128 ops on a
|
||||
@@ -538,7 +547,7 @@ detached runner (§6) — e.g. before a release, or after touching
|
||||
`oom/timeout/crash: 0/0/0` throughout. Coverage 3427 edges /
|
||||
17,529 features / 1184 in-memory corpus entries and STILL
|
||||
EXPANDING at budget end (the op-interleaving space is much larger
|
||||
than the parser targets') — longer campaigns keep paying. The
|
||||
than the parser targets'), so longer campaigns keep paying. The
|
||||
two-pump contract held across every adversarial sequence: no
|
||||
wedged pump (the 5 s reconcile join), no lost datagram (the
|
||||
FIFO/backlog reconciliation), no premature EOF (the sentinel
|
||||
@@ -547,14 +556,14 @@ detached runner (§6) — e.g. before a release, or after touching
|
||||
routed-but-unpulled remainder.
|
||||
- **NO crate bugs found by wave 2.** All six findings across waves
|
||||
1–2 were harness-model defects (four this session), each
|
||||
invisible to example-based review and caught by fuzzing/replay —
|
||||
invisible to example-based review and caught by fuzzing/replay:
|
||||
the alkcall §7.7 "fuzzer as a harness-checker" pattern,
|
||||
re-confirmed. The stateful thesis is validated from the harness
|
||||
side: the harness defects themselves (double-adopt semantics, the
|
||||
clear_all ledger/map distinction, the route_payload direction)
|
||||
are exactly the interleaving-shape mistakes the alkcall §7.8
|
||||
campaigns caught in their own drivers.
|
||||
- (progress appended as implementation proceeds — see git log)
|
||||
- (progress appended as implementation proceeds; see git log)
|
||||
|
||||
## 8. Verification commands
|
||||
|
||||
@@ -579,9 +588,9 @@ Campaign flags per target: `datagram_codec -max_len=65536`;
|
||||
- Upstream campaign + operational model (authoritative for tooling,
|
||||
runner, corpus policy, no-CI posture):
|
||||
`/workspace/@alkdev/alkcall/docs/research/fuzzing.md`
|
||||
- alkcall 0.8.1 changelog — the finding our adopt path is exposed to
|
||||
- alkcall 0.8.1 changelog: the finding our adopt path is exposed to
|
||||
- This crate: ADR-003 (codec framing), ADR-002 (ALPN), ADR-007/008
|
||||
(direct/forwarded ops), `docs/architecture/wire.md`
|
||||
- External: RUSTSEC-2026-0037 (quinn-proto — the maintainers'
|
||||
- External: RUSTSEC-2026-0037 (quinn-proto, the maintainers'
|
||||
fuzzing-coverage statement), rust-fuzz trophy case, cargo-fuzz Rust
|
||||
Fuzz Book (all linked from the upstream doc's §9)
|
||||
+161
-158
@@ -3,7 +3,7 @@ status: draft
|
||||
last_updated: 2026-09-05
|
||||
---
|
||||
|
||||
# alktunnels — Phase 0 Research Findings
|
||||
# alktunnels: Phase 0 Research Findings
|
||||
|
||||
This document captures Phase 0 (Exploration) findings and open design
|
||||
questions for the `alktunnels` crate. The objective of Phase 0 per
|
||||
@@ -13,16 +13,17 @@ input to Phase 1 (Architecture), where the Architect will produce
|
||||
`docs/architecture/` specs, ADRs, and open questions.
|
||||
|
||||
Drafted 2026-09-05, emerging from the initial setup discussion. The crate is
|
||||
the sibling of alktty (`alk/tty` — terminal sessions) on the alkcall
|
||||
the sibling of alktty (`alk/tty`, terminal sessions) on the alkcall
|
||||
substrate: where alktty multiplexes one service with a fixed five-stream
|
||||
channel structure, alktunnels generalizes the tunnel handler shape to
|
||||
arbitrary bidirectional tunnels in the `ssh -L` / `ssh -D` sense — TCP, UDP,
|
||||
arbitrary bidirectional tunnels in the `ssh -L` / `ssh -D` sense, meaning
|
||||
TCP, UDP,
|
||||
unix sockets, and other stream or datagram substrates.
|
||||
|
||||
The 2026-09-05 first revision adds the tun2proxy UDP gateway prior art
|
||||
(`OQ-TN-01`, `OQ-TN-02`, `OQ-TN-07`, `OQ-TN-10`) and the transport story
|
||||
clarification (TCP vs QUIC at the channels layer, invisible to this crate —
|
||||
§What is already settled). This prior art was discussed with the POC agent
|
||||
clarification (TCP vs QUIC at the channels layer, invisible to this crate;
|
||||
see §What is already settled). This prior art was discussed with the POC agent
|
||||
around the alknet-channels POC but never documented; it is captured here so
|
||||
it survives into the spec.
|
||||
|
||||
@@ -31,34 +32,34 @@ it survives into the spec.
|
||||
The foundation is POC-validated and ADR-pinned; this crate is not starting
|
||||
from zero. It inherits:
|
||||
|
||||
- **The demux→Connection→handler→mux path** — validated by the
|
||||
- **The demux→Connection→handler→mux path**, validated by the
|
||||
alknet-channels POC (Target 3), now production alkcall channels. The
|
||||
tunnel payload is raw bytes inside a channels data channel; channels
|
||||
strips its 8-byte header transparently (alknet ADR-093 / alkcall
|
||||
ADR-035).
|
||||
- **The two-pump handler shape** — one pump per direction, each pump MUST
|
||||
- **The two-pump handler shape**: one pump per direction, and each pump MUST
|
||||
shut down the opposite sink on completion (`try_join!` alone deadlocks;
|
||||
alknet ADR-078). POC-validated with a 1 MiB backpressure test. This
|
||||
crate is the *second* two-pump consumer the ADR deferred helper
|
||||
extraction for (the first was the POC's tunnel handler; SSH
|
||||
`direct-tcpip` would be a later third).
|
||||
- **The producer/consumer model** — producer registers openable channels
|
||||
- **The producer/consumer model**: the producer registers openable channels
|
||||
via `ChannelCore::register_openable` (authorization for free via
|
||||
`AccessControl`); consumer opens tunnel channels via `ChannelClient`
|
||||
`AccessControl`); the consumer opens tunnel channels via `ChannelClient`
|
||||
(alkcall ADR-037, ADR-043). Connection direction is independent of
|
||||
tunnel direction.
|
||||
- **The backend inversion point pattern** — substrate-specific types
|
||||
(`TcpStream`, `UdpSocket`, unix sockets) confined to feature-gated
|
||||
- **The backend inversion point pattern**: substrate-specific types
|
||||
(`TcpStream`, `UdpSocket`, unix sockets) are confined to feature-gated
|
||||
backend modules, injected at the assembly layer, never imported from
|
||||
the shared/producer/consumer modules (alktty `TtyBackend` precedent).
|
||||
- **The wasm-clean default crate** — protocol-only code compiles to
|
||||
- **The wasm-clean default crate**: protocol-only code compiles to
|
||||
`wasm32-unknown-unknown`; socket/platform I/O is feature-gated
|
||||
(alktty precedent).
|
||||
- **The relay story** — tunnels traverse alkcall hub relays
|
||||
- **The relay story**: tunnels traverse alkcall hub relays
|
||||
transparently via byte-for-byte data-channel forwarding with ID
|
||||
rewrite (alkcall ADR-042). No tunnel-specific relay work.
|
||||
- **The transport story** — the underlying channels transport is TCP or
|
||||
QUIC (QUIC preferred) but that is the alkcall layer's concern, not
|
||||
- **The transport story**: the underlying channels transport is TCP or
|
||||
QUIC (QUIC preferred), but that is the alkcall layer's concern, not
|
||||
this crate's: the tunnel protocol sees a `BiStream` and is
|
||||
transport-agnostic like the sibling crates. UDP tunneling (below)
|
||||
rides the same chunk stream; the stream-vs-datagram question is
|
||||
@@ -69,7 +70,7 @@ from zero. It inherits:
|
||||
|
||||
Discussed 2026-09-05. The standing stance across the alk* crates: **the
|
||||
hub owns the connection and explicitly proxies to expose resources for
|
||||
others.** A produced resource is not a socket — it is an ACL-scoped
|
||||
others.** A produced resource is not a socket, it is an ACL-scoped
|
||||
*virtual resource* on the side that can reach the real target. Applied
|
||||
to tunnels, this dissolves the SSH direction model:
|
||||
|
||||
@@ -79,11 +80,11 @@ to tunnels, this dissolves the SSH direction model:
|
||||
wants the bytes is the consumer (opens channels via `ChannelClient`).
|
||||
This holds for both SSH `-L` and `-R`: in both, the entry-point side
|
||||
opens the channel (consumer) and the target side handles the open
|
||||
(producer) — the only difference is which machine hosts the entry
|
||||
(producer), and the only difference is which machine hosts the entry
|
||||
point, which is assembly-layer wiring, not protocol.
|
||||
- **The "exposed port" is a virtual port.** The far side may *think* it
|
||||
exposed a port on the remote end; what actually exists is an
|
||||
ACL-scoped resource in the connection/identity registry — the same
|
||||
ACL-scoped resource in the connection/identity registry, the same
|
||||
shape as operation registration, not a bind. Example: a self-hosted
|
||||
gitea (HTTP in a docker container, really binding a port) is tunneled
|
||||
to the hub; the hub proxies the tunnel to consumers whose ACL grants
|
||||
@@ -92,12 +93,12 @@ to tunnels, this dissolves the SSH direction model:
|
||||
- **The hub proxy is a producer wrapping a consumer.** The proxy is an
|
||||
assembly-layer construct. On leg 1 (gitea side ↔ hub): gitea side is
|
||||
producer, hub is consumer. On leg 2 (hub ↔ service consumer): the hub
|
||||
re-produces the resource ACL-scoped — producer wrapping its leg-1
|
||||
consumer role. Data path per hop is the same two-pump shape; the
|
||||
re-produces the resource ACL-scoped (producer wrapping its leg-1
|
||||
consumer role). Data path per hop is the same two-pump shape; the
|
||||
consumer opens a channel naming the resource, the producer's open
|
||||
handler dials the target. This means the hub *terminates and
|
||||
re-produces* rather than relaying byte-for-byte — unlike alkcall
|
||||
ADR-042's transparent data-channel relay — because ACL must apply
|
||||
re-produces* rather than relaying byte-for-byte, unlike alkcall
|
||||
ADR-042's transparent data-channel relay, because ACL must apply
|
||||
per hop. (ADR-042 relays remain valid for transports that don't
|
||||
need per-hop ACL; the hub's tunnel proxy is the explicit-ACL path.)
|
||||
- **`-D` (dynamic/SOCKS) composes.** A SOCKS5 server at some assembly
|
||||
@@ -108,7 +109,7 @@ to tunnels, this dissolves the SSH direction model:
|
||||
- **No forced binding, everywhere (OQ-TN-04 resolved).** Binding is
|
||||
always assembly-layer and optional, on either side. The protocol
|
||||
never binds: a producer "produces a resource" (which may or may not
|
||||
correspond to a local bind — a docker container's port, a unix
|
||||
correspond to a local bind: a docker container's port, a unix
|
||||
socket, an in-process service); a consumer "consumes it." The SSH
|
||||
mental image of "expose a port" is an illusion the assembly layer
|
||||
may create locally; the protocol only ever carries
|
||||
@@ -120,10 +121,10 @@ it; direction, bind, and ACL scoping are assembly-layer concerns
|
||||
wrapped around that pair.
|
||||
|
||||
**Residue (folded into other OQs):**
|
||||
- How does a consumer *discover* produced resources — resolved
|
||||
- How does a consumer *discover* produced resources? Resolved
|
||||
2026-09-05: the existing bidirectional ACL-filtered ops listing
|
||||
(openable channels are operations, alkcall ADR-047). See OQ-TN-08.
|
||||
- Dynamic-target policy for `-D`-style opens — dissolved 2026-09-05:
|
||||
- Dynamic-target policy for `-D`-style opens. Dissolved 2026-09-05:
|
||||
`-D` is "just tunnel a socks5 connection"; target selection lives in
|
||||
the socks5 protocol at the producing side, governed by the same
|
||||
op-level ACL. See OQ-TN-08.
|
||||
@@ -135,42 +136,42 @@ wrapped around that pair.
|
||||
## Prior art: tun2proxy UDP gateway (`OQ-TN-01`, `OQ-TN-02`, `OQ-TN-07`, `OQ-TN-10`)
|
||||
|
||||
`/workspace/tun2proxy/src/udpgw.rs` implements a UDP gateway over a TCP
|
||||
stream — structurally the same problem this crate faces for UDP tunnels
|
||||
stream; structurally it is the same problem this crate faces for UDP tunnels
|
||||
over channels data channels. Discussed with the alknet-channels POC
|
||||
agent as the example of "UDP over a stream substrate," but never
|
||||
documented. Key mechanics, all of which generalize:
|
||||
|
||||
- **Per-datagram length framing over the stream** — the packet format is
|
||||
- **Per-datagram length framing over the stream**: the packet format is
|
||||
`LEN(u16 BE) | FLAGS(u8) | CONN_ID(u16) | [SOCKS5 address] | DATA`
|
||||
(`udpgw.rs:82-88`). Boundary preservation is re-added by the protocol,
|
||||
not by the substrate: exactly the "length-prefix each datagram inside
|
||||
the channel" half-answer in OQ-TN-02, proven in production.
|
||||
- **SOCKS5 address format travels per data packet** — `ATYP`
|
||||
- **SOCKS5 address format travels per data packet**: `ATYP`
|
||||
(0x01 IPv4 / 0x03 domain / 0x04 IPv6) + variable address + port
|
||||
(`udpgw.rs:68-76`). This is concrete prior art for OQ-TN-01's
|
||||
addressing: a scheme-tagged addressing encoding with v4/v6/domain
|
||||
coverage already standardized. Note the asymmetry with TCP tunnels:
|
||||
for UDP, the remote endpoint is per-datagram, not per-channel.
|
||||
- **One stream carries many UDP flows** — `CONN_ID(u16)` multiplexes
|
||||
- **One stream carries many UDP flows**: `CONN_ID(u16)` multiplexes
|
||||
associations over a single gateway connection (`udpgw.rs:66`),
|
||||
with `keepalive` (0x01) and `error` (0x20) flag packets as the only
|
||||
non-data frame types (`udpgw.rs:21-26`). This is the "one channel =
|
||||
one association, per-endpoint multiplexing inside" half-answer in
|
||||
OQ-TN-02, with the refinement that the per-endpoint multiplexing key
|
||||
(`CONN_ID`) is protocol-level, allocated by the client, u16.
|
||||
- **Flow lifecycle is packet-level** — `udp_timeout` idle expiry,
|
||||
- **Flow lifecycle is packet-level**: `udp_timeout` idle expiry,
|
||||
`keepalive_time` heartbeats on idle connections
|
||||
(`UDPGW_KEEPALIVE_TIME = 30s`, `udpgw.rs:16`), and an MTU cap
|
||||
(`parse_udp_response` rejects `data.len() > udp_mtu`,
|
||||
`udpgw.rs:527`). Also `UDPGW_MAX_CONNECTIONS = 5` pooled gateway
|
||||
connections *above* the packet layer — a throughput choice, not a
|
||||
connections *above* the packet layer, a throughput choice, not a
|
||||
protocol requirement; channels gives us N channels already.
|
||||
- **Implications for alktunnels:**
|
||||
- UDP boundary preservation over the chunk stream is validated
|
||||
prior art, not speculation — raises confidence in the OQ-TN-02
|
||||
prior art, not speculation, and it raises confidence in the OQ-TN-02
|
||||
half-answer considerably.
|
||||
- The frame-type set (DATA/KEEPALIVE/ERR) is a useful minimal
|
||||
vocabulary — historically mapped onto OQ-TN-09's establishment
|
||||
vocabulary; it historically mapped onto OQ-TN-09's establishment
|
||||
question, now superseded there by alkcall ADR-049 (establishment
|
||||
is the open op's reply, not an in-stream frame). KEEPALIVE and
|
||||
ERR both drop (channels owns liveness; establishment is
|
||||
@@ -185,20 +186,20 @@ documented. Key mechanics, all of which generalize:
|
||||
underlying transport (QUIC/TCP) handles connection keepalive, and
|
||||
channel liveness is the channels layer's concern. The tunnel
|
||||
protocol likely needs only flow-level idle expiry, not
|
||||
transport-level keepalive packets — TBD in the spec.
|
||||
transport-level keepalive packets (TBD in the spec).
|
||||
|
||||
## Prior art: the alktty stream-splitting pattern (`OQ-TN-09`, `OQ-TN-05`)
|
||||
|
||||
Discussed 2026-09-05. alktty tunnels io-like streams: the adapter
|
||||
splits the `BiStream` from `accept_bi` into read/write halves
|
||||
(`tokio::io::split`), wraps each half in a `ChunkReader`/`ChunkWriter`
|
||||
(`wire.rs` — the 5-byte `[stream_type: u8][length: u32 be][payload]`
|
||||
(`wire.rs`, the 5-byte `[stream_type: u8][length: u32 be][payload]`
|
||||
codec), and pumps chunks between the wire halves and the backend's
|
||||
`TtyHandle` halves. Strip packets on the way in, wrap packets on the
|
||||
way out; everything above the codec sees plain `AsyncRead`/`AsyncWrite`.
|
||||
|
||||
The proposal: that same pattern (or close) may be *the* tunnel pattern.
|
||||
A tunnel is then literally "tty without the five stream types" — split
|
||||
A tunnel is then literally "tty without the five stream types": split
|
||||
the channel's `BiStream`, wrap the halves in a codec, pump to/from
|
||||
substrate halves. What differs per concern is only the codec and the
|
||||
far-end handle:
|
||||
@@ -215,12 +216,12 @@ Implications:
|
||||
establishment/error half is resolved at the call layer (the open op
|
||||
replies `channel:open_failed`; no in-stream establishment frame).
|
||||
What the ctrl-frame pattern still informs: whether a *mid-stream*
|
||||
control frame is ever needed (v1 likely not — see OQ-TN-09's
|
||||
control frame is ever needed (v1 likely not; see OQ-TN-09's
|
||||
resolved status), and the alktty zero-length-sentinel EOF semantics
|
||||
carry over unchanged either way.
|
||||
- **OQ-TN-05 (backend trait) gets a concrete shape to judge.** If
|
||||
tunnels are "split + wrap + pump," the far-end handle is just
|
||||
"(AsyncRead + AsyncWrite) halves" for stream substrates — which is
|
||||
"(AsyncRead + AsyncWrite) halves" for stream substrates, which is
|
||||
either a very thin trait or no trait (fn returning boxed halves).
|
||||
The UDP case's flow table is the real differentiator to design
|
||||
around, not the stream case.
|
||||
@@ -228,25 +229,25 @@ Implications:
|
||||
pump loop shape is identical to alktty's modulo the number of pumps,
|
||||
a shared helper extraction (this crate's second-consumer moment,
|
||||
alknet ADR-078) should be evaluated against alktty's `pump_session`
|
||||
too — the convergence test the ADR asked for may be nearly free.
|
||||
- **Wasm story intact** — the codec is pure byte manipulation; the
|
||||
too; the convergence test the ADR asked for may be nearly free.
|
||||
- **Wasm story intact**: the codec is pure byte manipulation; the
|
||||
split/pump plumbing is tokio-async, no substrate types in the
|
||||
protocol layer. Same as alktty.
|
||||
|
||||
**Codec direction set 2026-09-06 (discussion): raw pass-through for
|
||||
stream substrates, `u16` length-prefix for UDP — no 5-byte header.**
|
||||
stream substrates, `u16` length-prefix for UDP; no 5-byte header.**
|
||||
Phase 1 ADR, before the first consumer.
|
||||
|
||||
Rationale (structural, not stylistic): alktty's 5-byte
|
||||
`[stream_type:u8][len:u32]` exists because five logical streams share
|
||||
one `BiStream` — the type byte is a sub-demux key. A tunnel has
|
||||
one `BiStream`; the type byte is a sub-demux key. A tunnel has
|
||||
exactly one data stream per direction (the channel's own read/write
|
||||
halves), so there is nothing to demux and no per-chunk type byte. TCP
|
||||
needs no length prefix either: the channels layer already
|
||||
length-prefixes every chunk (8-byte header), so the codec is pure
|
||||
pass-through. Only UDP needs boundary re-framing (datagram boundaries
|
||||
do not survive the chunk stream), and `u16` suffices — UDP's max
|
||||
payload is 65507 < 65535.
|
||||
do not survive the chunk stream), and `u16` suffices (UDP's max
|
||||
payload is 65507 < 65535).
|
||||
|
||||
Per-chunk wire overhead (data chunks, both directions):
|
||||
|
||||
@@ -262,7 +263,7 @@ DNS-sized datagram (~100 B) pays ~3% for boundary framing.
|
||||
|
||||
Trade-offs accepted:
|
||||
- **No in-band control path for TCP channels, ever** (any future
|
||||
mid-stream signaling is a wire break). Post-ADR-049 this is clean —
|
||||
mid-stream signaling is a wire break). Post-ADR-049 this is clean:
|
||||
establishment is call-level (`channel:open_failed`), the survey
|
||||
found no mid-stream control need, and the escape hatch is
|
||||
protocol-level (a new ALPN is cheap; a wire change is not). The
|
||||
@@ -275,19 +276,19 @@ Trade-offs accepted:
|
||||
- UDP-specific framing lives only in the UDP path; a hypothetical
|
||||
future multi-endpoint UDP gateway resource would carry its own
|
||||
self-describing framing *inside* the datagram payloads (udpgw
|
||||
precedent, OQ-TN-02) — invisible to this base codec.
|
||||
precedent, OQ-TN-02), invisible to this base codec.
|
||||
|
||||
Remaining for the Phase 1 codec ADR: confirm `len=0` empty-datagram
|
||||
semantics (send allowed? receive maps to a zero-payload datagram),
|
||||
and whether the UDP length prefix rides `u16 BE` (leaned) or a varint
|
||||
(rejected for v1 simplicity — datagrams are MTU-bounded anyway).
|
||||
(rejected for v1 simplicity; datagrams are MTU-bounded anyway).
|
||||
|
||||
## Open Questions
|
||||
|
||||
These are the design questions Phase 0 must resolve (or explicitly defer)
|
||||
before the architecture spec. They are numbered OQ-TN-01.. so they can be
|
||||
referenced, tracked, and promoted into `docs/architecture/open-questions.md`
|
||||
in Phase 1. Half-answers and hunches are marked as such — the point of this
|
||||
in Phase 1. Half-answers and hunches are marked as such; the point of this
|
||||
document is to hold them without forcing premature decisions.
|
||||
|
||||
> **Promoted 2026-09-07 (Phase 1):** OQ-TN-01..10 now live in
|
||||
@@ -303,43 +304,43 @@ What does the tunnel `params` on `channel/open` look like? alknet ADR-071
|
||||
addressing scheme was never decided. It must cover at minimum:
|
||||
|
||||
- TCP dial (`host:port`)
|
||||
- UDP (associate-style or endpoint-style — see OQ-TN-02)
|
||||
- UDP (associate-style or endpoint-style; see OQ-TN-02)
|
||||
- Unix domain sockets (path)
|
||||
- Direction (who dials the target — see OQ-TN-03, resolved)
|
||||
- Direction (who dials the target; see OQ-TN-03, resolved)
|
||||
- Bind/listen vs dial semantics (see OQ-TN-04, mostly resolved)
|
||||
|
||||
Considerations:
|
||||
|
||||
- `params` is ALPN-specific JSON, interpreted by the open handler, not by
|
||||
the channels layer (alknet ADR-075 / alkcall ADR-039). alktty's
|
||||
precedent is the `NegotiateRequest` shape — a self-contained JSON
|
||||
precedent is the `NegotiateRequest` shape, a self-contained JSON
|
||||
object carried in the open op.
|
||||
- The addressing string is wire-stable once a consumer exists (one-way
|
||||
door). It must be substrate-extensible without format changes (a new
|
||||
substrate should be an additive `scheme` value, not a v2 format).
|
||||
- Prior art to survey: SSH forwarding models (`direct-tcpip`,
|
||||
`forwarded-tcpip`), SOCKS5 addressing (ATYP + addr + port), russh
|
||||
(`/workspace/russh` — note: russh has no UDP channel type at all, so
|
||||
(`/workspace/russh`; note: russh has no UDP channel type at all, so
|
||||
there is no `direct-udp`-style prior art there; still useful for
|
||||
channel-open framing and addressing-intel generally).
|
||||
- ALPN convention check (2026-09-05): the ALPN prefix swap `alknet/` →
|
||||
`alk/` happened alkcall v0.1.1, before the first published consumer.
|
||||
Everything in this crate's docs referencing `alknet/tunnel` means
|
||||
`alk/tunnel` — a small thing, but docs/ADRs must not perpetuate the
|
||||
`alk/tunnel`; it is a small thing, but docs/ADRs must not perpetuate the
|
||||
old prefix or consumers will bake it in.
|
||||
|
||||
**Reframe 2026-09-05 — this is partly an XY problem.** The rich
|
||||
**Reframe 2026-09-05: this is partly an XY problem.** The rich
|
||||
"remote addressing" framing was chasing the wrong thing. Except in the
|
||||
`-D`/dynamic case (which composes at the assembly layer), a tunnel is
|
||||
either TCP or UDP, and `params` need only *identify a produced
|
||||
resource* — not carry a general-purpose address. The producer owns
|
||||
resource*, not carry a general-purpose address. The producer owns
|
||||
where the resource comes from (a local port, a docker container's
|
||||
port, an in-process service, a unix socket — its problem, most likely
|
||||
port, an in-process service, a unix socket; its problem, most likely
|
||||
a local port). So `params` reduce to:
|
||||
|
||||
1. Which resource (a produced resource identifier — stable name or
|
||||
1. Which resource (a produced resource identifier: stable name or
|
||||
target address, still to decide),
|
||||
2. Substrate discriminator (`tcp` / `udp` / extensible) — so UDP is
|
||||
2. Substrate discriminator (`tcp` / `udp` / extensible), so UDP is
|
||||
structurally supported from day one even if the vast majority of
|
||||
use is TCP.
|
||||
|
||||
@@ -350,18 +351,18 @@ enters only through the `-D`/dynamic-target composition path
|
||||
**Status:** mostly resolved 2026-09-05 (pending the resource-naming
|
||||
and discovery residue). Direction of travel:
|
||||
- `params` = self-contained JSON object in the open op (alktty
|
||||
`NegotiateRequest` precedent) — accepted path.
|
||||
`NegotiateRequest` precedent), the accepted path.
|
||||
- `params` identify a produced resource + substrate discriminator;
|
||||
producer owns the backing. No URL-style general addressing.
|
||||
- Residue: stable resource name vs target address as the identifier
|
||||
shape; discovery resolved 2026-09-05 — see OQ-TN-08 (the existing
|
||||
shape; discovery resolved 2026-09-05; see OQ-TN-08 (the existing
|
||||
ACL-filtered ops listing IS tunnel-resource discovery, since
|
||||
openable channels are operations per alkcall ADR-047); exact JSON
|
||||
field layout (Phase 1 spec, ADR before first consumer — wire-stable
|
||||
field layout (Phase 1 spec, ADR before first consumer, wire-stable
|
||||
once published).
|
||||
|
||||
**Survey input 2026-09-06** (`ssh-socks5-survey.md`): SSH's
|
||||
`direct-tcpip` payload reduces to "target + informational originator" —
|
||||
`direct-tcpip` payload reduces to "target + informational originator";
|
||||
the originator pair has no analogue here (ACL rides the channels
|
||||
open-op machinery), supporting the two-field reframe. OpenSSH's
|
||||
`direct-streamlocal` extension is the extensibility template: new
|
||||
@@ -370,20 +371,21 @@ string → new `substrate` value, not a format change. SOCKS5 ATYP is
|
||||
not needed in base params (dynamic-path addressing only). Minimal
|
||||
shape: `{ "resource": <id>, "substrate": "tcp" | "udp" | <extensible> }`.
|
||||
|
||||
### OQ-TN-02: Datagram substrates (UDP) — boundary preservation
|
||||
### OQ-TN-02: Boundary preservation for datagram substrates (UDP)
|
||||
|
||||
Does a UDP tunnel preserve datagram boundaries end-to-end, or does the
|
||||
tunnel present a byte-stream abstraction to the consumer (boundaries lost,
|
||||
re-chunked arbitrarily)?
|
||||
|
||||
- Channels is a chunk stream with bounded buffers; the zero-length chunk
|
||||
is the EOF sentinel — datagram boundaries are *not* preserved by the
|
||||
is the EOF sentinel, so datagram boundaries are *not* preserved by the
|
||||
substrate (alknet ADR-071/093; the POC only exercised TCP).
|
||||
- **Corrected 2026-09-06** (`ssh-socks5-survey.md`): an earlier line
|
||||
here claimed "SSH's `-D` UDP associate tunnels UDP as a stream" — a
|
||||
here claimed "SSH's `-D` UDP associate tunnels UDP as a stream"; that was
|
||||
a
|
||||
category error. SSH has *no UDP forwarding at all* (RFC 4254 defines
|
||||
only TCP/X11/session channels; russh is grep-confirmed UDP-free;
|
||||
only SSH3 — a different HTTP/3 protocol — has `direct-udp`). SSH
|
||||
only SSH3, a different HTTP/3 protocol, has `direct-udp`). SSH
|
||||
`-D` carries only the SOCKS5 *TCP* control connection. The real
|
||||
UDP-over-stream prior art is tun2proxy udpgw (§Prior art) and
|
||||
SOCKS5's own UDP relay.
|
||||
@@ -394,7 +396,7 @@ re-chunked arbitrarily)?
|
||||
document (AGENTS.md convention 12). Boundary loss is cheaper but
|
||||
changes what protocols can ride the tunnel (DNS? QUIC? game traffic?).
|
||||
- Datagrams also raise multiplexing questions TCP does not: one UDP
|
||||
"association" carries many remote endpoints — does one tunnel channel
|
||||
"association" carries many remote endpoints. Does one tunnel channel
|
||||
carry one endpoint or many, and how are per-endpoint replies routed?
|
||||
|
||||
**Status: resolved as a split by path, 2026-09-06** (survey
|
||||
@@ -405,28 +407,28 @@ re-chunked arbitrarily)?
|
||||
association). Aligns with resource naming (OQ-TN-01); per-datagram
|
||||
addressing would reintroduce the "general addressing in params" the
|
||||
reframe removed. Boundary preservation inside the channel stays
|
||||
per-datagram length framing (tun2proxy-proven) — endpoint-at-open is
|
||||
per-datagram length framing (tun2proxy-proven); endpoint-at-open is
|
||||
about addressing, not about dropping the LEN prefix.
|
||||
- **Dynamic/`-D` UDP (SOCKS5-style): per-datagram addressing inside
|
||||
the tunnel payload** (SOCKS5 UDP header or udpgw format), composed
|
||||
at the assembly layer, never in base params.
|
||||
- **The channel ID replaces udpgw's CONN_ID** — a conn-id inside the
|
||||
- **The channel ID replaces udpgw's CONN_ID**: a conn-id inside the
|
||||
channel would be a second demux layer (AGENTS.md convention 10).
|
||||
One channel per UDP flow; no protocol-level flow key.
|
||||
- **KEEPALIVE drops** — udpgw's heartbeats exist for NAT-traversed
|
||||
long-lived TCP; channels transport liveness is the alkcall layer's
|
||||
- **KEEPALIVE drops** because udpgw's heartbeats exist for NAT-traversed
|
||||
long-lived TCP, while channels transport liveness is the alkcall layer's
|
||||
concern. Flow-level idle expiry remains producer-side bookkeeping.
|
||||
- A UDP gateway resource (multi-endpoint, udpgw-shaped) remains
|
||||
possible *inside* a channel as self-describing framing — invisible
|
||||
to base params, keeping OQ-TN-07 option A viable. A targeted POC
|
||||
possible *inside* a channel as self-describing framing, invisible
|
||||
to base params and keeping OQ-TN-07 option A viable. A targeted POC
|
||||
(OQ-TN-10 #1) remains +EV for the channels-layer fit
|
||||
(chunk-size vs datagram-size, MTU vs bounded buffers, idle expiry).
|
||||
|
||||
**Mandate strengthened 2026-09-07 (reverse POC finding F-2):** the
|
||||
length-framed codec is not just boundary preservation — it is
|
||||
length-framed codec is not just boundary preservation; it is
|
||||
*mandatory for correctness* on UDP. In the raw pass-through pump
|
||||
shape, an empty datagram is a zero-byte read from the substrate
|
||||
adapter — indistinguishable from EOF (`tokio::io::copy` treats `Ok(0)`
|
||||
adapter, indistinguishable from EOF (`tokio::io::copy` treats `Ok(0)`
|
||||
as end-of-stream and shuts the pump down). An empty datagram and the
|
||||
zero-length EOF sentinel are the same wire shape at the pump level;
|
||||
the `[len: u16 BE]` prefix makes an empty datagram two bytes,
|
||||
@@ -442,12 +444,12 @@ genuinely EOF). Validated from a second angle by the reverse-flow POC
|
||||
protocol-level direction: role follows the resource. Whoever can reach
|
||||
the target is the producer (registers openable channels); whoever wants
|
||||
the bytes is the consumer (opens channels). SSH `-L` and `-R` are the
|
||||
same producer/consumer pair with the entry point on different machines —
|
||||
assembly-layer wiring, not protocol. The "exposed port" is a virtual,
|
||||
same producer/consumer pair with the entry point on different machines,
|
||||
which is assembly-layer wiring, not protocol. The "exposed port" is a virtual,
|
||||
ACL-scoped resource on the producing side; the hub proxy is a producer
|
||||
wrapping a consumer. `-D`/SOCKS composes as a consumer opening channels
|
||||
with per-connection dynamic targets, gated by the producer's target
|
||||
policy (OQ-TN-08) — not a base-crate concern. Original question retained
|
||||
policy (OQ-TN-08); it is not a base-crate concern. Original question retained
|
||||
below for context.
|
||||
|
||||
Considerations (original):
|
||||
@@ -475,7 +477,7 @@ connection. The open questions:
|
||||
advertises listen targets)? Does it interact with `channel/open` at all,
|
||||
or is it a call-level operation ("please open a tunnel channel to me
|
||||
when a local accept happens")?
|
||||
- Dynamic (-D) may not be a tunnel concern at all — it may compose as
|
||||
- Dynamic (-D) may not be a tunnel concern at all; it may compose as
|
||||
"SOCKS5 server implemented over alktunnels dial primitives" in a
|
||||
separate crate. Keep or cut for v1?
|
||||
|
||||
@@ -483,7 +485,7 @@ connection. The open questions:
|
||||
the last open thread (the `-R` advertisement/lifecycle surface) closed
|
||||
2026-09-07 by the reverse-flow POC (`reverse-poc-summary.md`): **no
|
||||
advertisement op is needed.** The listener is the initiating side's
|
||||
own local resource — it binds (assembly layer, OQ-TN-04) and opens
|
||||
own local resource: it binds (assembly layer, OQ-TN-04) and opens
|
||||
tunnel channels toward the serving side per accept; a far-side
|
||||
listener ("expose a port on your end") is a producer-side listen
|
||||
establisher over the same open op, not a new mechanism. The hunch
|
||||
@@ -498,7 +500,7 @@ The POC's shape dialed a target from the handler; binding is optional and
|
||||
belongs to the caller (assembly layer), not the protocol crate. The API
|
||||
surface must support:
|
||||
|
||||
- Dial flows with no local bind (POC shape) — covered.
|
||||
- Dial flows with no local bind (POC shape) are covered.
|
||||
- Listen flows where the binding happens on one side only.
|
||||
- Unbound/abstract flows (e.g. unix socketpair-style, stdio bridges,
|
||||
in-process pipes) where neither side binds.
|
||||
@@ -508,25 +510,26 @@ channel" without assuming either endpoint is a bound socket. Substrate
|
||||
modules (behind feature flags) own actual `bind()` calls; the protocol
|
||||
owns bookkeeping only.
|
||||
|
||||
**Status:** open — mostly resolved (2026-09-05, see §Prior art: the
|
||||
**Status:** open but mostly resolved (2026-09-05, see §Prior art: the
|
||||
hub-owns-the-connection model): binding is always assembly-layer and
|
||||
optional, on either side; the protocol never binds. What remains is the
|
||||
concrete API surface — who calls what to start a tunnel in each mode
|
||||
concrete API surface: who calls what to start a tunnel in each mode
|
||||
(produce-with-dial, produce-without-dial/accept-style, consume). This is
|
||||
now a spec-shape task, not a research question.
|
||||
|
||||
### OQ-TN-05: Backend inversion point — is there a `TunnelBackend` trait?
|
||||
### OQ-TN-05: Backend inversion point (is there a `TunnelBackend` trait?)
|
||||
|
||||
alktty has `TtyBackend` because backends (local PTY, docker, SSH) produce
|
||||
handles and the adapter pumps them. For tunnels, the producer side's
|
||||
substrate action is narrower — dial a target, or accept on a listener —
|
||||
substrate action is narrower (dial a target, or accept on a listener),
|
||||
so the question:
|
||||
|
||||
- Is a `TunnelBackend`-style trait needed at all, or is the two-pump
|
||||
handler + feature-gated substrate modules (dial/listen helpers) the
|
||||
whole story, with the assembly layer wiring substrate streams directly?
|
||||
- If a trait: what is the handle type? A tunnel "handle" is just an
|
||||
`AsyncRead + AsyncWrite` stream (or a datagram endpoint) — much thinner
|
||||
`AsyncRead + AsyncWrite` stream (or a datagram endpoint), which is much
|
||||
thinner
|
||||
than `TtyHandle`'s stdin/stdout/stderr/exit-code quadruple. The trait
|
||||
may collapse to "produce a boxed stream for this target" plus a
|
||||
listener variant.
|
||||
@@ -537,19 +540,19 @@ so the question:
|
||||
wrapping a consumer). Does the proxy need a composition helper from
|
||||
this crate, or does it assemble from the public producer/consumer
|
||||
surface as-is? If a helper is warranted, it may share shape with the
|
||||
substrate dial/listen trait — which would argue for the trait.
|
||||
substrate dial/listen trait, which would argue for the trait.
|
||||
|
||||
**Status: resolved for stream substrates, 2026-09-06 (POC).**
|
||||
`pump_halves` is generic over `(AsyncRead, AsyncWrite)` halves — TCP
|
||||
`pump_halves` is generic over `(AsyncRead, AsyncWrite)` halves: TCP
|
||||
contributes `into_split()` halves, UDP contributes the `UdpHalf`
|
||||
socket adapter, the pump never knows which. "Produce boxed halves for
|
||||
a resource" is a function, not a trait; the `TunnelBackend` trait
|
||||
question resolves to *no trait* for substrate access. The only
|
||||
remaining thread is the hub re-produce composition question (above) —
|
||||
remaining thread is the hub re-produce composition question (above),
|
||||
a Phase 1 spec question, and now decidable against the validated
|
||||
producer/consumer surface.
|
||||
|
||||
### OQ-TN-06: The two-pump helper — extract now?
|
||||
### OQ-TN-06: Extracting the two-pump helper now?
|
||||
|
||||
alknet ADR-078 deferred helper extraction until a second two-pump consumer
|
||||
exists ("a genuine deferral... the contract is decided (shutdown-on-
|
||||
@@ -565,29 +568,29 @@ consumer (POC tunnel was the first; SSH `direct-tcpip` would be a third).
|
||||
one consumer would bake in a wrong shape; with two consumers the shapes
|
||||
should be compared before extraction.
|
||||
|
||||
**Status:** open — decide when the first real tunnel handler is written;
|
||||
not a blocker for the spec. Half-answer: the helper probably belongs
|
||||
**Status:** open. Decide when the first real tunnel handler is written;
|
||||
it is not a blocker for the spec. Half-answer: the helper probably belongs
|
||||
upstream (alkcall, near the channels-adapter handler-integration
|
||||
conventions) but only if the two shapes genuinely converge.
|
||||
**Convergence input 2026-09-06 (POC):** the producer pump
|
||||
(`pump_halves`) and the consumer pump (`take_halves` + copy) are the
|
||||
same shape modulo channel side — the ADR-078 convergence test is
|
||||
same shape modulo channel side, so the ADR-078 convergence test is
|
||||
satisfied; extraction is decidable in Phase 1. Also surfaced by the
|
||||
POC: the `OpenHandler` JoinHandle semantics (see `poc-summary.md` §
|
||||
Issues Surfaced #1) are where such a helper would pin the teardown
|
||||
contract.
|
||||
|
||||
**RESOLVED 2026-09-07 — the helper landed upstream in alkcall 0.6.0**
|
||||
**RESOLVED 2026-09-07; the helper landed upstream in alkcall 0.6.0**
|
||||
(review 007 R-03, ADR-050): `alkcall::channels::pump_bidi(channel,
|
||||
peer_read, peer_write) -> (u64, u64)`. Shape as this OQ sketched it —
|
||||
peer_read, peer_write) -> (u64, u64)`. Shape as this OQ sketched it:
|
||||
two pumps, shutdown-on-completion wired in, copy counts returned for
|
||||
observability, errors EOF-shaped (no `Err` state — a mid-stream error
|
||||
observability, errors EOF-shaped (no `Err` state, since a mid-stream error
|
||||
is an abrupt close, so an `io::Result` would be dead code). The POC
|
||||
is cited in the ADR as convergence input alongside alktty's channels
|
||||
session and the assembly-layer copies. alktunnels consumes it; the
|
||||
POC's `pump_halves` is now prior art only. The JoinHandle lifetime
|
||||
contract (R-02) is documented on the `OpenHandler` type: await the
|
||||
helper inline inside the handler's task — early return is
|
||||
helper inline inside the handler's task; early return is
|
||||
teardown-at-birth.
|
||||
|
||||
### OQ-TN-07: ALPN strategy
|
||||
@@ -595,14 +598,14 @@ teardown-at-birth.
|
||||
This crate owns the `alk/tunnel`-family ALPN(s). alkcall ADR-004: one
|
||||
ALPN per protocol; `alk/` prefix. If stream (TCP/unix) and datagram (UDP)
|
||||
tunnels get distinct ALPNs, the split must be decided before the first
|
||||
consumer — ALPN strings are wire-stable once published.
|
||||
consumer, since ALPN strings are wire-stable once published.
|
||||
|
||||
- Option A: single `alk/tunnel` ALPN; substrate is a `params` field
|
||||
(and datagram framing, if any, is self-describing inside the channel).
|
||||
- Option B: `alk/tunnel` (stream) + `alk/tunnel-dgram` (datagram), so
|
||||
the wire framing differs per ALPN cleanly.
|
||||
- Channels' `params` is ALPN-specific, and the open-handler registry
|
||||
dispatches per ALPN — both options are cheap mechanically; the cost is
|
||||
dispatches per ALPN, so both options are cheap mechanically; the cost is
|
||||
consumer-side API bifurcation (two session types vs one with a
|
||||
substrate enum).
|
||||
|
||||
@@ -613,14 +616,14 @@ transport); SOCKS5 runs CONNECT and UDP ASSOCIATE over one control
|
||||
connection with a CMD discriminator; udpgw proves datagram framing
|
||||
self-describes over a stream. With OQ-TN-02 resolved as endpoint-at-
|
||||
open for base UDP resources, the substrate discriminator in `params`
|
||||
tells the handler which framing to expect — exactly option A's shape.
|
||||
tells the handler which framing to expect, which is exactly option A's shape.
|
||||
Option B (`alk/tunnel-dgram`) remains defensible only if Phase 1 wants
|
||||
structurally different framing from byte zero with no params-dependent
|
||||
dispatch; prior art gives no reason to prefer that.
|
||||
|
||||
### OQ-TN-08: Access control and ownership scope
|
||||
|
||||
Tunnels reach local networks — the open gate is the security boundary.
|
||||
Tunnels reach local networks, so the open gate is the security boundary.
|
||||
Shape follows alktty: `TUNNEL_OPEN_SCOPE` scope-gate, and the channels
|
||||
path gets `AccessControl` wiring for free via
|
||||
`ChannelCore::register_openable`. Open sub-questions:
|
||||
@@ -634,22 +637,22 @@ path gets `AccessControl` wiring for free via
|
||||
sets), and does it live in `AccessControl` or in the open handler's
|
||||
params validation?
|
||||
|
||||
**Status: mostly resolved 2026-09-05** — this OQ pointed at a general
|
||||
**Status: mostly resolved 2026-09-05.** This OQ pointed at a general
|
||||
conceptual tangle ("the host owns the resource and proxies on top of
|
||||
that" — how does that manifest for tunnels?), and the resolution is
|
||||
that"; how does that manifest for tunnels?), and the resolution is
|
||||
the same posture alktty already uses:
|
||||
|
||||
- **Assume the resource is owned by the other side of the connection.**
|
||||
At these protocol-crate levels (alktunnels, like alktty), the
|
||||
protocol works under the assumption that a produced resource belongs
|
||||
to the far side — so the ACL story is exactly alkcall's existing
|
||||
to the far side, so the ACL story is exactly alkcall's existing
|
||||
op-level ACL. No new policy layer, no target allowlists, no
|
||||
tunnel-specific ownership machinery. The actual proxy/overlay
|
||||
mechanism (hub workers connecting in and exposing tunnels, the hub
|
||||
providing an overlay to those resources based on the other side's
|
||||
ACL) is a *downstream* (assembly-layer) concern.
|
||||
- **Discovery is the existing bidirectional ops listing.** alkcall
|
||||
already has an underlying bidirectional discovery mechanism — each
|
||||
already has an underlying bidirectional discovery mechanism: each
|
||||
side can obtain the list of ops available to it (ACL-filtered). Since
|
||||
openable channels *are* operations (alkcall ADR-047), that listing
|
||||
*is* tunnel-resource discovery: a consumer asks "what ops are
|
||||
@@ -657,13 +660,13 @@ the same posture alktty already uses:
|
||||
by identity. This is exactly how a consumer learns a socks5 tunnel,
|
||||
a postgres TCP tunnel, or a redis tunnel is an available resource.
|
||||
Examples of the pattern: services typically served over a VPN or
|
||||
SSH tunnels (postgres, redis, gitea HTTP) — workers connect to a
|
||||
SSH tunnels (postgres, redis, gitea HTTP): workers connect to a
|
||||
hub, expose those tunnels, and the hub proxies them per-ACL.
|
||||
- **`-D` simplifies to "just tunnel a socks5 connection."** The socks5
|
||||
server lives on the producing side; the consumer opens an ordinary
|
||||
tunnel channel to that resource and speaks socks5 inside it. Target
|
||||
selection happens in the socks5 protocol at the far side — *not* in
|
||||
tunnel params — so the previously-tracked "dynamic-target policy
|
||||
selection happens in the socks5 protocol at the far side, *not* in
|
||||
tunnel params, so the previously-tracked "dynamic-target policy
|
||||
hook for `-D`-style opens" residue dissolves: whatever ACL governs
|
||||
the socks5 resource governs everything reachable through it, plus
|
||||
whatever policy the socks5 implementation itself applies downstream.
|
||||
@@ -681,14 +684,14 @@ upstream, and the rule is to make asks early. The E-01/E-02 sweep
|
||||
(alkcall review 006, filed from this crate's Phase 0) is the working
|
||||
proof: the establishment phase (ADR-049), the typed
|
||||
`ChannelOpenError`, and `OperationSpec.description` all landed in
|
||||
0.5.0 within a day of being filed — alktunnels was the consumer that
|
||||
0.5.0 within a day of being filed; alktunnels was the consumer that
|
||||
pulled them through. Future upstream asks follow the same path.
|
||||
|
||||
### OQ-TN-09: Lifecycle, teardown, and error reporting
|
||||
|
||||
The two-pump shape gives byte-level teardown for free (EOF sentinels;
|
||||
channels drops per-channel senders on transport EOF — alknet ADR-078,
|
||||
POC issue #6). What's missing is the error/level above bytes:
|
||||
channels drops per-channel senders on transport EOF, per alknet ADR-078
|
||||
and POC issue #6). What's missing is the error/level above bytes:
|
||||
|
||||
- How does a failed target dial reach the consumer (e.g. "connection
|
||||
refused to 10.0.0.5:80")? Is there a structured error frame in the
|
||||
@@ -696,13 +699,13 @@ POC issue #6). What's missing is the error/level above bytes:
|
||||
error on the open op?
|
||||
- Is there a "tunnel established/failed" ack before byte pumping starts
|
||||
(alktty has the negotiation frame; the POC's tunnel handler had
|
||||
nothing — it dialed and pumped)?
|
||||
nothing and simply dialed and pumped)?
|
||||
- Half-open semantics: one direction EOFs, the other keeps pumping
|
||||
(standard two-pump behavior) — is that always desired, or does the
|
||||
(standard two-pump behavior). Is that always desired, or does the
|
||||
consumer need a "close both" control?
|
||||
|
||||
**Status: resolved 2026-09-06 by alkcall ADR-049** (alkcall 0.5.0,
|
||||
review 006 E-01 — the establishment gap filed from this crate's Phase
|
||||
review 006 E-01: the establishment gap filed from this crate's Phase
|
||||
0 pass). The in-band control-frame hunch below is **superseded**:
|
||||
establishment failure is now the open op's reply, not a frame on the
|
||||
channel stream.
|
||||
@@ -710,29 +713,29 @@ channel stream.
|
||||
- The producer's tunnel open op registers via
|
||||
`ChannelCore::register_openable_with_establisher`; the establisher
|
||||
(`OpenEstablisher`) runs as an awaited, bounded establishment phase
|
||||
— semantically validate params, **dial the target** — before the
|
||||
(semantically validate params, **dial the target**) before the
|
||||
open op replies. On failure the just-allocated channel is torn down
|
||||
(allocation and teardown balance; ledger un-increment) and the
|
||||
consumer receives `channel:open_failed` with
|
||||
`details: {reason, message}`, `reason ∈ dial_failed /
|
||||
unknown_resource / resource_shortage / handler_error / timeout` —
|
||||
the SSH contract consumer-visibly (a failed open never returns a
|
||||
unknown_resource / resource_shortage / handler_error / timeout`,
|
||||
which is the SSH contract consumer-visibly (a failed open never returns a
|
||||
`channel_id`). No phantom channel, no establishment frame needed on
|
||||
the data stream.
|
||||
- Implementation note (ADR-049 amendment): the establisher takes
|
||||
`(input, auth)` only — the channel's yield-once `BiStream` belongs
|
||||
`(input, auth)` only, because the channel's yield-once `BiStream` belongs
|
||||
exclusively to the pump handler. **Amendment 2 (alkcall 0.6.0,
|
||||
review 007 R-01) filled the reserved `Establishment` field:**
|
||||
`Establishment::new(plan)` carries the dialed handle to the pump
|
||||
handler via its `plan` parameter (`ChannelPlan` — typed-opaque
|
||||
handler via its `plan` parameter (`ChannelPlan`, a typed-opaque
|
||||
`Arc<dyn Any + Send + Sync>`; downcast happens in this crate). The
|
||||
POC's side-channel `HandleHandoff` is dead — no handoff map, no
|
||||
POC's side-channel `HandleHandoff` is dead: no handoff map, no
|
||||
same-resource race; the establisher just returns
|
||||
`Ok(Establishment::new(dialed))`.
|
||||
- What remains for this crate's Phase 1 ADR (narrowed from the
|
||||
original frame-vocabulary question):
|
||||
- Half-open semantics: one direction EOFs, the other keeps pumping
|
||||
(standard two-pump behavior) — the only establishment/teardown
|
||||
(standard two-pump behavior): the only establishment/teardown
|
||||
question not answered upstream. Dial errors are fully
|
||||
establishment-phase now; byte-level EOFs stay per-direction
|
||||
(unchanged).
|
||||
@@ -740,7 +743,7 @@ channel stream.
|
||||
(post-establishment). Per ADR-049 §6's pinned posture,
|
||||
pump-phase failures are EOF-shaped by design; the SSH/SOCKS5
|
||||
survey found no prior art for in-stream control after
|
||||
establishment beyond udpgw's KEEPALIVE (dropped — channels owns
|
||||
establishment beyond udpgw's KEEPALIVE (dropped; channels owns
|
||||
liveness). v1 likely needs none; if a UDP flow table ever needs
|
||||
in-band signaling, that is a substrate-framing decision, not a
|
||||
base-wire one.
|
||||
@@ -755,42 +758,42 @@ The alknet-channels POC validated TCP only. Candidate targeted POCs
|
||||
Phase 0 may need (in rough priority order, per the SDD process's
|
||||
"validate promising approaches"):
|
||||
|
||||
1. **UDP tunnel POC** — boundary-preserving length framing over a
|
||||
1. **UDP tunnel POC**: boundary-preserving length framing over a
|
||||
channels channel, per-endpoint multiplexing inside one association,
|
||||
backpressure behavior. Partially derisked by the tun2proxy prior
|
||||
art (§Prior art) — the POC now mainly validates *channels-layer*
|
||||
art (§Prior art); the POC now mainly validates *channels-layer*
|
||||
fit: chunk-size vs datagram-size interaction, MTU cap against the
|
||||
channels bounded buffers, idle-expiry mapping, and the chosen
|
||||
endpoint-addressing shape. Derisks OQ-TN-02 (and OQ-TN-07's option B).
|
||||
|
||||
**Done 2026-09-06** — see `poc-summary.md`. 17 tests pass; the
|
||||
**Done 2026-09-06** (see `poc-summary.md`). 17 tests pass; the
|
||||
producer/consumer shape validated end-to-end over alkcall 0.5.0
|
||||
(establisher + two-pump + codec + typed establishment errors).
|
||||
2. **Reverse-flow POC** — `-R`-style: the accept side listens, the far
|
||||
side carries. Derisks OQ-TN-03's advertisement/lifecycle shape.
|
||||
2. **Reverse-flow POC** (`-R`-style: the accept side listens, the far
|
||||
side carries). Derisks OQ-TN-03's advertisement/lifecycle shape.
|
||||
Template available: SSH's `tcpip-forward` global-request
|
||||
registration → per-accept `forwarded-tcpip` opens → cancel
|
||||
(`ssh-socks5-survey.md` §RFC 4254 §7.1).
|
||||
|
||||
**Done 2026-09-07** — see `reverse-poc-summary.md`. 14 tests pass
|
||||
**Done 2026-09-07** (see `reverse-poc-summary.md`). 14 tests pass
|
||||
over alkcall 0.6.0; the review 007 non-finding trace confirmed by
|
||||
execution (`from_connection_with_serving` + `register_on` +
|
||||
serving-side allocation). The advertisement thread of OQ-TN-03
|
||||
closes: no advertisement op needed — the listener is the hub's own
|
||||
closes: no advertisement op needed, since the listener is the hub's own
|
||||
local resource; a far-side listener is a producer-side listen
|
||||
establisher over the same open op. Findings: F-1 (plan payloads
|
||||
must be `Send + Sync` — documented upstream in 0.7.0), F-2 (UDP
|
||||
raw pass-through cannot carry empty datagrams — the length-framed
|
||||
must be `Send + Sync`, documented upstream in 0.7.0), F-2 (UDP
|
||||
raw pass-through cannot carry empty datagrams, so the length-framed
|
||||
codec is mandatory, second validation of the codec decision). W1
|
||||
(connect-side serving path resolves identity only via payload
|
||||
`auth_token`) filed for the alkcall ledger — **RESOLVED by alkcall
|
||||
`auth_token`) filed for the alkcall ledger, **RESOLVED by alkcall
|
||||
0.7.0 (CF-005/CF-006)**; the POC re-validated over 0.7.0 (16
|
||||
tests): transport identity authorizes alone, `ServingConfig.
|
||||
identity` overrides, identity-less fails closed, and the
|
||||
establisher sees the per-call opener identity.
|
||||
3. **Unix socket + stdio bridge POC** — cheap; validates "substrate
|
||||
agnostic" beyond IP substrates.
|
||||
4. **Two-pump helper extraction spike** — OQ-TN-06, only after 1–3.
|
||||
3. **Unix socket + stdio bridge POC** (cheap; validates "substrate
|
||||
agnostic" beyond IP substrates).
|
||||
4. **Two-pump helper extraction spike** (OQ-TN-06, only after 1–3).
|
||||
|
||||
POC placement conventions (2026-09-05): a POC that needs code from
|
||||
this repo runs in a worktree/branch (`.worktrees/research/<task-id>/`
|
||||
@@ -801,7 +804,7 @@ into `docs/research/` here. Both are valid; pick per the POC's
|
||||
dependency footprint. Findings always land in `docs/research/`
|
||||
regardless of where the code lives.
|
||||
|
||||
**Status:** open — pick 1 (and probably 2) after the research pass;
|
||||
**Status:** open. Pick 1 (and probably 2) after the research pass;
|
||||
3 is cheap enough to fold into whichever POC runs first.
|
||||
|
||||
## Survey / prior-art list
|
||||
@@ -811,14 +814,14 @@ Candidate reading for the research specialist (to be expanded):
|
||||
- SSH channel/forwarding model: RFC 4254 §7 (direct-tcpip /
|
||||
forwarded-tcpip), OpenSSH `-L`/`-R`/`-D` semantics, russh's
|
||||
`ChannelOpen` framing (russh is already in `/workspace/russh`;
|
||||
confirmed 2026-09-05: no UDP channel support at all — no UDP prior
|
||||
confirmed 2026-09-05: no UDP channel support at all; no UDP prior
|
||||
art there, but useful for open-op framing intel).
|
||||
- SOCKS5 (RFC 1928): addressing (ATYP), UDP ASSOCIATE framing,
|
||||
per-endpoint multiplexing — the closest standardized "arbitrary
|
||||
per-endpoint multiplexing, the closest standardized "arbitrary
|
||||
tunnel + UDP" model. Relevant to the `-D` composition path
|
||||
(OQ-TN-03 residue), not the base open-op params (OQ-TN-01 reframe).
|
||||
- tun2proxy (`/workspace/tun2proxy`, `src/udpgw.rs`): UDP gateway over
|
||||
TCP — per-datagram length framing, SOCKS5 per-datagram addressing,
|
||||
TCP: per-datagram length framing, SOCKS5 per-datagram addressing,
|
||||
CONN_ID flow multiplexing, keepalive/ERR flag packets, MTU cap,
|
||||
idle expiry. Analyzed in §Prior art. Its `socks.rs` /
|
||||
`proxy_handler.rs` are also relevant for the `-D` (dynamic/SOCKS)
|
||||
@@ -842,22 +845,22 @@ Candidate reading for the research specialist (to be expanded):
|
||||
## Convergence checklist (what Phase 0 must produce)
|
||||
|
||||
- [x] Survey notes: SSH/SOCKS5 addressing + UDP framing
|
||||
(OQ-TN-01, OQ-TN-02) — tun2proxy UDP gateway (§Prior art) +
|
||||
(OQ-TN-01, OQ-TN-02): tun2proxy UDP gateway (§Prior art) +
|
||||
`ssh-socks5-survey.md` (SSH/SOCKS5, error vocabularies,
|
||||
endpoint-vs-per-datagram, anti-prior-art list)
|
||||
- [x] Reframe landed (OQ-TN-01) — params = self-contained JSON open-op
|
||||
- [x] Reframe landed (OQ-TN-01): params = self-contained JSON open-op
|
||||
object identifying a produced resource + substrate
|
||||
discriminator (`tcp`/`udp`/extensible); producer owns the
|
||||
backing; no URL-style general addressing. Residue: resource
|
||||
naming shape, exact JSON layout (Phase 1 ADR); discovery
|
||||
resolved (OQ-TN-08)
|
||||
- [x] Direction model resolved (OQ-TN-03) — hub-owns-the-connection
|
||||
- [x] Direction model resolved (OQ-TN-03), the hub-owns-the-connection
|
||||
model: role follows the resource, no protocol-level direction;
|
||||
`-D` composes at the assembly layer
|
||||
- [x] No-forced-binding requirement encoded (OQ-TN-04) — binding is
|
||||
- [x] No-forced-binding requirement encoded (OQ-TN-04): binding is
|
||||
always assembly-layer and optional; remaining work is the
|
||||
concrete produce/consume API surface sketch (spec task)
|
||||
- [x] Access control + discovery resolved (OQ-TN-08) — resource
|
||||
- [x] Access control + discovery resolved (OQ-TN-08): resource
|
||||
ownership assumed on the far side; alkcall's existing ACL
|
||||
applies as-is; ops listing (ADR-047) is tunnel-resource
|
||||
discovery; `-D` = "tunnel a socks5 connection" (target selection
|
||||
@@ -866,19 +869,19 @@ Candidate reading for the research specialist (to be expanded):
|
||||
`OperationSpec.description` (round-trips through discovery);
|
||||
the live resource-enumeration half (OQ-40) stays deferred and
|
||||
is not needed for v1
|
||||
- [x] Lifecycle/error resolved (OQ-TN-09) — superseded 2026-09-06 by
|
||||
- [x] Lifecycle/error resolved (OQ-TN-09): superseded 2026-09-06 by
|
||||
alkcall ADR-049 (0.5.0): establishment is the open op's awaited
|
||||
phase (`register_openable_with_establisher`); dial failure is a
|
||||
typed `channel:open_failed` call error (reason ∈ dial_failed /
|
||||
unknown_resource / resource_shortage / handler_error / timeout),
|
||||
never a phantom channel. Residual for Phase 1: half-open
|
||||
semantics only; v1 needs no mid-stream control frame
|
||||
- [x] Codec direction set (from the alktty pattern prior art) — raw
|
||||
- [x] Codec direction set (from the alktty pattern prior art): raw
|
||||
pass-through for stream substrates (0 B tunnel overhead),
|
||||
`[len:u16 BE]` per datagram for UDP (2 B); no 5-byte header (a
|
||||
tunnel has one data stream per direction — no sub-demux key
|
||||
needed). Residual for the Phase 1 ADR: empty-datagram
|
||||
(`len=0`) semantics — **strengthened 2026-09-07 (F-2): the
|
||||
tunnel has one data stream per direction, so no sub-demux key
|
||||
is needed). Residual for the Phase 1 ADR: empty-datagram
|
||||
(`len=0`) semantics. **Strengthened 2026-09-07 (F-2): the
|
||||
codec is mandatory for UDP** (empty datagram = zero-byte read
|
||||
= EOF collision in raw pass-through); the empty-datagram
|
||||
semantic under the codec is `len=0` = a legal empty datagram
|
||||
@@ -887,31 +890,31 @@ Candidate reading for the research specialist (to be expanded):
|
||||
OQ-TN-05 trait shape
|
||||
- [ ] Decision input: backend trait vs no-trait (OQ-TN-05), now
|
||||
including the hub re-produce composition question
|
||||
- [x] Targeted POC(s) run + summary (OQ-TN-10) — UDP POC DONE
|
||||
- [x] Targeted POC(s) run + summary (OQ-TN-10): UDP POC DONE
|
||||
(2026-09-06, `poc-summary.md`); reverse-flow POC DONE
|
||||
(2026-09-07, `reverse-poc-summary.md` — the `-R` shape validated
|
||||
(2026-09-07, `reverse-poc-summary.md`: the `-R` shape validated
|
||||
end-to-end over alkcall 0.6.0, re-validated over 0.7.0 with the
|
||||
CF-005/CF-006 identity remediation; findings F-1/F-2; W1 filed
|
||||
upstream and RESOLVED). Remaining items resolved in Phase 1:
|
||||
#3 split — unix ships with the `local` feature (OQ-TN-14);
|
||||
stdio is OUT (alktty's pipe mode owns process stdio — exit
|
||||
codes + signals are tty semantics); #4 resolved by alkcall
|
||||
#3 split, with unix shipping in the `local` feature (OQ-TN-14);
|
||||
stdio is OUT (alktty's pipe mode owns process stdio, since
|
||||
exit codes + signals are tty semantics); #4 resolved by alkcall
|
||||
0.6.0 (ADR-050)
|
||||
- [x] Two-pump helper convergence input gathered (OQ-TN-06) — the POC
|
||||
- [x] Two-pump helper convergence input gathered (OQ-TN-06): the POC
|
||||
gives both shapes (producer `pump_halves`, consumer
|
||||
`take_halves` + copy): same shape modulo channel side; the
|
||||
alknet ADR-078 convergence test is satisfied, extraction now
|
||||
decidable in Phase 1. **RESOLVED 2026-09-07:** the helper
|
||||
landed upstream in alkcall 0.6.0 as `channels::pump_bidi`
|
||||
(ADR-050, review 007 R-03) — this crate consumes it; see
|
||||
(ADR-050, review 007 R-03), which this crate consumes; see
|
||||
OQ-TN-06
|
||||
- [x] OQ-TN-05 executable input (backend trait vs no-trait) — POC
|
||||
- [x] OQ-TN-05 executable input (backend trait vs no-trait); the POC
|
||||
confirms stream substrates need no trait: `pump_halves` is
|
||||
generic over boxed halves (TCP `into_split`, UDP `UdpHalf`
|
||||
adapter); "produce halves for a resource" is a function. The
|
||||
hub re-produce composition question is the only remaining
|
||||
OQ-TN-05 thread
|
||||
- [x] Open questions promoted to Phase 1
|
||||
`docs/architecture/open-questions.md` with statuses — DONE
|
||||
`docs/architecture/open-questions.md` with statuses; DONE
|
||||
2026-09-07 (OQ-TN-01..10 promoted as OQ-TN-01..14, including the
|
||||
Phase 1 residues; the convergence checklist is complete)
|
||||
@@ -5,18 +5,18 @@ last_updated: 2026-09-07
|
||||
|
||||
# alktunnels: UDP Tunnel POC Research Summary
|
||||
|
||||
**Status:** Research complete — the producer/consumer shape validated
|
||||
**Status:** Research complete. The producer/consumer shape validated
|
||||
end-to-end over alkcall 0.5.0 channels, including the ADR-049
|
||||
establishment phase and the codec decision (raw pass-through for TCP,
|
||||
`[len: u16 BE]` for UDP). 17 tests pass (7 codec + 10 integration);
|
||||
clippy `-D warnings` clean; fmt clean. The remaining unknowns are
|
||||
spec-scope (Phase 1 ADRs), not feasibility. Both upstream findings
|
||||
below landed in alkcall 0.6.0 (review 007) — see §Resolutions
|
||||
below landed in alkcall 0.6.0 (review 007); see §Resolutions
|
||||
2026-09-07.
|
||||
**Date:** 2026-09-06 (resolutions added 2026-09-07)
|
||||
**Scope:** Validates the OQ-TN-10 #1 POC (UDP tunnel) — plus the
|
||||
**Scope:** Validates the OQ-TN-10 #1 POC (UDP tunnel), plus the
|
||||
consumer-side of the establisher hand-off (ADR-049), the two-pump
|
||||
pump-handler contract's `JoinHandle` semantics (a real finding — see
|
||||
pump-handler contract's `JoinHandle` semantics (a real finding; see
|
||||
§Issues Surfaced), and the OQ-TN-05 "no backend trait for stream
|
||||
substrates" judgment in executable form.
|
||||
|
||||
@@ -27,34 +27,34 @@ substrates" judgment in executable form.
|
||||
A POC (`alktunnels-udp-poc`, `/workspace/alktunnels-udp-poc`)
|
||||
validated the alktunnels producer/consumer shape end-to-end:
|
||||
|
||||
1. **The producer shape** — `channels/tunnel/sub` open op registered
|
||||
via `ChannelCore::register_openable_with_establisher` (ADR-049):
|
||||
1. **The producer shape**: `channels/tunnel/sub` open op registered
|
||||
via `ChannelCore::register_openable_with_establisher` (ADR-049);
|
||||
the establisher validates params semantically (registry lookup of
|
||||
`{resource, substrate}` → backing address, producer-side), dials
|
||||
the substrate (TCP connect / UDP connect), and delivers the dialed
|
||||
handle to the pump handler via a handoff; the wrapper replies only
|
||||
after establishment. A refused dial is a typed
|
||||
`channel:open_failed` call error with
|
||||
`details.reason ∈ {unknown_resource, dial_failed, resource_shortage}`
|
||||
— the SSH contract consumer-visibly, verified from the consumer
|
||||
`details.reason ∈ {unknown_resource, dial_failed, resource_shortage}`,
|
||||
which is the SSH contract consumer-visibly, verified from the consumer
|
||||
side (`TunnelSession::open` resolving `Err` with the reason).
|
||||
2. **The two-pump data plane** — the pump handler accepts the
|
||||
2. **The two-pump data plane**: the pump handler accepts the
|
||||
channel's yield-once `BiStream`, splits it, and runs two
|
||||
`tokio::io::copy` pumps with shutdown-on-completion (alknet ADR-078)
|
||||
against the substrate halves. 1 MiB round-trips through the
|
||||
two-pump shape with the bounded-buffer backpressure path intact.
|
||||
3. **The codec decision** — raw pass-through for TCP (zero
|
||||
3. **The codec decision**: raw pass-through for TCP (zero
|
||||
tunnel-level framing; the channels 8-byte header is the only
|
||||
overhead) and `[len: u16 BE][datagram]` for UDP (2 bytes/datagram).
|
||||
Datagram boundaries survive chunk splitting, batching, and empty
|
||||
datagrams (`len=0`) are legal payloads, NOT EOF — the EOF sentinel
|
||||
datagrams (`len=0`) are legal payloads, NOT EOF. The EOF sentinel
|
||||
lives at the channels layer (`BiStream` EOF) and the two never
|
||||
collide (different layers).
|
||||
4. **The consumer shape** — `TunnelSession::open` →
|
||||
4. **The consumer shape**: `TunnelSession::open` →
|
||||
`ChannelClient::open_channel` → split the channel `BiStream` →
|
||||
present raw halves (TCP) or framed datagrams (UDP). One session =
|
||||
one channel = one tunnel; the channel ID is the flow key (no
|
||||
conn-id — OQ-TN-02 resolution holds).
|
||||
conn-id, so OQ-TN-02 resolution holds).
|
||||
|
||||
**17 tests pass** (7 wire codec + 10 integration: TCP echo, TCP 1 MiB,
|
||||
UDP boundary preservation incl. empty datagrams, UDP-through-chunk-
|
||||
@@ -68,29 +68,29 @@ channels on one connection). Clippy clean, fmt clean.
|
||||
|
||||
```
|
||||
alktunnels-udp-poc/
|
||||
Cargo.toml — alkcall 0.5.0; tokio wasm-clean subset + net (POC-local)
|
||||
Cargo.toml : alkcall 0.5.0; tokio wasm-clean subset + net (POC-local)
|
||||
src/
|
||||
lib.rs — module docs; the POC overview
|
||||
params.rs — TunnelParams {resource, substrate} (the OQ-TN-01 shape)
|
||||
wire.rs — the codec: frame_datagram / DatagramReader
|
||||
lib.rs : module docs; the POC overview
|
||||
params.rs : TunnelParams {resource, substrate} (the OQ-TN-01 shape)
|
||||
wire.rs : the codec: frame_datagram / DatagramReader
|
||||
([len: u16 BE] framing; incremental decode across
|
||||
chunk boundaries; len=0 = empty datagram, not EOF)
|
||||
producer.rs — tunnel_open_spec (scope-gated, Sub-typed, channel_open
|
||||
producer.rs : tunnel_open_spec (scope-gated, Sub-typed, channel_open
|
||||
marker), tunnel_establisher (registry lookup + dial +
|
||||
handoff), HandleHandoff (resource-keyed), pump_halves
|
||||
(the ADR-078 two-pump shape, generic over halves),
|
||||
make_tunnel_pump_handler (the OpenHandler),
|
||||
register_tunnel_openable (the wiring), UdpHalf
|
||||
(UdpSocket as AsyncRead/AsyncWrite halves)
|
||||
consumer.rs — TunnelSession (typed client): open / stream_halves /
|
||||
consumer.rs : TunnelSession (typed client): open / stream_halves /
|
||||
send_datagram / recv_datagram / take_halves;
|
||||
establishment_reason (the ADR-049 typed-error branch)
|
||||
harness.rs — wire_tunnel (the alktty testing.rs pattern: duplex
|
||||
harness.rs : wire_tunnel (the alktty testing.rs pattern: duplex
|
||||
transport, ChannelsAdapter, install_channel_zero hook,
|
||||
register_tunnel_openable on the hook's registry,
|
||||
tunnel_identity with the tunnel:open scope)
|
||||
tests/
|
||||
tunnel_poc.rs — the 10 integration tests
|
||||
tunnel_poc.rs : the 10 integration tests
|
||||
```
|
||||
|
||||
The POC follows the `alknet-channels-poc` convention: standalone crate
|
||||
@@ -99,7 +99,7 @@ crate's `docs/research/`.
|
||||
|
||||
## Key findings
|
||||
|
||||
### 1. The OpenHandler JoinHandle contract — the pump handler MUST return the pump's own handle
|
||||
### 1. The OpenHandler JoinHandle contract: the pump handler MUST return the pump's own handle
|
||||
|
||||
The most important finding of the POC, and it was found by the POC
|
||||
hanging, not by reading: **the `OpenHandler`'s returned `JoinHandle`
|
||||
@@ -108,18 +108,18 @@ awaits it, then tears the channel down (drop the demux sender → EOF to
|
||||
the handler's read half → the pumps exit). A handler that spawns the
|
||||
pump as a *nested* task and returns the outer handle immediately
|
||||
completes the wrapper's await at birth → teardown → both pumps see
|
||||
instant EOF with zero bytes — every tunnel appears to "connect and
|
||||
instant EOF with zero bytes, so every tunnel appears to "connect and
|
||||
instantly close" while everything upstream looks healthy.
|
||||
|
||||
```rust
|
||||
// WRONG — outer task returns immediately; wrapper tears down at birth:
|
||||
// WRONG: outer task returns immediately; wrapper tears down at birth:
|
||||
tokio::spawn(async move {
|
||||
let bidi = conn.accept_bi().await?;
|
||||
tokio::spawn(pump_halves(bidi, read, write)); // fire-and-forget
|
||||
// <- handler ends here; wrapper's raw_task.await resolves NOW
|
||||
})
|
||||
|
||||
// RIGHT — the returned JoinHandle tracks the pump's lifetime:
|
||||
// RIGHT: the returned JoinHandle tracks the pump's lifetime:
|
||||
tokio::spawn(async move {
|
||||
let bidi = conn.accept_bi().await?;
|
||||
let _ = pump_halves(bidi, read, write).await; // awaited inline
|
||||
@@ -127,7 +127,7 @@ tokio::spawn(async move {
|
||||
```
|
||||
|
||||
This is an API-sharpness finding for alkcall (the `OpenHandler`
|
||||
contract's `JoinHandle` semantics are load-bearing and undocumented —
|
||||
contract's `JoinHandle` semantics are load-bearing and undocumented;
|
||||
the type docs say "so the wrapper can record it for teardown (abort on
|
||||
channel/close / connection drop)" but not that an early return *is*
|
||||
an early teardown). Worth an upstream doc note; the shape itself is
|
||||
@@ -136,13 +136,13 @@ correct. Filed as a follow-up for the alkcall review ledger.
|
||||
The pump shape itself (ADR-078) is unchanged and correct: each pump
|
||||
shuts down the opposite sink on completion; `tokio::join!` on the two
|
||||
pumps; the wrapper's teardown on pump completion drops the opposite
|
||||
direction's sender — half-open semantics fall out naturally (one
|
||||
direction's sender, and half-open semantics fall out naturally (one
|
||||
direction EOFs, the other keeps pumping until its own EOF).
|
||||
|
||||
### 2. The establisher hand-off works; its shape is POC-grade only
|
||||
|
||||
The ADR-049 amendment says the establisher gets `(input, auth)` only —
|
||||
the channel's `BiStream` belongs to the pump handler. The POC's
|
||||
The ADR-049 amendment says the establisher gets `(input, auth)` only,
|
||||
for the channel's `BiStream` belongs to the pump handler. The POC's
|
||||
`HandleHandoff` (a `Mutex<HashMap<resource, SubstrateHandle>>` + poll
|
||||
loop in `take`) works because the wrapper *guarantees* ordering: the
|
||||
establisher is awaited before the reply, and the pump handler is
|
||||
@@ -153,11 +153,11 @@ one open per resource at a time (concurrent opens of the same resource
|
||||
would race the slot). The real crate's mechanism should be the
|
||||
ADR-049 reserved extension point: **`Establishment` should carry the
|
||||
channel plan** (the struct is already documented as "Reserved for a
|
||||
channel plan — today the wrapper consults only success/failure"). When
|
||||
channel plan; today the wrapper consults only success/failure"). When
|
||||
alkcall extends `Establishment` to carry data, the handoff mechanism
|
||||
dies entirely and the establisher returns the dialed handle in its
|
||||
result. That is the recommended upstream evolution (small, additive —
|
||||
`Establishment {}` → `Establishment { plan: Option<Value> }` or a
|
||||
result. That is the recommended upstream evolution (small, additive,
|
||||
with `Establishment {}` → `Establishment { plan: Option<Value> }` or a
|
||||
generic), and it removes the last awkwardness of the establisher
|
||||
shape. Filed as a follow-up for the alkcall ledger.
|
||||
|
||||
@@ -170,17 +170,17 @@ shape. Filed as a follow-up for the alkcall ledger.
|
||||
end-to-end with no deadlock (the alknet-channels POC's
|
||||
`tunnel_large_payload` result reproduces on alkcall 0.5.0).
|
||||
- **UDP: `[len: u16 BE]` framing.** `DatagramReader` is an
|
||||
incremental decoder — datagrams split across chunks, datagrams
|
||||
incremental decoder: datagrams split across chunks, datagrams
|
||||
batched in one chunk, partial headers, and empty datagrams all
|
||||
round-trip (verified at the codec level with maximally awkward
|
||||
7-byte chunk splits, and end-to-end through the channels stack).
|
||||
- **Sentinel layering verified:** `len == 0` in the codec is an empty
|
||||
datagram (DNS-over-TCP-style; round-trips through a UDP echo);
|
||||
EOF is the channels-level `length=0` chunk on the `BiStream`. The
|
||||
two coexist without ambiguity — the consumer's `recv_datagram`
|
||||
two coexist without ambiguity, because the consumer's `recv_datagram`
|
||||
returns `None` only on stream EOF, never on an empty datagram.
|
||||
**Mandate strengthened 2026-09-07** (reverse POC F-2): without the
|
||||
codec (raw pass-through), an empty datagram IS a zero-byte read —
|
||||
codec (raw pass-through), an empty datagram IS a zero-byte read,
|
||||
colliding with EOF at the pump level. UDP must ride the codec.
|
||||
- **MTU discipline:** the codec rejects >65535 at frame time; the
|
||||
1400-byte (max ethernet MTU payload) datagram round-trips through
|
||||
@@ -193,7 +193,7 @@ shape. Filed as a follow-up for the alkcall ledger.
|
||||
The consumer branches on `ChannelOpenError::CallFailed { error }`:
|
||||
`error.code == "channel:open_failed"` with
|
||||
`details.reason` ∈ `{unknown_resource, dial_failed, resource_shortage}`
|
||||
(from the establisher) — plus the wrapper-level `timeout` reason and
|
||||
(from the establisher), plus the wrapper-level `timeout` reason and
|
||||
the pre-allocation failures (`FORBIDDEN` for the scope gate,
|
||||
`channel:too_many_channels` for the cap). All verified in tests:
|
||||
|
||||
@@ -208,10 +208,11 @@ The SSH contract holds end-to-end: a failed open never returns a
|
||||
|
||||
`pump_halves` is generic over `(AsyncRead, AsyncWrite)` halves. TCP
|
||||
contributes `OwnedReadHalf`/`OwnedWriteHalf` (`TcpStream::into_split`);
|
||||
UDP contributes `UdpHalf` (the POC's `UdpSocket` adapter — `poll_recv`
|
||||
UDP contributes `UdpHalf` (the POC's `UdpSocket` adapter, with
|
||||
`poll_recv`
|
||||
as `AsyncRead`, `poll_send` as `AsyncWrite`, connected-socket
|
||||
semantics). The pump never knows which. The "backend" question
|
||||
collapses to "produce boxed halves for a resource" — a function, not a
|
||||
collapses to "produce boxed halves for a resource", a function, not a
|
||||
trait, for everything the producer dials. The remaining OQ-TN-05
|
||||
question (whether the *hub's* re-produce proxy needs a trait) is
|
||||
unaffected by this finding; it was always about composition, not
|
||||
@@ -239,14 +240,14 @@ substrate access.
|
||||
into a smaller caller buffer truncates the datagram silently
|
||||
(UDP semantics). For well-formed peers the codec's MTU bound makes
|
||||
this unreachable; the Phase 1 spec should pin whether the real
|
||||
crate's UDP adapter errors on truncation instead (fail loud) —
|
||||
crate's UDP adapter errors on truncation instead (fail loud);
|
||||
recommend erroring, since truncation would corrupt the length-
|
||||
framed stream invariants. → Phase 1 ADR note.
|
||||
4. **Consumer-side pump symmetry** (alktunnels Phase 1): the consumer
|
||||
mirrors the producer's pump shape (`tokio::io::copy` between
|
||||
local substrate and channel halves, shutdown-on-completion). The
|
||||
POC's `TunnelSession::take_halves` exists precisely so the assembly
|
||||
layer can pump generically — the OQ-TN-06 helper extraction
|
||||
layer can pump generically, so the OQ-TN-06 helper extraction
|
||||
question can now be evaluated with both shapes in hand (producer
|
||||
`pump_halves`, consumer `take_halves` + copy): they are the same
|
||||
shape modulo channel side. The convergence test alknet ADR-078
|
||||
@@ -257,7 +258,7 @@ substrate access.
|
||||
Both upstream findings above landed, with deviations recorded in the
|
||||
review and ADRs:
|
||||
|
||||
1. **JoinHandle semantics (→ R-02)** — documented on the `OpenHandler`
|
||||
1. **JoinHandle semantics (→ R-02)**, documented on the `OpenHandler`
|
||||
type and both `register_openable*` methods: the returned
|
||||
`JoinHandle` must track the data-plane lifetime; the wrapper awaits
|
||||
it and its completion triggers channel teardown. Plus the POC's
|
||||
@@ -265,49 +266,52 @@ review and ADRs:
|
||||
`ChannelBidiStreamSource` and a `debug!` birth-teardown hint in the
|
||||
teardown task, so the hang-this-POC-found shape is diagnosable at
|
||||
runtime, not just in docs.
|
||||
2. **`Establishment` carries the plan (→ R-01)** — filled as
|
||||
2. **`Establishment` carries the plan (→ R-01)**, filled as
|
||||
`Establishment { plan: Option<ChannelPlan> }` with
|
||||
`Establishment::new(plan)`; **typed-opaque** (`ChannelPlan =
|
||||
Arc<dyn Any + Send + Sync>`), not the `Option<Value>` sketch here —
|
||||
the payloads are live handles (dialed sockets, TTY handles) with no
|
||||
Arc<dyn Any + Send + Sync>`), not the `Option<Value>` sketch here,
|
||||
because the payloads are live handles (dialed sockets, TTY handles)
|
||||
with no
|
||||
JSON representation. The `OpenHandler` gains a `plan` parameter and
|
||||
the wrapper threads it. The POC's `HandleHandoff` side-channel and
|
||||
its same-resource race are dead: the establisher returns the dialed
|
||||
handle directly (`Ok(Establishment::new(dialed))`). **Confirmed
|
||||
under concurrency 2026-09-07** by the reverse-flow POC
|
||||
(`reverse-poc-summary.md`): two concurrent opens of the same
|
||||
resource, distinct channels, distinct dialed handles — the race
|
||||
resource, distinct channels, distinct dialed handles, so the race
|
||||
this handoff had is structurally gone. (F-1 there: plan payloads
|
||||
must be `Send + Sync`.)
|
||||
3. **Two-pump helper (→ R-03, bonus)** — `alkcall::channels::pump_bidi`
|
||||
3. **Two-pump helper (→ R-03, bonus)**: `alkcall::channels::pump_bidi`
|
||||
pinned upstream (ADR-050), with `(u64, u64)` copy counts (no
|
||||
`io::Result` — errors are EOF-shaped per the ADR-078 contract). The
|
||||
`io::Result`, since errors are EOF-shaped per the ADR-078
|
||||
contract). The
|
||||
POC's `pump_halves` shape converged and became the helper; Phase 1
|
||||
consumes it instead of hand-rolling.
|
||||
|
||||
## What the POC does NOT validate
|
||||
|
||||
1. **Real transports** — `tokio::io::duplex` stands in for
|
||||
1. **Real transports**: `tokio::io::duplex` stands in for
|
||||
TCP/TLS/QUIC. The alkcall layer owns transport; unchanged from the
|
||||
alknet-channels POC scope.
|
||||
2. **Unix-socket and stdio substrates** — the OQ-TN-10 #3 "cheap"
|
||||
2. **Unix-socket and stdio substrates**: the OQ-TN-10 #3 "cheap"
|
||||
item. The pump is substrate-agnostic by construction (finding #5);
|
||||
these are wiring, not mechanics. Fold into the real crate's test
|
||||
suite rather than a separate POC. (Phase 1 resolution: unix in via
|
||||
the `local` feature; stdio OUT — alktty's pipe mode owns process
|
||||
the `local` feature; stdio OUT, since alktty's pipe mode owns
|
||||
process
|
||||
stdio. OQ-TN-14.)
|
||||
3. **The reverse-flow (`-R`) advertisement** — OQ-TN-10 #2 remains
|
||||
3. **The reverse-flow (`-R`) advertisement**: OQ-TN-10 #2 remains
|
||||
open; the SSH `tcpip-forward` template in the survey is the
|
||||
lifecycle shape to validate there.
|
||||
4. **Relay traversal** — hub relay (alkcall ADR-042) forwards data
|
||||
4. **Relay traversal**: hub relay (alkcall ADR-042) forwards data
|
||||
channels byte-for-byte; the POC's single-hop shape is what
|
||||
relays see per leg. The terminate-and-re-produce hub proxy (the
|
||||
hub model) is an assembly-layer concern.
|
||||
5. **Multi-endpoint UDP gateway resources** — the udpgw-shaped
|
||||
5. **Multi-endpoint UDP gateway resources**: the udpgw-shaped
|
||||
framing-inside-a-channel variant (OQ-TN-02's "split by path"
|
||||
dynamic half). Not a v1 base-protocol concern; the codec keeps the
|
||||
door open (framing inside datagram payloads, self-describing).
|
||||
6. **Wasm** — the POC uses `tokio/net` for real sockets; the
|
||||
6. **Wasm**: the POC uses `tokio/net` for real sockets; the
|
||||
wasm-clean story is for the real crate's protocol-only core (the
|
||||
codec and pump are pure byte/async work; `UdpHalf` and the dial
|
||||
paths are the feature-gated parts). Not exercised here.
|
||||
@@ -339,22 +343,22 @@ test udp_and_tcp_concurrent ... ok
|
||||
|
||||
## References
|
||||
|
||||
- alkcall 0.5.0 — ADR-049 (establishment phase),
|
||||
- alkcall 0.5.0: ADR-049 (establishment phase),
|
||||
`ChannelCore::register_openable_with_establisher`,
|
||||
`OpenEstablisher`/`Establishment`/`EstablishmentError`,
|
||||
`ChannelOpenError` (typed client error), `run_open_wrapper`
|
||||
(`src/channels/operations.rs`). **0.6.0 supersedes parts:** ADR-049
|
||||
amendment 2 (`Establishment.plan`), ADR-050 (`pump_bidi`), review
|
||||
007 (filed from this POC, all units landed).
|
||||
- alknet ADR-078 — the two-pump shutdown-on-completion contract;
|
||||
- alknet ADR-078, the two-pump shutdown-on-completion contract;
|
||||
`pump_halves` is its direct implementation.
|
||||
- alktunnels `docs/research/phase-0-findings.md` — the OQ ledger this
|
||||
- alktunnels `docs/research/phase-0-findings.md`, the OQ ledger this
|
||||
POC closes items against (OQ-TN-01 params shape, OQ-TN-02 split by
|
||||
path, OQ-TN-05 halves-not-trait, OQ-TN-09 ADR-049 supersession,
|
||||
OQ-TN-10 #1).
|
||||
- `docs/research/ssh-socks5-survey.md` — the reason-code vocabulary
|
||||
- `docs/research/ssh-socks5-survey.md`, the reason-code vocabulary
|
||||
the establisher errors surface as.
|
||||
- alktty `src/testing.rs` — the harness pattern this POC's
|
||||
- alktty `src/testing.rs`, the harness pattern this POC's
|
||||
`wire_tunnel` mirrors.
|
||||
- tun2proxy `src/udpgw.rs` — the framing precedent the UDP codec
|
||||
- tun2proxy `src/udpgw.rs`, the framing precedent the UDP codec
|
||||
improves on (no CONN_ID, no FLAGS; boundary framing only).
|
||||
@@ -5,19 +5,19 @@ last_updated: 2026-09-07
|
||||
|
||||
# alktunnels: Reverse-Flow (`-R`) Tunnel POC Research Summary
|
||||
|
||||
**Status:** Research complete — the `-R` shape (the hub opens tunnel
|
||||
**Status:** Research complete. The `-R` shape (the hub opens tunnel
|
||||
channels *toward* a connect-side worker that serves its own open op)
|
||||
validated end-to-end over alkcall 0.6.0 (2026-09-07 pass, 14 tests)
|
||||
and re-validated over alkcall 0.7.0 (16 tests) after the CF-005/CF-006
|
||||
identity remediation landed — the W1 `auth_token` workaround is dead;
|
||||
identity remediation landed, so the W1 `auth_token` workaround is dead;
|
||||
the primary caller-identity path is transport identity. Clippy
|
||||
`-D warnings` clean; fmt clean. The review 007 non-finding trace
|
||||
("no upstream mechanism is missing") is confirmed by execution —
|
||||
and the POC surfaced two real findings the forward POC could not
|
||||
(one an upstream sharpness question — F-1, documented upstream in
|
||||
0.7.0; one a spec-mandate — F-2).
|
||||
("no upstream mechanism is missing") is confirmed by execution, and
|
||||
the POC surfaced two real findings the forward POC could not
|
||||
(one an upstream sharpness question, F-1, documented upstream in
|
||||
0.7.0; one a spec-mandate, F-2).
|
||||
**Date:** 2026-09-07
|
||||
**Scope:** Validates the OQ-TN-10 #2 POC — the SSH `tcpip-forward`
|
||||
**Scope:** Validates the OQ-TN-10 #2 POC: the SSH `tcpip-forward`
|
||||
template (register → per-accept channel-open → cancel) mapped onto
|
||||
alkcall's both-sides serving semantics. Probes four wrinkles (W1..W4)
|
||||
selected from the mechanism trace; findings F-1/F-2 below.
|
||||
@@ -28,28 +28,28 @@ selected from the mechanism trace; findings F-1/F-2 below.
|
||||
|
||||
A POC (`alktunnels-reverse-poc`, `/workspace/alktunnels-reverse-poc`)
|
||||
validated the reverse flow end-to-end over alkcall 0.6.0 (re-validated
|
||||
over 0.7.0 — see §W1):
|
||||
over 0.7.0; see §W1):
|
||||
|
||||
1. **The worker (connect side, serving side)** — dials the transport,
|
||||
1. **The worker (connect side, serving side)** dials the transport,
|
||||
`ChannelClient::from_connection_with_serving` +
|
||||
`ChannelOperations::register_on` (generic channel ops on the
|
||||
serving registry, so the hub can `channel/close`) + post-hoc
|
||||
`register_openable_with_establisher` (W2). Its establisher dials
|
||||
the resource target and returns the handle via
|
||||
`Establishment::new(plan)` — R-01's plan flow, no handoff map.
|
||||
`Establishment::new(plan)`, which is R-01's plan flow with no handoff map.
|
||||
Its pump handler awaits `alkcall::channels::pump_bidi` inline
|
||||
(R-02/R-03). This is ADR-022 §2 both-sides semantics exercised
|
||||
for real: connect side AND serving side.
|
||||
2. **The hub (accept side, reverse-flow initiator)** —
|
||||
`ChannelsAdapter` + install hook that captures the hub's channel-0
|
||||
2. **The hub (accept side, reverse-flow initiator)** uses
|
||||
`ChannelsAdapter` + an install hook that captures the hub's channel-0
|
||||
`CallConnection` and `ChannelManager`. Per local accept: call the
|
||||
worker's open op (`call_with_payload` — the caller identity rides
|
||||
the transport identity by default (0.7.0); the payload
|
||||
worker's open op (`call_with_payload`; the caller identity rides
|
||||
the transport identity by default (0.7.0), and the payload
|
||||
`auth_token` is the optional hub-forwarding path), adopt the
|
||||
worker-allocated channel ID, pump the accepted socket against the
|
||||
adopted halves via `pump_bidi` spawned locally.
|
||||
3. **The ADR-047 §5 orientation under test holds:** the reverse
|
||||
channels are allocated by the worker (connect side — odd IDs) and
|
||||
channels are allocated by the worker (connect side, odd IDs) and
|
||||
adopted by the hub (accept side). "Connection owner allocates"
|
||||
exercised in the Pub-like orientation.
|
||||
4. **16 tests (0.7.0 pass):** TCP round trip, 1 MiB backpressure,
|
||||
@@ -64,53 +64,53 @@ over 0.7.0 — see §W1):
|
||||
late-registration visibility, and two finding-pinning tests
|
||||
(F-1 compiler-pinned, F-2 below).
|
||||
|
||||
## Wrinkle probes (W1..W4) — results
|
||||
## Wrinkle probes (W1..W4): results
|
||||
|
||||
- **W1 identity on the serving path — CONFIRMED 2026-09-07 against
|
||||
- **W1 identity on the serving path, CONFIRMED 2026-09-07 against
|
||||
0.6.0; RESOLVED by alkcall 0.7.0 (ledger CF-005 + CF-006).**
|
||||
Original finding: `from_connection_with_serving` built channel 0
|
||||
internally; its `Connection::identity()` was empty and
|
||||
`ServingConfig` had no auth capture point — the serving dispatcher
|
||||
`ServingConfig` had no auth capture point; the serving dispatcher
|
||||
resolved the caller identity ONLY from the payload `auth_token` →
|
||||
`ServingConfig.identity_provider`, and the establisher/pump handler
|
||||
received the install-time `AuthContext`, never the per-call opener.
|
||||
**The 0.7.0 remediation (validated from this POC's topology, 16
|
||||
tests):**
|
||||
- `ServingConfig.identity: Option<Identity>` — the explicit
|
||||
- `ServingConfig.identity: Option<Identity>`, the explicit
|
||||
override (remediation (a)).
|
||||
- The transport `Connection::identity()` (set before dialing — the
|
||||
- The transport `Connection::identity()` (set before dialing, the
|
||||
mTLS/QUIC posture) propagates to channel 0 automatically
|
||||
(remediation (b)). Validated: the POC's default harness now
|
||||
authorizes the scope-gated open on transport identity ALONE — no
|
||||
token payload.
|
||||
authorizes the scope-gated open on transport identity ALONE, with
|
||||
no token payload.
|
||||
- Precedence (alkcall cf005 tests + POC probes): payload token >
|
||||
`ServingConfig.identity` > transport identity; none → fail-closed
|
||||
`FORBIDDEN` (validated).
|
||||
- CF-006 corollary: the establisher/pump handler now receive the
|
||||
**per-call** opener identity (validated via an identity witness —
|
||||
**per-call** opener identity (validated via an identity witness:
|
||||
the establisher saw `hub`, and the override identity under (a)).
|
||||
On hub-forwarded opens the establisher sees the end client, not
|
||||
the hub.
|
||||
Status: resolved — the POC's `auth_token` workaround is dead code;
|
||||
Status: resolved, with the POC's `auth_token` workaround dead code;
|
||||
the primary path is transport identity.
|
||||
- **W2 post-hoc openable registration — works.** The worker registers
|
||||
- **W2 post-hoc openable registration works.** The worker registers
|
||||
its openable AFTER `from_connection_with_serving` returns; the
|
||||
dispatcher reads through the shared `Arc<OperationRegistry>` per
|
||||
dispatch. Proven by every open succeeding + a direct
|
||||
`registration()` assertion. No finding — the assembly order
|
||||
`registration()` assertion. No finding: the assembly order
|
||||
(construct client → register ops → serve) is viable as-is.
|
||||
- **W3 adopter self-reaping — confirmed asymmetry, POC-local fix.**
|
||||
- **W3 adopter self-reaping, confirmed asymmetry with a POC-local fix.**
|
||||
The serving side's channel is wrapper-managed (teardown cascades on
|
||||
pump completion or out-of-band close). The ADOPTING side has no
|
||||
such machinery: `adopt_channel` installs routing state nothing
|
||||
awaits. The hub must hold its pump handle and reap
|
||||
(`teardown_channel`) itself — `ReverseTunnel::join_and_reap`/`close`
|
||||
in the POC. Spec note: the real crate's consumer half needs a
|
||||
(`teardown_channel`) itself (`ReverseTunnel::join_and_reap`/`close`
|
||||
in the POC). Spec note: the real crate's consumer half needs a
|
||||
session type with the same ownership (the adopted channel + pump
|
||||
handle + reaper). Not an upstream gap (the hub's lifecycle is
|
||||
assembly-layer by design, OQ-TN-04) — but it is the reason
|
||||
assembly-layer by design, OQ-TN-04), but it is the reason
|
||||
`TunnelSession` in the spec must own teardown explicitly.
|
||||
- **W4 EOF sentinel propagation — works both ways.** Half-close
|
||||
- **W4 EOF sentinel propagation works both ways.** Half-close
|
||||
semantics test: local `shutdown()` → EOF sentinel into the mux →
|
||||
worker pump sees the zero-length read → shuts the target write
|
||||
down; the target's echo still flows back before the reverse leg
|
||||
@@ -119,11 +119,11 @@ over 0.7.0 — see §W1):
|
||||
|
||||
## Findings
|
||||
|
||||
### F-1 — `ChannelPlan`'s `Send + Sync` bound constrains plan payloads (compiler-pinned)
|
||||
### F-1: `ChannelPlan`'s `Send + Sync` bound constrains plan payloads (compiler-pinned)
|
||||
|
||||
`ChannelPlan = Arc<dyn Any + Send + Sync>` (ADR-049 amendment 2).
|
||||
A dialed-socket handle whose halves are
|
||||
`Box<dyn AsyncRead + Send + Unpin>` does NOT satisfy the bound —
|
||||
`Box<dyn AsyncRead + Send + Unpin>` does NOT satisfy the bound:
|
||||
`Arc<T>` requires `T: Send + Sync`, and `dyn AsyncRead` is not
|
||||
`Sync` by default. The POC widened the halves to `+ Sync` (socket
|
||||
halves are `Sync`, so dial-shaped payloads are unaffected).
|
||||
@@ -132,26 +132,26 @@ be `Sync`, which is fine for socket-backed substrates but would
|
||||
exclude non-Sync handles (e.g. process pipes as boxed trait objects)
|
||||
without a wrapper. **Documented upstream 2026-09-07 (alkcall 0.7.0):
|
||||
the `ChannelPlan` type doc now carries the `Send + Sync` payload
|
||||
constraint.** No further action needed — the bound is correct (the
|
||||
constraint.** No further action needed; the bound is correct (the
|
||||
plan crosses the wrapper task boundary).
|
||||
|
||||
### F-2 — empty UDP datagrams collide with the EOF sentinel in raw pass-through (spec-mandate)
|
||||
### F-2: empty UDP datagrams collide with the EOF sentinel in raw pass-through (spec-mandate)
|
||||
|
||||
The raw pass-through pump shape (`tokio::io::copy` between the UdpHalf
|
||||
adapter and the channel) cannot carry an empty datagram: `poll_recv`
|
||||
returning zero bytes is `Ok(0)` from the adapter — indistinguishable
|
||||
returning zero bytes is `Ok(0)` from the adapter, indistinguishable
|
||||
from EOF for `copy`, which treats it as end-of-stream and shuts the
|
||||
pump down. An empty datagram and the zero-length EOF sentinel are the
|
||||
SAME wire shape at the pump level. Pinned by test
|
||||
(`reverse_udp_empty_datagram_finding` asserts the non-round-trip so
|
||||
the conclusion is executable, not anecdotal).
|
||||
Consequence: the forward POC's codec decision — `[len: u16 BE]`
|
||||
framing for UDP — is now validated from a SECOND angle: not only does
|
||||
Consequence: the forward POC's codec decision (`[len: u16 BE]`
|
||||
framing for UDP) is now validated from a SECOND angle: not only does
|
||||
it preserve boundaries, it is MANDATORY for correctness (an empty
|
||||
datagram is 2 bytes under the codec, unambiguous with EOF). The
|
||||
Phase 1 spec must state: UDP rides the length-framed codec, never raw
|
||||
pass-through. Stream substrates (TCP, unix) keep raw pass-through —
|
||||
a zero-length read there is genuinely EOF.
|
||||
pass-through. Stream substrates (TCP, unix) keep raw pass-through,
|
||||
since a zero-length read there is genuinely EOF.
|
||||
|
||||
## The `-R` registration template (SSH tcpip-forward analogue)
|
||||
|
||||
@@ -159,24 +159,24 @@ SSH: global request `tcpip-forward` → per-accept `forwarded-tcpip`
|
||||
channel opens toward the forwarder → `cancel-tcpip-forward`.
|
||||
|
||||
Mapped onto the validated surface (OQ-TN-03's role-follows-resource
|
||||
model — no protocol-level direction):
|
||||
model, no protocol-level direction):
|
||||
|
||||
| SSH | alktunnels reverse shape (validated) |
|
||||
| --- | --- |
|
||||
| `tcpip-forward` registration | hub binds the listener locally (OQ-TN-04: binding is assembly-layer; no advertisement op needed for the hub's OWN listeners) |
|
||||
| server-side accept loop | hub accept loop (assembly layer) |
|
||||
| per-accept `forwarded-tcpip` open | hub calls the worker's served open op per accept (`call_with_payload`) + `adopt_channel` |
|
||||
| target dial | worker's establisher dials the resource (establishment phase — typed errors) |
|
||||
| target dial | worker's establisher dials the resource (establishment phase with typed errors) |
|
||||
| `cancel-tcpip-forward` | hub closes: pump teardown + `teardown_channel` (and/or `channel/close` out-of-band on the worker) |
|
||||
| originator pair (informational) | not needed — ACL rides the channels open-op machinery |
|
||||
| originator pair (informational) | not needed; ACL rides the channels open-op machinery |
|
||||
|
||||
Key validation: **no advertisement surface is needed in the base
|
||||
crate.** The "please listen on your side" step of SSH's template
|
||||
dissolves when the listener is the hub's own local resource — the hub
|
||||
dissolves when the listener is the hub's own local resource: the hub
|
||||
does not ask the worker to listen; the worker serves open ops, and
|
||||
the hub opens channels whenever its own accepts fire. An advertisement
|
||||
op only becomes necessary if the LISTENER lives on the far side
|
||||
("worker, expose port 8080 on your end") — which is the worker
|
||||
("worker, expose port 8080 on your end"), which is the worker
|
||||
exposing a produced resource = the forward shape with the listener as
|
||||
the resource (producer-side listen is an establisher variant, not a
|
||||
new mechanism). This closes the last open thread of OQ-TN-03's
|
||||
@@ -186,55 +186,59 @@ original question set (the `-R` advertisement/lifecycle surface).
|
||||
|
||||
```
|
||||
alktunnels-reverse-poc/
|
||||
Cargo.toml — alkcall 0.6.0; tokio wasm-clean subset + net (POC-local)
|
||||
Cargo.toml : alkcall 0.6.0; tokio wasm-clean subset + net (POC-local)
|
||||
src/
|
||||
lib.rs — module docs; wrinkle overview
|
||||
producer.rs — worker half: tunnel_open_spec, tunnel_establisher
|
||||
(Establishment::new(plan) — R-01; per-call
|
||||
identity witness — CF-006), make_tunnel_
|
||||
pump_handler (pump_bidi inline — R-02/03),
|
||||
lib.rs : module docs; wrinkle overview
|
||||
producer.rs : worker half: tunnel_open_spec, tunnel_establisher
|
||||
(Establishment::new(plan), R-01; the per-call
|
||||
identity witness is CF-006), make_tunnel_
|
||||
pump_handler (pump_bidi inline, R-02/03),
|
||||
register_tunnel_openable (post-hoc, W2)
|
||||
producer/
|
||||
params.rs — TunnelParams {resource, substrate} + the UdpHalf
|
||||
adapter (Send + Sync — F-1)
|
||||
consumer.rs — hub half: open_reverse_channel (call_with_payload
|
||||
with optional auth_token — now the fallback
|
||||
params.rs : TunnelParams {resource, substrate} + the UdpHalf
|
||||
adapter (Send + Sync, F-1)
|
||||
consumer.rs : hub half: open_reverse_channel (call_with_payload
|
||||
with optional auth_token; this is now the fallback
|
||||
path; primary is transport identity, 0.7.0),
|
||||
ReverseTunnel::open_and_pump (adopt +
|
||||
pump_bidi), join_and_reap/close (W3),
|
||||
bi_stream_from_halves
|
||||
harness.rs — wire()/wire_with(): worker =
|
||||
harness.rs : wire()/wire_with(): worker =
|
||||
from_connection_with_serving (transport identity
|
||||
+ ServingConfig identity/provider — the 0.7.0
|
||||
precedence chain) + ChannelOperations::
|
||||
+ ServingConfig identity/provider, which is the
|
||||
0.7.0 precedence chain) + ChannelOperations::
|
||||
register_on + post-hoc openable; hub = adapter +
|
||||
capturing install hook (manager + CallConnection)
|
||||
tests/
|
||||
tunnel_poc.rs — 16 tests (see Executive summary + §W1)
|
||||
tunnel_poc.rs : 16 tests (see Executive summary + §W1)
|
||||
```
|
||||
|
||||
## What the POC does NOT validate
|
||||
|
||||
1. **A real transport** — `tokio::io::duplex` stands in for
|
||||
1. **A real transport**: `tokio::io::duplex` stands in for
|
||||
TCP/TLS/QUIC (unchanged from the forward POC scope).
|
||||
2. **The advertisement op for far-side listeners** — resolved as
|
||||
2. **The advertisement op for far-side listeners**, resolved as
|
||||
"not needed" (see the template table); if a deploy mode ever wants
|
||||
"worker, expose a port on YOUR side," that is a producer-side
|
||||
listen establisher over the same open op — spec-shaped, not POC-
|
||||
listen establisher over the same open op, which is
|
||||
spec-shaped, not POC-
|
||||
shaped.
|
||||
3. **Unix socket + stdio substrates** — OQ-TN-10 #3 (cheap; the pump
|
||||
3. **Unix socket + stdio substrates**: OQ-TN-10 #3 (cheap; the pump
|
||||
is substrate-agnostic and both POCs now confirm it). Phase 1
|
||||
resolution: unix in via the `local` feature; stdio OUT — alktty's
|
||||
resolution: unix in via the `local` feature; stdio OUT, since
|
||||
alktty's
|
||||
pipe mode owns process stdio (OQ-TN-14).
|
||||
4. ~~**W1 remediation**~~ — **resolved 2026-09-07 (alkcall 0.7.0)**:
|
||||
4. ~~**W1 remediation**~~; **resolved 2026-09-07 (alkcall 0.7.0)**:
|
||||
the remediation IS now exercised from this POC's topology
|
||||
(transport identity, `ServingConfig.identity` override, precedence,
|
||||
fail-closed, CF-006 witness). Remaining un-exercised: fingerprint-
|
||||
based identity resolution (`resolve_from_fingerprint` — the POC's
|
||||
based identity resolution (`resolve_from_fingerprint`, since the
|
||||
POC's
|
||||
duplex transport has no TLS layer to fingerprint).
|
||||
5. **Multi-hop (hub relay traversal)** — single hop; relay forwarding
|
||||
5. **Multi-hop (hub relay traversal)**: single hop, and relay
|
||||
forwarding
|
||||
(alkcall ADR-042) is transparent per leg.
|
||||
6. **Wasm** — POC-local `tokio/net`; the real crate's protocol-only
|
||||
6. **Wasm**: POC-local `tokio/net`; the real crate's protocol-only
|
||||
core stays wasm-clean (unchanged conclusion).
|
||||
|
||||
## Verification
|
||||
@@ -248,21 +252,22 @@ alktunnels-reverse-poc/
|
||||
|
||||
## References
|
||||
|
||||
- alkcall 0.6.0 — review 007 remediation as consumed here: ADR-049
|
||||
- alkcall 0.6.0, review 007 remediation as consumed here: ADR-049
|
||||
amendment 2 (`Establishment::new(plan)`, R-01), ADR-050
|
||||
(`channels::pump_bidi`, R-03), R-02 JoinHandle contract.
|
||||
- alkcall review 007 §Part B — the reverse-flow non-finding trace
|
||||
- alkcall review 007 §Part B: the reverse-flow non-finding trace
|
||||
(`from_connection_with_serving` + `register_on` + serving-side
|
||||
allocation) — confirmed by execution.
|
||||
- alkcall ADR-022 §2 — both-sides serving semantics (the worker's
|
||||
shape). ADR-047 §5 — connection-owner allocation (the odd-ID
|
||||
orientation). ADR-037/ADR-042 — channel ops, relay.
|
||||
- alktunnels `docs/research/phase-0-findings.md` — OQ-TN-03 (direction
|
||||
semantics — the advertisement thread this POC closes), OQ-TN-04
|
||||
(no forced binding — the hub binds, the worker never does),
|
||||
OQ-TN-09 (half-open residual — validated W4), OQ-TN-10 #2 (this
|
||||
allocation), confirmed by execution.
|
||||
- alkcall ADR-022 §2, both-sides serving semantics (the worker's
|
||||
shape). ADR-047 §5, connection-owner allocation (the odd-ID
|
||||
orientation). ADR-037/ADR-042, channel ops, relay.
|
||||
- alktunnels `docs/research/phase-0-findings.md`: OQ-TN-03 (direction
|
||||
semantics, the advertisement thread this POC closes), OQ-TN-04
|
||||
(no forced binding, the hub binds while the worker never does),
|
||||
OQ-TN-09 (half-open residual, validated W4), OQ-TN-10 #2 (this
|
||||
POC).
|
||||
- alktunnels `docs/research/ssh-socks5-survey.md` §RFC 4254 §7.1 —
|
||||
- alktunnels `docs/research/ssh-socks5-survey.md` §RFC 4254 §7.1:
|
||||
the `tcpip-forward` template mapped in the table above.
|
||||
- forward POC (`poc-summary.md`) — the codec decision F-2 strengthens;
|
||||
- forward POC (`poc-summary.md`), the codec decision that F-2
|
||||
strengthens;
|
||||
the UdpHalf adapter reused; the plan flow replaces `HandleHandoff`.
|
||||
@@ -13,7 +13,7 @@ tun2proxy UDP gateway) and answers the residue questions on OQ-TN-01,
|
||||
OQ-TN-02, OQ-TN-07, and OQ-TN-09.
|
||||
|
||||
Sources: RFC 4254 §4–7 (SSH Connection Protocol), RFC 1928 (SOCKS5),
|
||||
russh (`/workspace/russh` — Rust SSH implementation), tun2proxy's
|
||||
russh (`/workspace/russh`, a Rust SSH implementation), tun2proxy's
|
||||
SOCKS5 client (`/workspace/tun2proxy/src/socks.rs`,
|
||||
`src/proxy_handler.rs`, `src/lib.rs`), alktty's negotiation/control
|
||||
frames (`/workspace/@alkdev/alktty/src/negotiation.rs`, `src/wire.rs`,
|
||||
@@ -22,7 +22,7 @@ frames (`/workspace/@alkdev/alktty/src/negotiation.rs`, `src/wire.rs`,
|
||||
|
||||
## SSH forwarding model
|
||||
|
||||
### RFC 4254 §5.1 — the channel-open envelope
|
||||
### RFC 4254 §5.1: the channel-open envelope
|
||||
|
||||
Every SSH channel open (RFC 4254 §5.1) is:
|
||||
|
||||
@@ -41,7 +41,7 @@ SSH's "channel type" string plays the role of the ALPN/substrate
|
||||
discriminator; the trailing bytes play the role of the resource
|
||||
identification.
|
||||
|
||||
### RFC 4254 §7.2 — `direct-tcpip` and `forwarded-tcpip`
|
||||
### RFC 4254 §7.2: `direct-tcpip` and `forwarded-tcpip`
|
||||
|
||||
Both TCP forwarding channel types share one payload shape
|
||||
(`TcpChannelInfo` in russh, `russh/src/parsing.rs:116-153`):
|
||||
@@ -64,24 +64,25 @@ uint32 originator port
|
||||
`-R` data path.
|
||||
- **The originator pair is informational.** RFC 4254 §7.2: "the
|
||||
'originator IP address' is the numeric IP address of the machine
|
||||
from where the connection request originates" — it is for the
|
||||
from where the connection request originates", and it is for the
|
||||
recipient's logging/policy, not routing. russh carries it verbatim
|
||||
(`channel_open_direct_tcpip(host, port, originator_address,
|
||||
originator_port)`, `russh/src/client/mod.rs:718-741`), and the
|
||||
example passes the real accepted peer address
|
||||
(`russh/examples/client_open_direct_tcpip.rs:135-143`). It is the
|
||||
one piece of SSH addressing that has no alktunnels analogue in the
|
||||
base params — it is metadata, not routing.
|
||||
base params; it is metadata, not routing.
|
||||
|
||||
OpenSSH extension for unix sockets (present in russh,
|
||||
`russh/src/client/session.rs:90-100`):
|
||||
`direct-streamlocal@openssh.com` carries only `socket_path` plus two
|
||||
reserved fields (`""` string, `0u32`) — the same 4-slot shape with the
|
||||
reserved fields (`""` string, `0u32`), which is
|
||||
the same 4-slot shape with the
|
||||
address slots emptied. This is prior art for "a new substrate = same
|
||||
open-op shape, degenerate address fields," which supports OQ-TN-01's
|
||||
substrate-discriminator reframe.
|
||||
|
||||
### RFC 4254 §7.1 — the `-R` registration (`tcpip-forward`)
|
||||
### RFC 4254 §7.1: the `-R` registration (`tcpip-forward`)
|
||||
|
||||
Remote forwarding is registered with a *global request*, not a channel:
|
||||
|
||||
@@ -93,8 +94,8 @@ string address to bind ("" | "0.0.0.0" | "::" | "localhost" | "12
|
||||
uint32 port number to bind
|
||||
```
|
||||
|
||||
Reply is `SSH_MSG_REQUEST_SUCCESS` (81) — carrying `uint32 port that
|
||||
was bound` when the client passed port 0 (server allocates) — or
|
||||
Reply is `SSH_MSG_REQUEST_SUCCESS` (81), carrying `uint32 port that
|
||||
was bound` when the client passed port 0 (server allocates), or
|
||||
`SSH_MSG_REQUEST_FAILURE` (82). Cancellation is the
|
||||
`cancel-tcpip-forward` global request (§7.1). After registration, each
|
||||
accepted connection arrives as a `forwarded-tcpip` channel open from
|
||||
@@ -146,12 +147,12 @@ opener's side afterward.**
|
||||
|
||||
- **`-L` (local forward):** the client accepts on a local port, and for
|
||||
each accept opens a `direct-tcpip` channel naming the target; the
|
||||
server dials. Consumer opens, producer handles — the POC's shape.
|
||||
server dials. Consumer opens, producer handles: the POC's shape.
|
||||
- **`-R` (remote forward):** the client sends `tcpip-forward` (global
|
||||
request) to register the listen; the *server* then opens
|
||||
`forwarded-tcpip` channels per accepted connection toward the client,
|
||||
which dials the real target. Producer-side accept, consumer-side
|
||||
dial — the roles are the same pair, entry point on the other machine
|
||||
dial; the roles are the same pair, with the entry point on the other machine
|
||||
(matches the phase-0 hub model resolution of OQ-TN-03).
|
||||
- **`-D` (dynamic/SOCKS):** the client runs a SOCKS server locally; for
|
||||
each SOCKS CONNECT it parses the target out of the SOCKS request and
|
||||
@@ -174,7 +175,7 @@ prior art is SSH3 (draft-michel-remote-terminal-http3), a different
|
||||
protocol over HTTP/3, not SSH-the-RFC. **Correction to the phase-0
|
||||
doc:** OQ-TN-02's line "SSH's `-D` UDP associate tunnels UDP as a
|
||||
stream with per-datagram framing re-added by the tunnel protocol (e.g.
|
||||
SOCKS5 UDP over TCP)" is a category error — SSH `-D` carries the SOCKS5
|
||||
SOCKS5 UDP over TCP)" is a category error: SSH `-D` carries the SOCKS5
|
||||
*TCP* control connection only; SOCKS5 UDP ASSOCIATE datagrams never
|
||||
traverse an SSH channel in any standard SSH implementation. The real
|
||||
UDP-over-stream prior art is tun2proxy's udpgw (already analyzed) and
|
||||
@@ -223,7 +224,7 @@ Reply codes (RFC 1928 §6):
|
||||
| 0x08 | address type not supported |
|
||||
|
||||
On failure the server MUST close the TCP connection within 10 seconds
|
||||
of sending the reply (§6 "Reply Processing") — the same
|
||||
of sending the reply (§6 "Reply Processing"), the same
|
||||
"error-then-close" shape SSH open-failure has, and the same shape
|
||||
OQ-TN-09's accepted direction implies for tunnel establishment (control
|
||||
frame, then channel death).
|
||||
@@ -234,18 +235,18 @@ ReceiveAuthResponse, SendRequest, ReceiveResponse, Established}`
|
||||
(`socks.rs:11-20`), with the reply parsed in
|
||||
`receive_connection_status` (`socks.rs:226-248`) and non-`Succeeded`
|
||||
replies aborting the session. The `ProxyHandler` trait
|
||||
(`proxy_handler.rs:9-22`) is the handler-inversion point — same shape
|
||||
as alktty's `TtyBackend`/this crate's OQ-TN-05 question, with
|
||||
(`proxy_handler.rs:9-22`) is the handler-inversion point, the same
|
||||
shape as alktty's `TtyBackend`/this crate's OQ-TN-05 question, with
|
||||
`get_udp_associate()` as the UDP-specific handle.
|
||||
|
||||
### UDP ASSOCIATE flow (RFC 1928 §7)
|
||||
|
||||
1. The client sends UDP ASSOCIATE **over the TCP control connection**,
|
||||
with DST.ADDR/DST.PORT = the client's expected datagram source (all
|
||||
zeros if unknown — the common case; tun2proxy sends
|
||||
zeros if unknown, the common case; tun2proxy sends
|
||||
`Address::unspecified()`, `socks.rs:214-215`).
|
||||
2. The reply's BND.ADDR/BND.PORT is **the relay endpoint the client
|
||||
must send UDP datagrams to** — this is how the client learns where
|
||||
must send UDP datagrams to**, which is how the client learns where
|
||||
to send (`socks.rs:241-244` stores it as `udp_associate`).
|
||||
3. Each datagram to/from that endpoint carries the UDP request header:
|
||||
|
||||
@@ -262,7 +263,7 @@ as alktty's `TtyBackend`/this crate's OQ-TN-05 question, with
|
||||
MUST drop datagrams with FRAG ≠ 0 (§7).
|
||||
4. The relay server pins the association to the client's source IP and
|
||||
drops datagrams from any other source (§7). **The association
|
||||
terminates when the TCP control connection terminates** (§6) — the
|
||||
terminates when the TCP control connection terminates** (§6); the
|
||||
TCP session is the UDP flow's lifecycle anchor.
|
||||
5. Replies are relayed silently; there is no per-datagram
|
||||
acknowledgment or error channel. Errors are signaled only by
|
||||
@@ -276,33 +277,33 @@ the relay and strips it on the way back.
|
||||
### What udpgw dropped vs kept from RFC 1928
|
||||
|
||||
udpgw's packet format (`udpgw.rs:55-88`) is `LEN(u16 BE) | FLAGS(u8) |
|
||||
CONN_ID(u16) | [SOCKS5 address] | DATA` — "referenced from SOCKS5
|
||||
CONN_ID(u16) | [SOCKS5 address] | DATA`, "referenced from SOCKS5
|
||||
packet format, with additional flags and connection ID fields"
|
||||
(`udpgw.rs:57`):
|
||||
|
||||
- **Kept:** the ATYP/DST.ADDR/DST.PORT address encoding per datagram
|
||||
(§5's scheme-tagged addressing, unchanged).
|
||||
- **Kept (implicitly):** per-datagram boundary framing — but udpgw
|
||||
- **Kept (implicitly):** per-datagram boundary framing, but udpgw
|
||||
re-derives it with an explicit LEN prefix, because it rides a TCP
|
||||
stream, not a datagram socket. SOCKS5 UDP never needed a length
|
||||
prefix because UDP itself preserves boundaries; udpgw's LEN is the
|
||||
price of moving the relay onto TCP.
|
||||
- **Dropped: RSV** — pointless over a stream (it exists only to
|
||||
- **Dropped: RSV**, pointless over a stream (it exists only to
|
||||
word-align the UDP header).
|
||||
- **Dropped: FRAG** — fragmentation over a reliable in-order stream is
|
||||
- **Dropped: FRAG** because fragmentation over a reliable in-order stream is
|
||||
unnecessary; udpgw caps datagram size with an MTU check instead
|
||||
(`parse_udp_response` rejects `data.len() > udp_mtu`,
|
||||
`udpgw.rs:527`).
|
||||
- **Dropped: the TCP-control-connection lifecycle model** — SOCKS5
|
||||
- **Dropped: the TCP-control-connection lifecycle model**: SOCKS5
|
||||
binds each UDP association to a TCP session and learns the relay
|
||||
endpoint from a reply; udpgw multiplexes all flows over pooled
|
||||
gateway connections with a client-allocated `CONN_ID(u16)`
|
||||
(`udpgw.rs:66`) and replaces "reply carries the relay endpoint" with
|
||||
"the gateway connection is the endpoint."
|
||||
- **Added: FLAGS** — `KEEPALIVE(0x01)` and `ERR(0x20)` as
|
||||
- **Added: FLAGS**, namely `KEEPALIVE(0x01)` and `ERR(0x20)` as
|
||||
address-less, data-less control packets (`udpgw.rs:21-26`), giving
|
||||
the stream a minimal frame vocabulary: DATA / KEEPALIVE / ERR.
|
||||
- **Dropped: reply codes** — the ERR flag carries *no* reason
|
||||
- **Dropped: reply codes**; the ERR flag carries *no* reason
|
||||
information (`build_error_packet` is just header + conn_id,
|
||||
`udpgw.rs:151-153`). SOCKS5's 9 reply codes and SSH's 4 reason codes
|
||||
+ description both collapse to a single opaque bit. This is the
|
||||
@@ -312,11 +313,11 @@ packet format, with additional flags and connection ID fields"
|
||||
|
||||
| | SSH open-failure (RFC 4254 §5.1) | SOCKS5 reply (RFC 1928 §6) | udpgw flags (udpgw.rs:21-26) | alktty ctrl frames (control.rs:58-93) |
|
||||
|---|---|---|---|---|
|
||||
| establishment ack | `CHANNEL_OPEN_CONFIRMATION` (91) | REP `0x00` + BND.ADDR/BND.PORT | (none — data just flows) | negotiation frame reply (negotiation.rs) |
|
||||
| establishment ack | `CHANNEL_OPEN_CONFIRMATION` (91) | REP `0x00` + BND.ADDR/BND.PORT | (none; data just flows) | negotiation frame reply (negotiation.rs) |
|
||||
| failure signal | `CHANNEL_OPEN_FAILURE` (92) | REP `0x01`–`0x08` | `ERR` flag packet | length-prefixed JSON error frame (error_response_bytes, negotiation.rs:263-277) |
|
||||
| failure granularity | 4 IANA codes + private range | 8 codes | 1 opaque bit | free-form JSON `{"error": ...}` |
|
||||
| human-readable detail | `description` string + language tag | none (codes only) | none | JSON fields |
|
||||
| channel survives failure | no — channel removed client-side (client/encrypted.rs:421-427) | TCP connection closed ≤10s | flow-level (conn_id), stream survives | stream never established |
|
||||
| channel survives failure | no; channel removed client-side (client/encrypted.rs:421-427) | TCP connection closed ≤10s | flow-level (conn_id), stream survives | stream never established |
|
||||
| in-band vs op-level | in-channel reply message | control-connection reply | in-stream flag packet | first frame on the stream |
|
||||
|
||||
Observations for the alktunnels control-frame ADR (OQ-TN-09):
|
||||
@@ -324,12 +325,12 @@ Observations for the alktunnels control-frame ADR (OQ-TN-09):
|
||||
- SSH is the only prior art that puts a *reason code + description* in
|
||||
the failure reply; SOCKS5 has codes but no detail; udpgw has neither.
|
||||
The alktty-shaped JSON control frame can carry both (a machine code
|
||||
field and a detail string) at zero structural cost — it is the
|
||||
field and a detail string) at zero structural cost; it is the
|
||||
strictly richest of the four, and it is already the accepted
|
||||
direction.
|
||||
- All four agree on the lifecycle consequence: **establishment failure
|
||||
kills the channel/connection before data flows.** None of them
|
||||
supports "fail the open but keep the channel for a retry" — there is
|
||||
supports "fail the open but keep the channel for a retry"; there is
|
||||
no prior art for a retry-without-reopen path, and none is needed
|
||||
(the consumer just opens a new channel).
|
||||
- SSH's reason-code taxonomy (policy / dial-failed / unknown-type /
|
||||
@@ -338,7 +339,7 @@ Observations for the alktunnels control-frame ADR (OQ-TN-09):
|
||||
open handler can produce (ACL denied, dial failed, unknown
|
||||
substrate/resource, channels limits). SOCKS5's set adds
|
||||
network-unreachable vs host-unreachable vs connection-refused
|
||||
granularity inside "dial failed" — worth considering as sub-codes or
|
||||
granularity inside "dial failed", worth considering as sub-codes or
|
||||
just leaving to the detail string.
|
||||
|
||||
## UDP: endpoint-at-open vs per-datagram
|
||||
@@ -347,7 +348,7 @@ What each prior art does:
|
||||
|
||||
| | endpoint model | where the address lives | flow multiplexing |
|
||||
|---|---|---|---|
|
||||
| SSH (TCP only) | fixed at open | `direct-tcpip`/`forwarded-tcpip` payload | none needed — one channel = one TCP connection |
|
||||
| SSH (TCP only) | fixed at open | `direct-tcpip`/`forwarded-tcpip` payload | none needed; one channel = one TCP connection |
|
||||
| SOCKS5 UDP ASSOCIATE | relay endpoint fixed at open (BND.ADDR/BND.PORT); **destination per-datagram** | DST.ADDR/DST.PORT in each UDP header | one association per control connection; source-IP pinning |
|
||||
| udpgw | destination per-datagram | SOCKS5 address in each DATA packet | `CONN_ID(u16)` client-allocated, many flows per gateway stream |
|
||||
| alktunnels base open-op (OQ-TN-01 reframe) | resource fixed at open | `params` (resource id + substrate) | the channel ID itself |
|
||||
@@ -360,30 +361,30 @@ Implications for a channels-based tunnel:
|
||||
alktunnels' base open-op is a *resource access*: the consumer names
|
||||
a produced resource, and the producer owns the backing endpoint.
|
||||
Under the hub model (phase-0 §Prior art), endpoint-at-open is the
|
||||
natural fit for the base op — the resource *is* the endpoint
|
||||
natural fit for the base op, since the resource *is* the endpoint
|
||||
binding, and per-datagram addressing would reintroduce exactly the
|
||||
"general addressing in params" that the OQ-TN-01 reframe removed.
|
||||
2. **Per-datagram addressing belongs to the `-D`/dynamic composition
|
||||
path, not the base op.** If a UDP SOCKS5-style service is produced,
|
||||
the consumer tunnels to it (base op, substrate `udp` or even `tcp`
|
||||
for the control leg) and the SOCKS5 UDP header rides *inside* the
|
||||
tunnel payload — exactly as tun2proxy's socks path does over its
|
||||
tunnel payload, exactly as tun2proxy's socks path does over its
|
||||
proxy connection (`lib.rs:672-687`). The base protocol never sees
|
||||
per-datagram addresses.
|
||||
3. **If a base-op UDP resource ever needs multiple remote endpoints
|
||||
(a "UDP gateway" resource), the udpgw shape is the template:**
|
||||
length-prefixed datagrams, SOCKS5-style per-datagram address, a
|
||||
flow key, and DATA/KEEPALIVE/ERR flags — all *inside* the channel,
|
||||
self-describing, invisible to the open-op params. That keeps
|
||||
OQ-TN-07's option A (single `alk/tunnel` ALPN) viable: udpgw proves
|
||||
one framing can carry the datagram case over a stream substrate
|
||||
without the ALPN knowing.
|
||||
(a "UDP gateway" resource), the udpgw shape is the template:**
|
||||
length-prefixed datagrams, SOCKS5-style per-datagram address, a
|
||||
flow key, and DATA/KEEPALIVE/ERR flags, all *inside* the channel,
|
||||
self-describing and invisible to the open-op params. That keeps
|
||||
OQ-TN-07's option A (single `alk/tunnel` ALPN) viable: udpgw proves
|
||||
one framing can carry the datagram case over a stream substrate
|
||||
without the ALPN knowing.
|
||||
4. **The channel ID replaces CONN_ID.** udpgw needs CONN_ID because it
|
||||
multiplexes over 1–5 pooled TCP streams (a throughput choice,
|
||||
`UDPGW_MAX_CONNECTIONS = 5`, udpgw.rs:15); channels already
|
||||
multiplexes per channel ID with a 256-channel cap. One channel per
|
||||
UDP flow (or per association) needs no protocol-level flow key —
|
||||
the OQ-TN-02 residue question "is a u16 conn-id vocabulary right"
|
||||
UDP flow (or per association) needs no protocol-level flow key; the
|
||||
OQ-TN-02 residue question "is a u16 conn-id vocabulary right"
|
||||
resolves to *no*: the channel ID does the demux, and a conn-id
|
||||
inside the channel would be a second demux layer (AGENTS.md
|
||||
convention 10: don't build a second demux/mux).
|
||||
@@ -396,7 +397,7 @@ Implications for a channels-based tunnel:
|
||||
|
||||
## What NOT to carry over
|
||||
|
||||
Explicit anti-prior-art — mechanisms the prior arts have that alkcall
|
||||
Explicit anti-prior-art: mechanisms the prior arts have that alkcall
|
||||
channels already owns, or that don't survive the substrate change:
|
||||
|
||||
1. **SSH window updates (`SSH_MSG_CHANNEL_WINDOW_ADJUST`, RFC 4254
|
||||
@@ -408,13 +409,13 @@ channels already owns, or that don't survive the substrate change:
|
||||
(`channels/mod.rs:116-139`) is the thing we *don't* build.
|
||||
2. **SSH's `maximum packet size` negotiation.** Channels owns chunk
|
||||
sizing; the tunnel codec inherits it. (udpgw's MTU cap is the
|
||||
datagram-flavored version — also producer-side, not wire.)
|
||||
datagram-flavored version, also producer-side, not wire.)
|
||||
3. **SSH's sender/recipient dual channel numbering.** Channels
|
||||
allocates monotonic IDs with a single namespace (alkcall
|
||||
ADR-034/040); the open op's channel ID is already agreed before the
|
||||
handler runs. No ID translation in the tunnel protocol.
|
||||
4. **SOCKS5's FRAG byte.** Fragmentation over a reliable in-order
|
||||
channel is redundant — udpgw already dropped it for exactly that
|
||||
channel is redundant; udpgw already dropped it for exactly that
|
||||
reason. If a datagram is too big for the channel's buffer, the
|
||||
producer-side MTU cap rejects it; reassembly is not the tunnel's
|
||||
job.
|
||||
@@ -426,7 +427,7 @@ channels already owns, or that don't survive the substrate change:
|
||||
6. **SOCKS5's BND.ADDR/BND.PORT reply semantics.** The "reply tells
|
||||
you where to send datagrams" pattern exists because SOCKS5's UDP
|
||||
relay is a *separate socket* from the control connection. On
|
||||
channels there is no second endpoint — the channel *is* the
|
||||
channels there is no second endpoint: the channel *is* the
|
||||
endpoint. An establishment-ack control frame needs no address
|
||||
payload.
|
||||
7. **udpgw's KEEPALIVE flag and connection pooling.** Both are
|
||||
@@ -446,10 +447,10 @@ channels already owns, or that don't survive the substrate change:
|
||||
two-field shape:
|
||||
|
||||
- SSH's `direct-tcpip` payload is exactly "target + (informational
|
||||
originator)" — and the informational half is the only part with no
|
||||
originator)", and the informational half is the only part with no
|
||||
alktunnels analogue, because ACL/identity is carried by the channels
|
||||
open-op machinery, not by params. The routing-relevant payload
|
||||
reduces to *one address-ish field + one port* — which under the
|
||||
reduces to *one address-ish field + one port*, which under the
|
||||
resource model collapses further into *one resource identifier*.
|
||||
- OpenSSH's `direct-streamlocal` shows the extensibility pattern: same
|
||||
open-op shape, degenerate address slots, new type string. For
|
||||
@@ -457,13 +458,13 @@ two-field shape:
|
||||
"substrate": "tcp" | "udp" | <extensible> }` with any
|
||||
substrate-specific detail owned by the producer's registry, not the
|
||||
wire. A new substrate is a new `substrate` value, not a format
|
||||
change — the same additive property SSH gets from channel-type
|
||||
change, which is the same additive property SSH gets from channel-type
|
||||
strings and SOCKS5 gets from ATYP values.
|
||||
- SOCKS5's ATYP (v4/domain/v6) is *not* needed in the base params: it
|
||||
is per-datagram/per-request addressing for the dynamic path, which
|
||||
rides inside the tunnel payload (see §UDP). If the resource
|
||||
identifier is ever an address rather than a name, ATYP's
|
||||
scheme-tagged encoding is the fallback shape — but the resource-name
|
||||
scheme-tagged encoding is the fallback shape, but the resource-name
|
||||
direction makes that unnecessary.
|
||||
|
||||
**Open-failure error path.** The prior art converges on: *a structured
|
||||
@@ -476,8 +477,8 @@ ADR):
|
||||
(phase-0 OQ-TN-09): a single JSON frame before any data chunk, with
|
||||
at minimum `{"ok": true}` or `{"error": <code>, "detail": <string>}`.
|
||||
- The reason-code vocabulary should cover SSH's four (policy-denied /
|
||||
dial-failed / unknown-resource-or-substrate / resource-shortage) —
|
||||
they map 1:1 onto what an open handler can actually produce:
|
||||
dial-failed / unknown-resource-or-substrate / resource-shortage),
|
||||
which map 1:1 onto what an open handler can actually produce:
|
||||
`AccessControl` denial, target dial failure, unregistered
|
||||
resource/substrate, channels limits. SOCKS5's finer
|
||||
network/host/refused granularity can live in `detail` rather than
|
||||
@@ -496,11 +497,11 @@ Resolved as a *split by path*, not a single choice (§UDP above):
|
||||
association). This aligns with resource naming, needs no in-band
|
||||
addressing, and matches how the hub model produces any resource.
|
||||
Boundary preservation inside the channel is still per-datagram
|
||||
length framing (tun2proxy-proven) — endpoint-at-open is about
|
||||
length framing (tun2proxy-proven); endpoint-at-open is about
|
||||
*addressing*, not about dropping the LEN prefix.
|
||||
- **Dynamic/`-D` UDP (SOCKS5-style): per-datagram addressing inside
|
||||
the tunnel payload**, using the SOCKS5 UDP header or udpgw's
|
||||
ADDR-bearing packet format — composed at the assembly layer, never
|
||||
ADDR-bearing packet format, composed at the assembly layer, never
|
||||
in base params.
|
||||
- The channel ID replaces udpgw's CONN_ID (no second demux); KEEPALIVE
|
||||
drops; the ERR flag's job is done better by the control frame.
|
||||
@@ -519,7 +520,7 @@ strengthens it:
|
||||
datagram framing is confined to UDP-substrate channels and is
|
||||
self-describing from the first chunk (a length prefix is
|
||||
unambiguous against raw pass-through only if TCP channels are also
|
||||
framed — see the codec decision on the convergence checklist; if
|
||||
framed; see the codec decision on the convergence checklist; if
|
||||
TCP is raw pass-through, the substrate discriminator in `params`
|
||||
tells the handler which framing to expect, which is exactly option
|
||||
A's "substrate is a params field").
|
||||
@@ -532,7 +533,7 @@ strengthens it:
|
||||
Inputs from this survey (§Comparison table above):
|
||||
|
||||
- Shape: alktty-style JSON control frame (already the accepted
|
||||
direction) — it is the richest vocabulary of the four prior arts
|
||||
direction), which is the richest vocabulary of the four prior arts
|
||||
(code + detail) and needs no new framing layer.
|
||||
- Content: an establishment ack (SSH's CONFIRMATION / SOCKS5's
|
||||
success reply analogue) and a failure frame with a small reason-code
|
||||
@@ -541,62 +542,62 @@ Inputs from this survey (§Comparison table above):
|
||||
- What the frame does *not* need: window adjustments (channels owns
|
||||
backpressure), keepalives (channels owns liveness), fragmentation
|
||||
(not the tunnel's job), per-datagram error replies (SOCKS5 relays
|
||||
silently; udpgw's ERR is flow-level at most — dial errors are
|
||||
silently; udpgw's ERR is flow-level at most, since dial errors are
|
||||
channel-closing per the accepted direction).
|
||||
|
||||
## References
|
||||
|
||||
- RFC 4254, *The Secure Shell (SSH) Connection Protocol* — §4 (global
|
||||
- RFC 4254, *The Secure Shell (SSH) Connection Protocol*: §4 (global
|
||||
requests), §5.1 (channel open / open-failure reason codes), §5.2
|
||||
(window adjust), §7.1 (`tcpip-forward`), §7.2 (`direct-tcpip`,
|
||||
`forwarded-tcpip`).
|
||||
https://www.rfc-editor.org/rfc/rfc4254.txt
|
||||
- RFC 1928, *SOCKS Protocol Version 5* — §3 (method negotiation), §4
|
||||
- RFC 1928, *SOCKS Protocol Version 5*: §3 (method negotiation), §4
|
||||
(request format), §5 (ATYP addressing), §6 (replies, UDP ASSOCIATE
|
||||
lifecycle), §7 (UDP relay header, FRAG).
|
||||
https://www.rfc-editor.org/rfc/rfc1928.txt
|
||||
- russh — `/workspace/russh`:
|
||||
- `russh/src/parsing.rs:8-153` — `OpenChannelMessage` parse,
|
||||
- russh (`/workspace/russh`):
|
||||
- `russh/src/parsing.rs:8-153`: `OpenChannelMessage` parse,
|
||||
`TcpChannelInfo` (direct-tcpip/forwarded-tcpip payload),
|
||||
`StreamLocalChannelInfo`, confirm/fail encoders.
|
||||
- `russh/src/lib_inner.rs:402-423` — `ChannelOpenFailure` reason
|
||||
- `russh/src/lib_inner.rs:402-423`: `ChannelOpenFailure` reason
|
||||
enum.
|
||||
- `russh/src/client/mod.rs:675-760` — public `channel_open_*` API;
|
||||
`client/session.rs:74-100` — wire encoding of direct-tcpip /
|
||||
- `russh/src/client/mod.rs:675-760`: public `channel_open_*` API;
|
||||
`client/session.rs:74-100`: wire encoding of direct-tcpip /
|
||||
direct-streamlocal payloads.
|
||||
- `russh/src/server/encrypted.rs:1150-1278` — server-side open
|
||||
- `russh/src/server/encrypted.rs:1150-1278`: server-side open
|
||||
dispatch, `finalize_channel_open` failure path;
|
||||
`server/session.rs:1055-1115, 1186-1233` — server-initiated opens
|
||||
`server/session.rs:1055-1115, 1186-1233`: server-initiated opens
|
||||
and `tcpip-forward` global request.
|
||||
- `russh/src/client/encrypted.rs:414-434` — client-side
|
||||
- `russh/src/client/encrypted.rs:414-434`: client-side
|
||||
open-failure handling.
|
||||
- `russh/examples/client_open_direct_tcpip.rs:129-184` — the -L
|
||||
- `russh/examples/client_open_direct_tcpip.rs:129-184`: the -L
|
||||
pump shape (two select arms, EOF handling).
|
||||
- Confirmed: no UDP anywhere in `russh/src/` (grep, 2026-09-06).
|
||||
- tun2proxy — `/workspace/tun2proxy`:
|
||||
- `src/udpgw.rs:55-88` — packet format doc comment; `:21-26` flags;
|
||||
- tun2proxy (`/workspace/tun2proxy`):
|
||||
- `src/udpgw.rs:55-88`: packet format doc comment; `:21-26` flags;
|
||||
`:147-153` keepalive/error packet builders; `:527` MTU cap.
|
||||
(Analyzed in phase-0-findings.md §Prior art.)
|
||||
- `src/socks.rs:11-20` — SOCKS5 client state machine; `:213-224`
|
||||
- `src/socks.rs:11-20`: SOCKS5 client state machine; `:213-224`
|
||||
request encoding (UDP ASSOCIATE sends unspecified address);
|
||||
`:226-248` reply parsing + `udp_associate` capture.
|
||||
- `src/proxy_handler.rs:9-32` — `ProxyHandler`/`ProxyHandlerManager`
|
||||
- `src/proxy_handler.rs:9-32`: `ProxyHandler`/`ProxyHandlerManager`
|
||||
inversion point.
|
||||
- `src/lib.rs:625-724` — `handle_udp_associate_session`: SOCKS5 UDP
|
||||
- `src/lib.rs:625-724`: `handle_udp_associate_session`: SOCKS5 UDP
|
||||
header added/stripped per datagram over the relay endpoint.
|
||||
- alktty — `/workspace/@alkdev/alktty`:
|
||||
- `src/negotiation.rs:64-120` — `NegotiateRequest` (self-contained
|
||||
JSON open-op precedent); `:127-277` — length-prefixed framing +
|
||||
- alktty (`/workspace/@alkdev/alktty`):
|
||||
- `src/negotiation.rs:64-120`: `NegotiateRequest` (self-contained
|
||||
JSON open-op precedent); `:127-277`: length-prefixed framing +
|
||||
`error_response_bytes` (the error-frame shape).
|
||||
- `src/wire.rs:1-57` — 5-byte chunk codec, stream types, zero-length
|
||||
- `src/wire.rs:1-57`: 5-byte chunk codec, stream types, zero-length
|
||||
sentinels.
|
||||
- `src/control.rs:58-108` — `ControlMessage` JSON control frames.
|
||||
- alknet-channels POC — `/workspace/alknet-channels-poc`:
|
||||
- `src/tunnel_handler.rs:24-59` — the two-pump tunnel handler (no
|
||||
establishment ack — the gap OQ-TN-09 closes).
|
||||
- `src/control.rs:58-108`: `ControlMessage` JSON control frames.
|
||||
- alknet-channels POC (`/workspace/alknet-channels-poc`):
|
||||
- `src/tunnel_handler.rs:24-59`: the two-pump tunnel handler (no
|
||||
establishment ack, the gap OQ-TN-09 closes).
|
||||
- OpenSSH `-D` protocol-level behavior (no local source; web):
|
||||
- stackoverflow.com/questions/78045232 and
|
||||
/questions/51347627 — `-D` = local SOCKS server feeding
|
||||
/questions/51347627: `-D` = local SOCKS server feeding
|
||||
per-connection `direct-tcpip` opens; no distinct channel type.
|
||||
- SSH3 (draft-michel-remote-terminal-http3) — the only "direct-udp"
|
||||
- SSH3 (draft-michel-remote-terminal-http3): the only "direct-udp"
|
||||
prior art found; a different protocol (HTTP/3), not RFC SSH.
|
||||
@@ -1,40 +1,42 @@
|
||||
---
|
||||
status: decided
|
||||
last_updated: 2026-09-16 (§2 → ADR-007; §3/§3.1 → ADR-008 — the leans are pinned, see the status note)
|
||||
last_updated: 2026-09-16 (§2 → ADR-007; §3/§3.1 → ADR-008, the leans are pinned, see the status note)
|
||||
---
|
||||
|
||||
# alksocks research — graduating proxy surface into alktunnels
|
||||
# alksocks research: graduating proxy surface into alktunnels
|
||||
|
||||
**Status note (2026-09-16).** Phase 1 decided both asks; this doc is
|
||||
the research record, the ADRs are now normative:
|
||||
|
||||
- **§2 (the direct op) →
|
||||
[ADR-007](../architecture/decisions/007-direct-open-op.md)** —
|
||||
[ADR-007](../architecture/decisions/007-direct-open-op.md)**:
|
||||
option (a) confirmed, with one refinement: the `target` encoding is
|
||||
a self-describing JSON object keyed by the sibling `substrate`
|
||||
field (`{host, port}` / `{path}`), not the ATYP byte string — the
|
||||
field (`{host, port}` / `{path}`), not the ATYP byte string, since
|
||||
the
|
||||
registry's `input_schema` validation (alkcall ADR-047) needs
|
||||
schema-describable params, and the unix path form falls out for
|
||||
free. Op id `channels/tunnel/direct`, scope `tunnel:direct`,
|
||||
dial-only.
|
||||
- **§3/§3.1 (listen metadata) →
|
||||
[ADR-008](../architecture/decisions/008-listen-metadata-forwarded-op.md)** —
|
||||
[ADR-008](../architecture/decisions/008-listen-metadata-forwarded-op.md)**:
|
||||
the §3.1 `forwarded-tcpip` alternative ADOPTED over the per-ask
|
||||
carriers: a bind-first listen establisher (reply carries the real
|
||||
`bound` address — ask #1; also dissolves the POC's deadlock and
|
||||
carriers; a bind-first listen establisher (reply carries the real
|
||||
`bound` address, ask #1; also dissolves the POC's deadlock and
|
||||
10s-bound findings) + the forwarded op `channels/tunnel/forwarded`
|
||||
with `{listen, substrate, peer}` params (ask #2; ADR-008 Amendment
|
||||
1, 2026-09-18 — the correlation key is a producer-minted token, the
|
||||
1, 2026-09-18, where the correlation key is a producer-minted token, the
|
||||
original channel-ID key was unimplementable against the landed
|
||||
handler surface). The
|
||||
`TargetHandle.peer` option (§3 ask #2, option i) is superseded.
|
||||
- **A discovery this research missed:** alkcall ADR-047 pins
|
||||
discovery's ALPN derivation to exactly `…/sub`/`…/pub` op-id shapes
|
||||
(`derive_alpn_from_op_name`) — flavor-form op ids (both new ops)
|
||||
(`derive_alpn_from_op_name`), so
|
||||
flavor-form op ids (both new ops)
|
||||
need an upstream alkcall amendment before the first consumer. Filed:
|
||||
`alkcall/docs/reviews/008-tunnels-graduation-upstream-review.md`
|
||||
(both asks landed in alkcall 0.8.0, plus the in-tree
|
||||
`ChannelRelay`/`HubLegTemplate` — alkcall ADR-051).
|
||||
`ChannelRelay`/`HubLegTemplate` (alkcall ADR-051)).
|
||||
|
||||
The upstream ask ledger (§5) is now: items 1–2 pinned by ADR-007/008
|
||||
(implementation pending the two upstream alkcall asks), item 3
|
||||
@@ -44,7 +46,7 @@ ledger), which is unchanged by this.
|
||||
|
||||
The downstream motivation (2026-09-15 discussion): alksocks' three
|
||||
commands (CONNECT / BIND / UDP ASSOCIATE) need only a few alktunnels
|
||||
additions, and those additions are generally useful — they are the
|
||||
additions, and those additions are generally useful, being the
|
||||
`ssh -L`/`-R` generalized shape that any dynamic-target consumer (SOCKS5
|
||||
included) composes on top of. This doc records the proposed graduation:
|
||||
what moves to alktunnels, what stays in alksocks, the design questions
|
||||
@@ -64,15 +66,15 @@ Mapping the SOCKS5 command surface onto alktunnels 0.1.0:
|
||||
|
||||
| SOCKS5 piece | alktunnels today | Gap |
|
||||
|---|---|---|
|
||||
| CONNECT (tcp proxy, egress) | dial establisher + `pump_bidi` + `local::dial_tcp` | none for **local** egress; **no dynamic-target open** for **composed** egress — params are `{resource, substrate}` (ADR-001) |
|
||||
| UDP ASSOCIATE (udp proxy) | connected-udp substrate + per-target tunnel table | none — the alksocks phase-0 note "no upstream ask exists here" holds; the composed form is per-target `udp` opens (POC #2 measured ~0.8 ms/destination) |
|
||||
| BIND (reverse tcp) | `listen_establisher` + `AcceptQueue` + `bind_tcp` | the two ledger asks: bound-addr discovery (reply#1's `BND.ADDR`) and peer metadata (reply#2's) — alksocks `poc-bind-findings.md` §Upstream-ask ledger |
|
||||
| CONNECT (tcp proxy, egress) | dial establisher + `pump_bidi` + `local::dial_tcp` | none for **local** egress; **no dynamic-target open** for **composed** egress, since params are `{resource, substrate}` (ADR-001) |
|
||||
| UDP ASSOCIATE (udp proxy) | connected-udp substrate + per-target tunnel table | none: the alksocks phase-0 note "no upstream ask exists here" holds; the composed form is per-target `udp` opens (POC #2 measured ~0.8 ms/destination) |
|
||||
| BIND (reverse tcp) | `listen_establisher` + `AcceptQueue` + `bind_tcp` | the two ledger asks: bound-addr discovery (reply#1's `BND.ADDR`) and peer metadata (reply#2's); see alksocks `poc-bind-findings.md` §Upstream-ask ledger |
|
||||
|
||||
So the graduation reduces to:
|
||||
|
||||
1. **One wire addition: a direct/dynamic open form** — "dial an
|
||||
arbitrary target," not "dial a named resource" (§2).
|
||||
2. **The two BIND metadata asks** — carriers to be decided (§3).
|
||||
1. **One wire addition: a direct/dynamic open form** ("dial an
|
||||
arbitrary target," not "dial a named resource"; §2).
|
||||
2. **The two BIND metadata asks**, carriers to be decided (§3).
|
||||
|
||||
The pumping, teardown, codec, and session machinery are already done
|
||||
and POC-validated; the proxy producers on the alksocks side are thin
|
||||
@@ -80,7 +82,7 @@ adapters over the three callback seams (`DialFn`, `EgressFactory`,
|
||||
`BindAcceptFactory`), which the POCs validated in both local and
|
||||
composed variants.
|
||||
|
||||
## 2. The direct open form — the real design decision
|
||||
## 2. The direct open form: the real design decision
|
||||
|
||||
### The constraint behind it
|
||||
|
||||
@@ -91,13 +93,13 @@ That decision was correct for its use case and the no-forced-bind
|
||||
posture (AGENTS.md #9) stands: nothing here forces any side to bind a
|
||||
port. The use case that justifies an *additive* revisit is the one the
|
||||
POC ledger itself names: a consumer asking the producer to dial an
|
||||
arbitrary `host:port` — ssh's `direct-tcpip` shape, which ssh carries
|
||||
arbitrary `host:port`, ssh's `direct-tcpip` shape, which ssh carries
|
||||
as its own channel type precisely because it is a different capability
|
||||
from opening a named resource. SOCKS5 CONNECT is the same capability:
|
||||
a dynamic target chosen at session time.
|
||||
|
||||
The capability is dangerous in exactly the way ADR-001's "resource,
|
||||
not address" framing protects against — arbitrary egress from the
|
||||
not address" framing protects against: arbitrary egress from the
|
||||
producer's network. The mitigation is not refusing the capability (a
|
||||
SOCKS5 service IS the capability) but separating it at the ACL: "dial
|
||||
`postgres-primary`" and "dial anything from my network" must not be
|
||||
@@ -105,13 +107,14 @@ the same grant.
|
||||
|
||||
### Options
|
||||
|
||||
**(a) Second op on the same ALPN — `channels/tunnel/direct`** with
|
||||
**(a) Second op on the same ALPN, `channels/tunnel/direct`** with
|
||||
params `{substrate, target}` and its own scope (`tunnel:direct`).
|
||||
Clean in kind: this IS a different operation — dial what I name, vs.
|
||||
Clean in kind, because this IS a different operation: dial what I
|
||||
name, vs.
|
||||
dial what the producer registered. The ACL separation is structural
|
||||
(scopes attach to op specs, not params); the POC #5 scope gate
|
||||
(`socks5:bind`) proved the pattern matters. Old producers reject the
|
||||
unknown op loudly (registry lookup fails — the SSH posture).
|
||||
unknown op loudly (registry lookup fails, the SSH posture).
|
||||
|
||||
**(b) Optional `target` field in the existing params** + a
|
||||
`target`-implies-scope establisher check. ADR-001's consequences bless
|
||||
@@ -125,10 +128,10 @@ grants by name; "any name of this shape" is not a scope), registry
|
||||
bookkeeping gets weird, and discovery listings leak addresses.
|
||||
Reject.
|
||||
|
||||
**Lean: (a).** One ALPN, two ops, two scopes — the same shape alktty's
|
||||
**Lean: (a).** One ALPN, two ops, two scopes: the same shape alktty's
|
||||
scope-gating and the POC #5 command gates took. Wire-stable questions
|
||||
(op id string, scope string, params field names, `target` address
|
||||
encoding) are pinned by an ADR before the first consumer — timing is
|
||||
encoding) are pinned by an ADR before the first consumer; timing is
|
||||
right because alksocks is the first real consumer and nothing is
|
||||
published yet.
|
||||
|
||||
@@ -137,23 +140,23 @@ published yet.
|
||||
The direct op's `target` must carry what `DialFn` today gets from the
|
||||
registry's opaque backing string: `{host, port}` for tcp, `{host,
|
||||
port}` (or resolved-destination semantics) for udp, `{path}` for unix.
|
||||
The RFC-1928 ATYP encoding (IP v4/v6/domain + port — `TargetAddr`'s
|
||||
The RFC-1928 ATYP encoding (IP v4/v6/domain + port, per `TargetAddr`'s
|
||||
`to_be_bytes`) is the natural cross-substrate carrier: self-contained,
|
||||
no DNS dependency on the consumer side, and it is the encoding SOCKS5
|
||||
itself uses — the alksocks adapter maps it verbatim. Domain-form
|
||||
itself uses, which the alksocks adapter maps verbatim. Domain-form
|
||||
targets stay unresolved on the wire (producer-side resolution, the
|
||||
ssh `-D` semantic; POC #1/#2 both validated egress-side resolution).
|
||||
|
||||
### What does NOT move to alktunnels
|
||||
|
||||
The RFC 1928 conversation, reply codes, the command scope gates, the
|
||||
front doors, the `{addr, data}` datagram stage — all alksocks. The
|
||||
front doors, the `{addr, data}` datagram stage; all alksocks. The
|
||||
direct op is substrate-agnostic transport capability; it knows
|
||||
nothing about SOCKS5 (no `fast-socks5` dependency, no reply-code
|
||||
semantics upstream). The layering assertion holds: alktunnels stays
|
||||
SOCKS-ignorant; alksocks composes it.
|
||||
|
||||
## 3. The BIND metadata asks — carriers, and one alternative
|
||||
## 3. The BIND metadata asks (carriers, and one alternative)
|
||||
|
||||
alksocks' `poc-bind-findings.md` pinned two asks. The POC's stand-ins
|
||||
(factory carries the expected `bind_addr`; sentinel `0.0.0.0:0` as
|
||||
@@ -166,36 +169,37 @@ The open-op reply is the only pre-data-plane wire moment, and it
|
||||
happens *after* the establisher. SOCKS5 BIND is address-first (reply#1
|
||||
must carry `BND.ADDR` before the app server dials); alktunnels' listen
|
||||
establisher is accept-first (its establishment phase pops an
|
||||
already-accepted connection — the open resolves *because* the app
|
||||
already-accepted connection, and the open resolves *because* the app
|
||||
server dialed). The POC resolved the sequencing in shape (reply#1
|
||||
first; the tunnel open rides the accept future) — the asks are about
|
||||
first; the tunnel open rides the accept future), so the asks are about
|
||||
fidelity, not feasibility.
|
||||
|
||||
### The two asks, restated as carriers
|
||||
|
||||
**Ask #1 — bound-addr discovery without consuming an accept.**
|
||||
**Ask #1: bound-addr discovery without consuming an accept.**
|
||||
Options:
|
||||
|
||||
- **Inspect-only open / `listen/addr` query op** — the plan carries
|
||||
- **Inspect-only open / `listen/addr` query op**, where the plan
|
||||
carries
|
||||
`local_addr`, or a small query op on the listen resource. Wire
|
||||
addition (a query op is a new op, but `Query`-typed — cheap, no data
|
||||
plane).
|
||||
- **Out-of-band discovery** — the `OperationSpec.description`
|
||||
addition (a query op is a new op, but `Query`-typed, cheap with
|
||||
no data plane).
|
||||
- **Out-of-band discovery**: the `OperationSpec.description`
|
||||
round-trips through discovery (OQ-TN-08); the producer's registry
|
||||
description carries the advertised address. Zero new wire; the cost
|
||||
is staleness (the description is install-time, the address may be
|
||||
OS-chosen) and per-session dynamic listeners have no description.
|
||||
- **Same-process assembly wiring** — the POC's stand-in formalized:
|
||||
- **Same-process assembly wiring**: the POC's stand-in formalized:
|
||||
the alksocks producer's accept factory holds the far side's
|
||||
`local_addr` directly when it constructed the listen resource
|
||||
itself. Works only when the producer owns both halves in-process —
|
||||
which is the common hub case (alknodes re-producers), but not the
|
||||
itself. Works only when the producer owns both halves in-process,
|
||||
which is the common hub case (alknodes re-producers) but not the
|
||||
general composition case.
|
||||
|
||||
**Ask #2 — peer metadata on accepted handles.** Impossible to carry
|
||||
**Ask #2: peer metadata on accepted handles.** Impossible to carry
|
||||
in the open-op reply (post-reply#2 does not exist as wire); the
|
||||
option space is (i) `TargetHandle` gaining an optional `peer:
|
||||
Option<SocketAddr>`-shaped metadata field (additive, no wire change —
|
||||
Option<SocketAddr>`-shaped metadata field (additive, no wire change;
|
||||
the metadata rides the establishment phase, which is where the accept
|
||||
happens), or (ii) accept-as-open: the accepted connection opens a
|
||||
reverse channel whose open-op params carry the peer (§3.1).
|
||||
@@ -206,14 +210,14 @@ ssh's `-R` far-side listener opens `forwarded-tcpip` channels back
|
||||
toward the client, with the listen target + peer address riding the
|
||||
open params. alktunnels has the whole reverse path already
|
||||
(`open_reverse_channel`, `TunnelSession::adopt`, producer-allocated
|
||||
channel IDs — consumer.rs). The shape:
|
||||
channel IDs in consumer.rs). The shape:
|
||||
|
||||
1. The alksocks producer, on BIND, opens the listen-tunnel (or binds
|
||||
locally) and advertises reply#1 from whatever address source the
|
||||
carrier question resolves.
|
||||
2. When the far-side listener accepts, the *tunnel producer* opens a
|
||||
reverse channel toward the alksocks producer with `{listen_target,
|
||||
peer}` in params — peer fidelity for free.
|
||||
peer}` in params, giving peer fidelity for free.
|
||||
3. The alksocks producer correlates the reverse open to the waiting
|
||||
BIND session (a session-id ↔ quiet-wait-task registry), writes
|
||||
reply#2 with the real peer, and pumps.
|
||||
@@ -221,8 +225,8 @@ channel IDs — consumer.rs). The shape:
|
||||
Costs: a correlation registry on the SOCKS5 side; a direction flip in
|
||||
the composed flow (the accept-initiated open is toward the alksocks
|
||||
producer); the quiet wait must accept opens, not await a popped
|
||||
handle. Benefits: both asks' fidelity with zero special carriers —
|
||||
the metadata rides the open op, which is the wire's native
|
||||
handle. Benefits: both asks' fidelity with zero special carriers,
|
||||
since the metadata rides the open op, which is the wire's native
|
||||
"this connection is for X" moment. This is the wire-native answer and
|
||||
should be compared against the two-ask ledger rather than assuming
|
||||
the ledger's shapes.
|
||||
@@ -241,8 +245,8 @@ provider next to the `local`-socket one:
|
||||
| alksocks seam | local provider (`local` feature) | composed provider (alktunnels) |
|
||||
|---|---|---|
|
||||
| `DialFn` (CONNECT) | `dial_tcp`/`dial_unix` (`local`) | open `channels/tunnel/direct` (or the direct-op form chosen) toward the target |
|
||||
| `EgressFactory` (ASSOCIATE) | one `UdpSocket` per association (POC #2's `LocalEgress`) | per-destination udp tunnel table (POC #2's `ComposedEgress` — the direct op dissolves its `resource_of` stand-in) |
|
||||
| `BindAcceptFactory` (BIND) | real `TcpListener` per session (POC #5's local variant) | listen-tunnel session (POC #5's composed variant — the metadata carrier decides reply#1/#2 fidelity) |
|
||||
| `EgressFactory` (ASSOCIATE) | one `UdpSocket` per association (POC #2's `LocalEgress`) | per-destination udp tunnel table (POC #2's `ComposedEgress`; the direct op dissolves its `resource_of` stand-in) |
|
||||
| `BindAcceptFactory` (BIND) | real `TcpListener` per session (POC #5's local variant) | listen-tunnel session (POC #5's composed variant; the metadata carrier decides reply#1/#2 fidelity) |
|
||||
|
||||
All six paths are individually POC-validated; nothing here is
|
||||
hypothesis-level except the metadata carriers (§3).
|
||||
@@ -251,29 +255,32 @@ hypothesis-level except the metadata carriers (§3).
|
||||
(alkcall ADR-042, alktunnels' refinement) forwards tunnel data
|
||||
byte-for-byte. One structural note for the ADR: a hub that re-produces
|
||||
the direct op applies its OWN target policy at the re-produce hop
|
||||
(the hub's ACL, not the original opener's, governs the final dial) —
|
||||
correct-by-default (the hub is the network boundary) but worth pinning
|
||||
(the hub's ACL, not the original opener's, governs the final dial),
|
||||
which is correct-by-default (the hub is the network boundary) but
|
||||
worth pinning
|
||||
as the security model sentence.
|
||||
|
||||
## 5. The ask ledger, reconciled
|
||||
|
||||
The one-version alktunnels bump, post-graduation:
|
||||
|
||||
1. **The direct op** (§2) — the wire addition itself: op spec, scope
|
||||
constant, params schema, establisher shape (dial only — no
|
||||
1. **The direct op** (§2), i.e. the wire addition itself: op spec,
|
||||
scope
|
||||
constant, params schema, establisher shape (dial only, no
|
||||
listen/direct-BIND form), `DialFn` reuse.
|
||||
2. **Listen metadata** (§3) — bound-addr discovery + peer metadata,
|
||||
2. **Listen metadata** (§3), meaning bound-addr discovery + peer
|
||||
metadata,
|
||||
carriers per the ADR; OR the `forwarded-tcpip` shape as the
|
||||
combined alternative (§3.1). The POC ledger's two items map onto
|
||||
this item either way.
|
||||
3. **(alksocks courtesy, fast-socks5)** — unchanged from POC #5's
|
||||
3. **(alksocks courtesy, fast-socks5)**, unchanged from POC #5's
|
||||
ledger: publish `new_reply`; the `net` feature gate;
|
||||
`run_udp_proxy_custom`'s unconditional bind.
|
||||
|
||||
Item 3 is independent; items 1–2 are the single alktunnels version
|
||||
bump alksocks composes against.
|
||||
|
||||
## 6. The vpn-like layer — where this ends up
|
||||
## 6. The vpn-like layer (where this ends up)
|
||||
|
||||
The graduation is the mechanical half of the vpn-like story; the
|
||||
addressing half rides on top:
|
||||
@@ -283,7 +290,8 @@ addressing half rides on top:
|
||||
translation: intercept `connect(10.x.y.z:p)`, match the subnet
|
||||
against a per-identity address table, open the direct op toward the
|
||||
owning node's resource (or the named resource the address maps
|
||||
to). No wire change — DST.ADDR/BND.ADDR are arbitrary RFC fields;
|
||||
to). No wire change, since DST.ADDR/BND.ADDR are arbitrary RFC
|
||||
fields;
|
||||
the intercept point is the SOCKS5 front door (tun2proxy routes the
|
||||
host's traffic there, `--proxy socks5://...` natively). The
|
||||
POC #2 `resource_of` seam is the same translation at the datagram
|
||||
@@ -295,26 +303,27 @@ addressing half rides on top:
|
||||
prior-art check on tun2proxy's virtual-DNS/virtual-network support
|
||||
(alksocks phase-0 flagged this) when that layer is specced.
|
||||
- **alknodes (the planned assembly layer).** The hub/spoke shape the
|
||||
user describes — spokes expose resources (postgres, redis,
|
||||
containers), the hub re-produces them to privileged spokes, spokes
|
||||
can be hubs themselves (russian-doll: an internal private network
|
||||
with selective exposure) — is the terminate-and-re-produce story
|
||||
user describes, where spokes expose resources (postgres, redis,
|
||||
containers), the hub re-produces them to privileged spokes, and
|
||||
spokes can be hubs themselves (russian-doll: an internal private
|
||||
network
|
||||
with selective exposure), is the terminate-and-re-produce story
|
||||
alktunnels' OQ-TN-12 deferred until a concrete consumer exists.
|
||||
alknodes is that consumer. What it needs from this graduation:
|
||||
the direct op (dynamic egress through the overlay), the listen
|
||||
surface (accept-side resources), and the metadata carriers (BIND
|
||||
fidelity through one or more hops). What it adds on its own layer:
|
||||
resource directory, identity→subnet mapping, hub ACL policy —
|
||||
all assembly, no new wire.
|
||||
resource directory, identity→subnet mapping, hub ACL policy, all
|
||||
assembly and no new wire.
|
||||
|
||||
## 7. Open items for Phase 1 / ADRs
|
||||
|
||||
- The direct op's wire shape (op id, scope string, params encoding) —
|
||||
- The direct op's wire shape (op id, scope string, params encoding):
|
||||
one-way door; ADR before the first consumer.
|
||||
- The listen-metadata carriers (or the `forwarded-tcpip` alternative)
|
||||
— same gate.
|
||||
- The listen-metadata carriers (or the `forwarded-tcpip` alternative):
|
||||
the same gate.
|
||||
- Whether BIND ships in alksocks' base crate + default accept variant
|
||||
(OQ-SK-08's remaining decision) — unchanged by this doc, but the
|
||||
(OQ-SK-08's remaining decision); unchanged by this doc, but the
|
||||
carrier choice feeds it.
|
||||
- The scope-separation posture: `tunnel:direct` ≠ `tunnel:open` ≠
|
||||
ingress scopes; pin the convention sentence in the ADR.
|
||||
|
||||
+32
-32
@@ -18,7 +18,7 @@ leverages:
|
||||
4. **Task-Driven**: Structured task graphs with dependency analysis
|
||||
5. **Safe Exit**: Always have a way to unblock progress when stuck
|
||||
6. **Categorical Estimates**: Use risk/scope/impact categories, not time
|
||||
estimates. These are structurally important — upstream failures multiply
|
||||
estimates. These are structurally important, since upstream failures multiply
|
||||
downstream damage regardless of developer type (human or LLM). See the
|
||||
cost-benefit framework in taskgraph's framework docs.
|
||||
|
||||
@@ -47,11 +47,11 @@ approaches
|
||||
**Process**:
|
||||
|
||||
1. Architect creates the architecture documentation structure:
|
||||
- `docs/architecture/README.md` — index with doc table, ADR table, lifecycle
|
||||
- `docs/architecture/overview.md` — package purpose, exports, dependencies
|
||||
- `docs/architecture/<component>.md` — one focused doc per component/area
|
||||
- `docs/architecture/decisions/` — numbered ADR files (ADR-001, ADR-002, ...)
|
||||
- `docs/architecture/open-questions.md` — centralized tracker with OQ-IDs
|
||||
- `docs/architecture/README.md`: index with doc table, ADR table, lifecycle
|
||||
- `docs/architecture/overview.md`: package purpose, exports, dependencies
|
||||
- `docs/architecture/<component>.md`: one focused doc per component/area
|
||||
- `docs/architecture/decisions/`: numbered ADR files (ADR-001, ADR-002, ...)
|
||||
- `docs/architecture/open-questions.md`: centralized tracker with OQ-IDs
|
||||
2. All docs start in `Draft` status. Spec docs reference ADRs by number (not
|
||||
inline rationale) and OQs by number (not inline questions).
|
||||
3. Architecture Review validates for ambiguities, risks, and structural issues
|
||||
@@ -134,16 +134,16 @@ WHAT, ADRs explain WHY, open questions track what's unresolved.
|
||||
- Extract decisions into numbered ADRs in `decisions/` directory
|
||||
- Centralize open questions in `open-questions.md` with OQ-IDs
|
||||
- Maintain `README.md` as the architecture index
|
||||
- Keep spec documents focused (~500 lines) — reference ADRs and OQs, don't inline them
|
||||
- Keep spec documents focused (~500 lines); reference ADRs and OQs, don't inline them
|
||||
- Redirect exploration work to Research Specialist
|
||||
- Iterate based on review feedback
|
||||
|
||||
**Deliverables**:
|
||||
|
||||
- `docs/architecture/README.md` — index with doc table, ADR table, lifecycle
|
||||
- `docs/architecture/<component>.md` — focused spec documents
|
||||
- `docs/architecture/decisions/` — numbered ADR files
|
||||
- `docs/architecture/open-questions.md` — centralized OQ tracker
|
||||
- `docs/architecture/README.md`: index with doc table, ADR table, lifecycle
|
||||
- `docs/architecture/<component>.md`: focused spec documents
|
||||
- `docs/architecture/decisions/`: numbered ADR files
|
||||
- `docs/architecture/open-questions.md`: centralized OQ tracker
|
||||
|
||||
---
|
||||
|
||||
@@ -179,17 +179,17 @@ sessions.
|
||||
**Mode**: Primary (manages worktrees and agent sessions)
|
||||
|
||||
**Uses**: The `worktree` tool from the **open-coordinator** opencode plugin.
|
||||
Single tool with `{action, args}` dispatch. Role is auto-detected — coordinator
|
||||
Single tool with `{action, args}` dispatch. Role is auto-detected: coordinator
|
||||
sessions get the full operation set, spawned implementation sessions get a
|
||||
limited set (current, notify, status). No mode toggle required.
|
||||
|
||||
**Tools**:
|
||||
|
||||
- `worktree({action, args})` — spawn, sessions, dashboard, message, abort,
|
||||
- `worktree({action, args})`: spawn, sessions, dashboard, message, abort,
|
||||
cleanup
|
||||
- Bash (opencode CLI for session interaction)
|
||||
- Read (monitor task files)
|
||||
- `memory` / `memory_compact` — context management and session history (via
|
||||
- `memory` / `memory_compact`, for context management and session history (via
|
||||
@alkdev/open-memory, when available)
|
||||
|
||||
**Key Behaviors**:
|
||||
@@ -218,10 +218,10 @@ limited set (current, notify, status). No mode toggle required.
|
||||
**Tools**:
|
||||
|
||||
- Read, Write, Edit, Glob, Grep, Bash
|
||||
- `worktree({action: "notify", ...})` — report progress/blockers to coordinator
|
||||
- `worktree({action: "current"})` — verify worktree assignment
|
||||
- `worktree({action: "notify", ...})`, for reporting progress/blockers to coordinator
|
||||
- `worktree({action: "current"})`, to verify worktree assignment
|
||||
- webSearch (documentation lookup)
|
||||
- `memory` / `memory_compact` — context management (via @alkdev/open-memory,
|
||||
- `memory` / `memory_compact`, for context management (via @alkdev/open-memory,
|
||||
when available)
|
||||
|
||||
**Key Behaviors**:
|
||||
@@ -388,7 +388,7 @@ Implement OAuth2 authentication with provider abstraction.
|
||||
### Categorical Estimates
|
||||
|
||||
These fields are structurally important, not optional metadata. They power
|
||||
`taskgraph decompose`, `risk-path`, `critical`, and `bottleneck` — commands that
|
||||
`taskgraph decompose`, `risk-path`, `critical`, and `bottleneck`, being commands that
|
||||
reveal structural problems in the task graph. A task missing `scope`, `risk`,
|
||||
`impact`, or `level` is a red flag indicating incomplete decomposition. See the
|
||||
cost-benefit framework in taskgraph's framework docs for the reasoning.
|
||||
@@ -422,15 +422,15 @@ cost-benefit framework in taskgraph's framework docs for the reasoning.
|
||||
|
||||
### Architecture work and the `level` field
|
||||
|
||||
Architecture work uses the same taskgraph format as implementation work —
|
||||
Architecture work uses the same taskgraph format as implementation work, and
|
||||
there is no separate `architecture` level. The `level` enum
|
||||
(`planning, decomposition, implementation, review, research`) already covers
|
||||
architecture activities:
|
||||
|
||||
| Architecture activity | `level` | Rationale |
|
||||
|---|---|---|
|
||||
| Writing an ADR (the decision) | `implementation` | The ADR *is* the deliverable — the implementation of an architecture decision. Calling it `planning` would mislabel the artifact. |
|
||||
| Decomposing a spec into ADRs + OQs | `decomposition` | Direct fit — breaking a large architecture area into decideable units. |
|
||||
| Writing an ADR (the decision) | `implementation` | The ADR *is* the deliverable, namely the implementation of an architecture decision. Calling it `planning` would mislabel the artifact. |
|
||||
| Decomposing a spec into ADRs + OQs | `decomposition` | Direct fit, as it means breaking a large architecture area into decideable units. |
|
||||
| Architecture review pass | `review` | Direct fit. |
|
||||
| Research / POC before a decision | `research` | Direct fit. |
|
||||
| Spec writing (overview, component spec) | `implementation` | The spec is the deliverable. |
|
||||
@@ -480,7 +480,7 @@ the deferral has two halves that must stay in sync:
|
||||
2. **The machine-readable half** (in `tasks/architecture/`): an
|
||||
external-trigger tracker task represents the unblocking condition. It is
|
||||
tagged `[external-trigger, deferred-oq]`, has `risk: trivial` and
|
||||
`level: research` (it is not actionable work — it tracks whether the
|
||||
`level: research` (it is not actionable work; it tracks whether the
|
||||
external condition has arrived), and its `id` is referenced from the OQ's
|
||||
`Blocked on:` text. This is the surface for the task graph: `taskgraph`
|
||||
tools can reason about it, and downstream work that depends on the
|
||||
@@ -490,7 +490,7 @@ When the unblocking condition arrives (a use case materializes, a crate is
|
||||
specced), the tracker task is marked `completed` and the OQ transitions from
|
||||
`deferred(scope)` to `open` (or directly to `resolved` if no architecture
|
||||
decision remains). The two halves serve different audiences and use one edge
|
||||
type (`depends_on`) — the reverse lookup uses `taskgraph dependents`. Do not
|
||||
type (`depends_on`); the reverse lookup uses `taskgraph dependents`. Do not
|
||||
add a `blocks:` field; it duplicates the edge and creates a sync hazard.
|
||||
|
||||
## Review Injection
|
||||
@@ -509,7 +509,7 @@ Use graph analysis to determine where reviews should happen:
|
||||
### Current (open-coordinator plugin)
|
||||
|
||||
The Coordinator uses the `worktree` tool from the open-coordinator opencode
|
||||
plugin. It's a single tool with `{action, args}` dispatch — no separate
|
||||
plugin. It's a single tool with `{action, args}` dispatch, with no separate
|
||||
enable/toggle steps. Role is auto-detected from session state.
|
||||
|
||||
```
|
||||
@@ -563,13 +563,13 @@ a worktree.
|
||||
When the open-memory plugin is available alongside open-coordinator, the
|
||||
coordinator gains:
|
||||
|
||||
- `memory({tool: "children", args: {sessionId: "..."}})` — view sub-agent
|
||||
- `memory({tool: "children", args: {sessionId: "..."}})`, to view sub-agent
|
||||
sessions spawned from the coordinator
|
||||
- `memory({tool: "messages", args: {sessionId: "..."}})` — read a spawned
|
||||
- `memory({tool: "messages", args: {sessionId: "..."}})`, to read a spawned
|
||||
session's conversation for debugging
|
||||
- `memory({tool: "context"})` — check context window usage before long
|
||||
- `memory({tool: "context"})`, to check context window usage before long
|
||||
monitoring sessions
|
||||
- `memory_compact()` — proactively compact at natural breakpoints
|
||||
- `memory_compact()`, to proactively compact at natural breakpoints
|
||||
|
||||
Implementation agents can also use `memory({tool: "context"})` and
|
||||
`memory_compact()` to manage their context during long tasks.
|
||||
@@ -593,7 +593,7 @@ Once the hub is operational, coordination uses native operations:
|
||||
```
|
||||
|
||||
State moves from in-process tracking to Postgres `mappings` table. The
|
||||
open-coordinator plugin becomes unnecessary — the hub provides the same
|
||||
open-coordinator plugin becomes unnecessary; the hub provides the same
|
||||
capabilities as server-side operations accessible from any environment.
|
||||
|
||||
## Document Structure
|
||||
@@ -660,8 +660,8 @@ Accepted | Proposed | Deprecated | Superseded
|
||||
(Links to related specs and ADRs)
|
||||
```
|
||||
|
||||
Numbering starts at 001 within each project. ADRs are stable — once Accepted,
|
||||
they don't revert. If superseded, mark the old one and create a new one.
|
||||
Numbering starts at 001 within each project. ADRs are stable, so once
|
||||
Accepted they don't revert. If superseded, mark the old one and create a new one.
|
||||
|
||||
### Open Questions Format
|
||||
|
||||
@@ -676,7 +676,7 @@ they don't revert. If superseded, mark the old one and create a new one.
|
||||
- **Status**: open | resolved | deferred(scope) | partially resolved
|
||||
- **Priority**: high | medium | low
|
||||
- **Resolution**: (when resolved)
|
||||
- **Blocked on**: (when deferred — concrete dependency that would unblock)
|
||||
- **Blocked on**: (when deferred; concrete dependency that would unblock)
|
||||
- **Cross-references**: OQ-NN, ADR-NNN
|
||||
```
|
||||
|
||||
|
||||
Generated
+3
-3
@@ -27,9 +27,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "alkcall"
|
||||
version = "0.10.0"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b8ef0591beff0e3fe64b7277a67d8e7caeccf71c27538b4b7aced028d22ca44"
|
||||
checksum = "690d747f42baf385dc22cd120f79d43cf1ea239bc426675e0e1e225b57fc031a"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
@@ -48,7 +48,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "alktunnels"
|
||||
version = "0.4.0"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"alkcall",
|
||||
"bytes",
|
||||
|
||||
+7
-7
@@ -7,19 +7,19 @@ from alkcall's `docs/research/fuzzing.md`).
|
||||
|
||||
## Layout
|
||||
|
||||
- `fuzz_targets/` — one binary per target; thin `fuzz_target!` wrappers.
|
||||
- `shared/` — the invariant logic, as a plain library so normal
|
||||
- `fuzz_targets/`: one binary per target; thin `fuzz_target!` wrappers.
|
||||
- `shared/`, the invariant logic, as a plain library so normal
|
||||
`cargo test` (stable toolchain) can replay the committed corpora
|
||||
through the same invariants (`fuzz/shared/src/*.rs` `#[cfg(test)]`
|
||||
modules; quinn's CI pattern). The fuzz binaries are nightly-only;
|
||||
the shared crate is stable-clean.
|
||||
- `corpus/<target>/` — committed hand-made seeds (regenerate with
|
||||
- `corpus/<target>/`: committed hand-made seeds (regenerate with
|
||||
`python3 fuzz/gen_fuzz_seeds.py` from the repo root). Grown corpora
|
||||
and artifacts are gitignored.
|
||||
- `run-detached.sh` — mandatory runner for agent sessions: wraps
|
||||
- `run-detached.sh`, the mandatory runner for agent sessions: wraps
|
||||
`cargo fuzz run` in `setsid` + `nohup` + log redirection so an OOM
|
||||
in a target can never take down the agent host (fuzzing.md §5).
|
||||
- `json.dict` — JSON token dictionary for the params target
|
||||
- `json.dict`: a JSON token dictionary for the params target
|
||||
(transferred from alkcall).
|
||||
|
||||
## Targets
|
||||
@@ -28,13 +28,13 @@ from alkcall's `docs/research/fuzzing.md`).
|
||||
|---|---|---|
|
||||
| `datagram_codec` | `DatagramReader::feed` / `frame_datagram` (`src/wire.rs`) | no-panic; infallible decode; yielded datagrams ≤ `MAX_DATAGRAM_LEN`; F-2 sentinel layering (empty datagram ≠ EOF, only from `[00 00]` headers); splitting transparency (3-byte slices == whole-input feed); re-frame round-trip; oversize rejection exactly at the bound; `is_mid_datagram` tracks framing; decoder-state retained-bytes bound |
|
||||
| `params_parse` | `TunnelParams` / `TunnelDirectParams` / `TunnelForwardedParams` / `SubstrateAddr` serde parses + `parse_listen_reply` / `listen_reply_fields` / `establishment_reason` (`src/params.rs`) | no-panic on raw bytes and structured Values; clean typed failure only; round-trip for valid params; `listen: 0` pin; loud/lenient projection-pair agreement; pinned establishment-reason table |
|
||||
| `session_pumps` | op sequences (`SessionSequence`) over one live channels stack per exec (MuxRunner → real demux → `ChannelManager` per side; the `TunnelSession` data-plane mirrors — codec plane + `pump_bidi` stream planes) | no-panic / no-wedge (5 s reconcile bound); two-pump shutdown-on-completion; lossless p2c copy count; EOF-sentinel teardown semantics (drain-before-EOF, map empties); FIFO datagram reconciliation (framing-walk model); `pending` backlog ≤ routed-batch remainder (the §1.3 bound); decoder-state retained-bytes bound re-asserted per op; monotonic counters |
|
||||
| `session_pumps` | op sequences (`SessionSequence`) over one live channels stack per exec (MuxRunner → real demux → `ChannelManager` per side; the `TunnelSession` data-plane mirrors, codec plane + `pump_bidi` stream planes) | no-panic / no-wedge (5 s reconcile bound); two-pump shutdown-on-completion; lossless p2c copy count; EOF-sentinel teardown semantics (drain-before-EOF, map empties); FIFO datagram reconciliation (framing-walk model); `pending` backlog ≤ routed-batch remainder (the §1.3 bound); decoder-state retained-bytes bound re-asserted per op; monotonic counters |
|
||||
|
||||
## Commands
|
||||
|
||||
`cargo fuzz build` and `cargo fuzz run` must be executed with `fuzz/`
|
||||
(or deeper) as the working directory so rustup selects the pinned
|
||||
nightly toolchain — the detached runner handles that itself.
|
||||
nightly toolchain. The detached runner handles that itself.
|
||||
|
||||
```sh
|
||||
# build (nightly, pinned by rust-toolchain.toml inside fuzz/; run from fuzz/)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Detached fuzzing runner for agent sessions: the fuzz campaign never
|
||||
# runs as a foreground child of the session (OOM in a target must not
|
||||
# take down the agent host — the opencode server shares the process
|
||||
# take down the agent host (the opencode server shares the process
|
||||
# tree and cgroup), and survives the session ending.
|
||||
#
|
||||
# Usage: fuzz/run-detached.sh <target> [extra libfuzzer args...]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! `Arbitrary` support for `serde_json::Value` — a bounded-depth
|
||||
//! `Arbitrary` support for `serde_json::Value`: a bounded-depth
|
||||
//! recursive generator shared by the semantic targets. Depth and size
|
||||
//! bounds keep exec/s high (serde_json's own 128-depth recursion limit
|
||||
//! is the decode-side guard; this generator explores structure).
|
||||
|
||||
@@ -10,8 +10,8 @@ use alktunnels::wire::{frame_datagram, DatagramCodecError, DatagramReader, MAX_D
|
||||
/// accumulation stay bounded by the fed chunk size plus one frame
|
||||
/// (the header + a full u16 payload). The session-shape
|
||||
/// `DatagramHalves.pending` backlog between chunk reads and
|
||||
/// `recv_datagram` pulls is a separate question (fuzzing.md §3.1) —
|
||||
/// this asserts the decoder state itself never grows with input.
|
||||
/// `recv_datagram` pulls is a separate question (fuzzing.md §3.1).
|
||||
/// This asserts the decoder state itself never grows with input.
|
||||
fn assert_retained_bound(reader: &DatagramReader, fed: usize) {
|
||||
let retained = reader.retained_bytes();
|
||||
let max = fed + MAX_DATAGRAM_LEN + 2;
|
||||
@@ -25,7 +25,7 @@ fn assert_retained_bound(reader: &DatagramReader, fed: usize) {
|
||||
pub fn fuzz_datagram_codec(data: &[u8]) {
|
||||
// The infallible decode direction: any chunk sequence feeds
|
||||
// cleanly. Drive the reader with the whole input and with awkward
|
||||
// 3-byte slices — splitting transparency must hold for both.
|
||||
// 3-byte slices; splitting transparency must hold for both.
|
||||
let mut whole = DatagramReader::new();
|
||||
let whole_dgs = whole.feed(data);
|
||||
assert_retained_bound(&whole, data.len());
|
||||
@@ -57,7 +57,8 @@ pub fn fuzz_datagram_codec(data: &[u8]) {
|
||||
}
|
||||
|
||||
// F-2 sentinel layering: an empty datagram comes only from a real
|
||||
// `[00 00]` length header **at a frame boundary** — never invented
|
||||
// `[00 00]` length header **at a frame boundary**: it is never
|
||||
// invented
|
||||
// by the codec, and never treated as stream end. The count walks
|
||||
// the framing (a [00 00] pair inside a payload region is payload,
|
||||
// not a header).
|
||||
@@ -139,7 +140,7 @@ fn count_empty_frames(data: &[u8]) -> usize {
|
||||
}
|
||||
|
||||
/// Split the input into complete `[len: u16 BE][payload]` frames from
|
||||
/// the start. Returns the frames and the byte length they occupy —
|
||||
/// the start. Returns the frames and the byte length they occupy;
|
||||
/// the remainder (if any) is a partial/trailing region.
|
||||
fn split_frames(data: &[u8]) -> (Vec<&[u8]>, usize) {
|
||||
let mut frames = Vec::new();
|
||||
@@ -160,7 +161,7 @@ fn ends_inside_frame(data: &[u8], well_framed_len: usize) -> bool {
|
||||
// Mid-datagram iff the well-framed prefix leaves a *parsed but
|
||||
// incomplete* frame: a remainder ≥ 2 bytes means the length header
|
||||
// was consumed and the payload is in flight (a remainder of 1 byte
|
||||
// is a buffered header fragment — not yet a datagram in flight).
|
||||
// is a buffered header fragment, not yet a datagram in flight).
|
||||
let remainder = data.len() - well_framed_len;
|
||||
remainder >= 2
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ pub fn fuzz_params_parse(data: &[u8]) {
|
||||
}
|
||||
|
||||
/// Run every params/reply parser against one JSON value. Each parser
|
||||
/// must return cleanly (Result / typed projection) — a panic is the
|
||||
/// must return cleanly (Result / typed projection); a panic is the
|
||||
/// finding.
|
||||
fn parse_shapes(value: &Value) {
|
||||
// Open-op params: wire-stable {resource, substrate}.
|
||||
@@ -100,8 +100,8 @@ where
|
||||
assert_eq!(&back, params);
|
||||
}
|
||||
|
||||
/// The typed establishment-reason surface: a pass-through extractor —
|
||||
/// `Some(details.reason)` iff the code is `channel:open_failed` and
|
||||
/// The typed establishment-reason surface: a pass-through extractor,
|
||||
/// yielding `Some(details.reason)` iff the code is `channel:open_failed` and
|
||||
/// the reason is a string, `None` otherwise (any string is surfaced
|
||||
/// as-is; validation is the consumer's job). The harness must model
|
||||
/// extraction, not a validated table.
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
//! Invariants for the stateful two-pump session shape (fuzzing.md
|
||||
//! §3.3, wave 2): one real channels stack per sequence — MuxRunner →
|
||||
//! §3.3, wave 2): one real channels stack per sequence (MuxRunner →
|
||||
//! raw chunk stream → `run_demux_loop_for_client` → ChannelManager
|
||||
//! per side — under adversarial op sequences, with the two-pump
|
||||
//! per side), under adversarial op sequences, with the two-pump
|
||||
//! shutdown-on-completion contract (AGENTS.md convention 8), the
|
||||
//! EOF-sentinel teardown semantics, and the `DatagramHalves.pending`
|
||||
//! backlog bound (§1.3) asserted by byte accounting.
|
||||
//!
|
||||
//! Topology (the real protocol shape, mirrored): the PRODUCER is the
|
||||
//! connect side — `Open` allocates a session (producer
|
||||
//! connect side: `Open` allocates a session (producer
|
||||
//! `open_channel` + a producer-side drainer, the pump handler's
|
||||
//! stand-in) and the CONSUMER (accept side) `Adopt`s the
|
||||
//! producer-allocated id (the early-arrival drain is the real
|
||||
//! adopt-race path). Sessions are addressed by INDEX into the
|
||||
//! harness's session table (no id collisions, no masking — the
|
||||
//! harness's session table (no id collisions, no masking, so the
|
||||
//! generator stays simple); ids are the manager's own allocation.
|
||||
//!
|
||||
//! Data-plane shapes mirror `TunnelSession`: datagram planes drive
|
||||
@@ -26,7 +26,7 @@
|
||||
//!
|
||||
//! The framing model is STATEFUL per datagram session: a partial
|
||||
//! trailing frame persists in the model's walk offset (the decoder
|
||||
//! buffers it; the next chunk completes it) — exactly the wave-1
|
||||
//! buffers it; the next chunk completes it), exactly the wave-1
|
||||
//! framing walk, carried across ops. Sentinel layering (F-2) holds
|
||||
//! at the session layer: a routed empty datagram is DATA (the codec
|
||||
//! yields it); the channels EOF sentinel is a separate mechanism
|
||||
@@ -51,13 +51,13 @@ use alktunnels::wire::{DatagramReader, MAX_DATAGRAM_LEN};
|
||||
const MAX_PAYLOAD: usize = 512;
|
||||
|
||||
/// The op sequence the fuzzer generates. Channel addressing is by
|
||||
/// SESSION INDEX (0..8) — the harness maps indices to the manager's
|
||||
/// SESSION INDEX (0..8); the harness maps indices to the manager's
|
||||
/// allocated ids; the generator never names raw ids.
|
||||
#[derive(Debug, arbitrary::Arbitrary, Clone)]
|
||||
pub enum SessionOp {
|
||||
/// The producer opens a session (allocates the channel id, the
|
||||
/// producer-side drainer runs). `datagram` shapes the consumer
|
||||
/// data plane (the params' substrate rode the open call — the
|
||||
/// data plane (the params' substrate rode the open call; the
|
||||
/// harness re-states it here, the adopt's shape input).
|
||||
Open { datagram: bool },
|
||||
/// Route one framed datagram (the `[len: u16 BE]` form, clamped
|
||||
@@ -68,14 +68,14 @@ pub enum SessionOp {
|
||||
/// against in-process peer halves for stream sessions).
|
||||
Adopt { session: u8 },
|
||||
/// Push bytes into a stream session's peer half (the pump's p2c
|
||||
/// source). `len == 0` drops the peer write half — the EOF path.
|
||||
/// source). `len == 0` drops the peer write half (the EOF path).
|
||||
Pump { session: u8, len: u16, byte: u8 },
|
||||
/// Pull one datagram off the session's backlog (the
|
||||
/// `recv_datagram` pull — the consumer-driven drain). `None`
|
||||
/// `recv_datagram` pull, the consumer-driven drain). `None`
|
||||
/// backlog = a no-op (the pull would block on the stream read;
|
||||
/// the harness models the batched-arrival case only).
|
||||
Pull { session: u8 },
|
||||
/// Tear the session's id down (both sides — the out-of-band
|
||||
/// Tear the session's id down (both sides, the out-of-band
|
||||
/// `channel/close` shape: the serving side reaps; the consumer
|
||||
/// session reaps its own entry).
|
||||
Teardown { session: u8 },
|
||||
@@ -88,7 +88,7 @@ pub struct SessionSequence {
|
||||
pub ops: Vec<SessionOp>,
|
||||
}
|
||||
|
||||
/// A datagram session's consumer-side plane — the `DatagramHalves`
|
||||
/// A datagram session's consumer-side plane: the `DatagramHalves`
|
||||
/// mirror (reader + pending queue), with counters.
|
||||
struct DatagramPlane {
|
||||
recv: MpscRecvStream,
|
||||
@@ -98,8 +98,8 @@ struct DatagramPlane {
|
||||
eof: bool,
|
||||
}
|
||||
|
||||
/// The framing model per datagram session — the wave-1 walk, carried
|
||||
/// across ops (stateful): partial trailing frames persist.
|
||||
/// The framing model per datagram session is the wave-1 walk,
|
||||
/// carried across ops (stateful): partial trailing frames persist.
|
||||
#[derive(Default)]
|
||||
struct DgModel {
|
||||
/// Expected datagram payload lengths (FIFO), in rout order.
|
||||
@@ -126,8 +126,8 @@ impl DgModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// A stream session's consumer-side plane — the `pump_against`
|
||||
/// mirror with counters.
|
||||
/// A stream session's consumer-side plane (the `pump_against`
|
||||
/// mirror) with counters.
|
||||
struct StreamPlane {
|
||||
/// The pump's p2c source (the harness pushes Pump ops here);
|
||||
/// `None` once dropped (the EOF path).
|
||||
@@ -139,7 +139,7 @@ struct StreamPlane {
|
||||
/// reader task's counter).
|
||||
c2p_counter: Arc<std::sync::atomic::AtomicU64>,
|
||||
/// The c2p far-end reader task (ends when the pump's write half
|
||||
/// shuts down — the shutdown-on-completion observable).
|
||||
/// shuts down, the shutdown-on-completion observable).
|
||||
reader: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ struct StreamPlane {
|
||||
struct Session {
|
||||
channel_id: u32,
|
||||
/// The producer's channel write half (the mux-registered
|
||||
/// sender — the pump handler's write side).
|
||||
/// sender, the pump handler's write side).
|
||||
producer_send: Option<alkcall::channels::reassembly::MpscSendStream>,
|
||||
dg_model: Option<DgModel>,
|
||||
dg_plane: Option<DatagramPlane>,
|
||||
@@ -265,10 +265,10 @@ impl SessionHarness {
|
||||
let channel_id = s.channel_id;
|
||||
let is_datagram = s.dg_model.is_some();
|
||||
// A re-adopt of a LIVE session is the manager's
|
||||
// documented id-collision outcome (ChannelExists) —
|
||||
// documented id-collision outcome (ChannelExists):
|
||||
// a legal no-op, not a finding (the alkcall
|
||||
// duplicate-adopt harness lesson; the live entry is
|
||||
// left intact — alkcall 0.8.1's fix).
|
||||
// left intact, per alkcall 0.8.1's fix).
|
||||
if s.adopted {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -363,7 +363,7 @@ impl SessionHarness {
|
||||
SessionOp::ClearAll => {
|
||||
// clear_all empties the ENTIRE channel map; the
|
||||
// returned entries are the opener-ledger records
|
||||
// (ADR-047 §7) — adopted channels carry no ledger
|
||||
// (ADR-047 §7); adopted channels carry no ledger
|
||||
// entry (the remote side opened them), so the
|
||||
// return value is NOT the reaped-id set (the
|
||||
// alkcall §7.7 harness lesson, learned again). The
|
||||
@@ -490,7 +490,7 @@ impl SessionHarness {
|
||||
let Some(plane) = s.dg_plane.as_ref() else {
|
||||
unreachable!("drained above");
|
||||
};
|
||||
// The FIFO expectation: the session consumes in order —
|
||||
// The FIFO expectation: the session consumes in order;
|
||||
// reconcile the pending queue head-by-head against the
|
||||
// model's expected tail. The model's `expected` holds
|
||||
// the routed-but-not-pulled datagrams (Pull pops both
|
||||
@@ -500,7 +500,7 @@ impl SessionHarness {
|
||||
// check; equal lengths are the lossless-delivery check.
|
||||
if plane.pending.len() != model.expected.len() {
|
||||
return Err(format!(
|
||||
"session {index}: backlog {} != expected {} — routed datagrams \
|
||||
"session {index}: backlog {} != expected {}; routed datagrams \
|
||||
must be either pulled or pending, never lost",
|
||||
plane.pending.len(),
|
||||
model.expected.len()
|
||||
@@ -516,7 +516,7 @@ impl SessionHarness {
|
||||
}
|
||||
// The session-shape bound (the §1.3 assertion): the
|
||||
// backlog holds exactly the routed batch beyond the
|
||||
// pulled datagrams — with the per-op drain the queue
|
||||
// pulled datagrams: with the per-op drain the queue
|
||||
// never exceeds one chunk's worth of batched datagrams,
|
||||
// and every routed datagram is eventually pullable. The
|
||||
// amplification question is thus bounded: the backlog
|
||||
@@ -554,7 +554,7 @@ impl SessionHarness {
|
||||
})
|
||||
.expect("pump task");
|
||||
// The p2c count equals what the harness pushed
|
||||
// (lossless two-pump copy — the copy never truncates).
|
||||
// (lossless two-pump copy; the copy never truncates).
|
||||
assert_eq!(
|
||||
p2c, plane.p2c_written,
|
||||
"session {index}: p2c copy count {p2c} != pushed {}",
|
||||
@@ -603,7 +603,7 @@ pub fn fuzz_session_pumps(seq: &SessionSequence) {
|
||||
let retained = plane.reader.retained_bytes();
|
||||
assert!(
|
||||
retained <= MAX_DATAGRAM_LEN + 2,
|
||||
"session {index}: decoder retained {retained} bytes — \
|
||||
"session {index}: decoder retained {retained} bytes; \
|
||||
the state must stay bounded by one frame"
|
||||
);
|
||||
}
|
||||
@@ -621,7 +621,7 @@ pub fn fuzz_session_pumps_arbitrary(data: &[u8]) {
|
||||
match <SessionSequence as arbitrary::Arbitrary>::arbitrary_take_rest(u) {
|
||||
Ok(seq) => fuzz_session_pumps(&seq),
|
||||
Err(_) => {
|
||||
// A sequence that does not decode is not a finding — the
|
||||
// A sequence that does not decode is not a finding. The
|
||||
// generator's op space is structured; malformed byte
|
||||
// streams are the corpus's job, not the harness's.
|
||||
}
|
||||
@@ -652,7 +652,7 @@ mod corpus_replay {
|
||||
);
|
||||
}
|
||||
|
||||
/// Semantic fixture: the §1.3 amplification probe — open, adopt
|
||||
/// Semantic fixture: the §1.3 amplification probe: open, adopt
|
||||
/// a datagram session, route batched frames (3-byte, empty,
|
||||
/// 1-byte) through the real demux; the backlog must reconcile
|
||||
/// against the FIFO expectation exactly (F-2: the empty
|
||||
@@ -685,7 +685,7 @@ mod corpus_replay {
|
||||
}
|
||||
|
||||
/// Semantic fixture: the half-close shape (the W4 probe at the
|
||||
/// session layer) — open a stream session, adopt, pump bytes,
|
||||
/// session layer): open a stream session, adopt, pump bytes,
|
||||
/// drop the p2c source (EOF), then teardown: the pump must
|
||||
/// complete and the pushed bytes must reconcile (the two-pump
|
||||
/// contract).
|
||||
|
||||
@@ -16,26 +16,26 @@ Phase 1 promoted the Phase 0 OQ ledger into `docs/architecture/open-questions.md
|
||||
(OQ-TN-01..14) and split OQ-TN-11/13 into resolved halves (ADR-001/ADR-003).
|
||||
The older ledgers still describe pre-Phase-1 states in two places:
|
||||
|
||||
1. `docs/research/phase-0-findings.md` — the OQ sections (OQ-TN-01..10) should
|
||||
1. `docs/research/phase-0-findings.md`: the OQ sections (OQ-TN-01..10) should
|
||||
carry one-line "promoted to `docs/architecture/open-questions.md` as OQ-TN-NN"
|
||||
pointers so a reader landing in the research doc finds the live tracker (the
|
||||
convergence checklist's last unchecked item says "promoted ... with statuses" —
|
||||
the promotion exists; the back-pointer is the missing half).
|
||||
2. `AGENTS.md` — the "Architecture Context" section says `docs/architecture/`
|
||||
convergence checklist's last unchecked item says "promoted ... with statuses";
|
||||
the promotion exists, and the back-pointer is the missing half).
|
||||
2. `AGENTS.md`: the "Architecture Context" section says `docs/architecture/`
|
||||
"will hold the authoritative spec (Phase 1 of the SDD process)... It does not
|
||||
exist yet". That is now stale: the spec exists (ADR-001..006 + 4 spec docs +
|
||||
BAST + OQ tracker). Update the paragraph to point at the real directory and
|
||||
adjust the "read it before non-trivial changes once it exists" line. Three
|
||||
adjacent stale references in the same file fold in (same staleness class,
|
||||
one pass — found during task review 2026-09-08):
|
||||
one pass; found during task review 2026-09-08):
|
||||
- Convention 16 (module structure): "expected shape (to be finalized in
|
||||
the architecture docs)" — the docs now exist and pin the module shape;
|
||||
the architecture docs)"; the docs now exist and pin the module shape;
|
||||
state it as decided (producer half / consumer half / shared wire).
|
||||
- Convention 11: "alkcall is v0.5.x" — Cargo.toml pins `0.7.0` and the
|
||||
- Convention 11: "alkcall is v0.5.x". Cargo.toml pins `0.7.0`, and the
|
||||
convention's own later text describes the 0.6.0/0.7.0 surfaces; the
|
||||
version sentence is self-contradicting.
|
||||
- Architecture Context alkcall bullet: "alkcall (v0.4.x, crates.io)" —
|
||||
same version drift.
|
||||
- Architecture Context alkcall bullet: "alkcall (v0.4.x, crates.io)",
|
||||
the same version drift.
|
||||
|
||||
## Work
|
||||
|
||||
@@ -73,7 +73,7 @@ The older ledgers still describe pre-Phase-1 states in two places:
|
||||
## Notes
|
||||
|
||||
- Executed together with `architecture/oq-tn-12-tracker` creation
|
||||
(process-gap fix found during task review — OQ-TN-12's Blocked-on text
|
||||
(process-gap fix found during task review; OQ-TN-12's Blocked-on text
|
||||
now references its tracker task id per `sdd_process.md` §Deferred OQs
|
||||
and the blocker-task half).
|
||||
- While rewriting the Architecture Context bullet, a fourth same-class
|
||||
|
||||
@@ -13,7 +13,7 @@ tags: [architecture, external-trigger, deferred-oq, hub, producer, consumer]
|
||||
## Description
|
||||
|
||||
OQ-TN-12 is `deferred(scope)` (open-questions.md): the hub re-produce
|
||||
composition helper — whether a crate that re-produces a tunnel resource it
|
||||
composition helper: whether a crate that re-produces a tunnel resource it
|
||||
consumes (a hub proxy: consumer + `register_openable` on the same
|
||||
connection) needs a composition helper from this crate, or assembles from
|
||||
the public producer/consumer surface as-is. ADR-004 resolved the substrate
|
||||
@@ -25,7 +25,8 @@ This task is the machine-readable half of the deferral (per
|
||||
whether the unblocking condition has arrived. It is not implementation
|
||||
work and blocks nothing.
|
||||
|
||||
The unblocking condition: a concrete hub-proxy consumer — a crate that
|
||||
The unblocking condition: a concrete hub-proxy consumer, i.e. a
|
||||
crate that
|
||||
re-produces a consumed tunnel resource (the reverse POC's hub is the seed
|
||||
shape minus re-export: consumer + `register_openable` on the same
|
||||
connection, without exercising re-export).
|
||||
@@ -39,7 +40,7 @@ connection, without exercising re-export).
|
||||
## Verification
|
||||
|
||||
- [ ] Tracked (this file existing and being reviewed at triage time IS
|
||||
the tracking mechanism — no code or doc change is expected unless
|
||||
the tracking mechanism; no code or doc change is expected unless
|
||||
the unblocking condition above arrives)
|
||||
|
||||
## Out of scope
|
||||
|
||||
@@ -14,21 +14,21 @@ tags: [architecture, external-trigger, unix, stdio, alktty]
|
||||
|
||||
OQ-TN-14 is resolved (open-questions.md, 2026-09-07): unix ships with
|
||||
the `local` feature v1 (`tunnels/local-socket-halves`); stdio is OUT of
|
||||
scope — a spawned process's stdio is alktty's pipe mode (exit codes +
|
||||
scope. A spawned process's stdio is alktty's pipe mode (exit codes +
|
||||
signals are tty semantics, not tunnel semantics); remote command
|
||||
execution composes via alktty.
|
||||
|
||||
This task stays open as the boundary-maintenance tracker (research
|
||||
level — not implementation work). It re-opens the question only if a
|
||||
level, not implementation work). It re-opens the question only if a
|
||||
concrete consumer demonstrates the resolved posture is wrong:
|
||||
|
||||
1. A consumer needs stdio tunneling with NO process semantics (no exit
|
||||
code, no signals) — e.g. piping an already-running long-lived
|
||||
code, no signals), e.g. piping an already-running long-lived
|
||||
process's existing stdio. That is a different resource shape (the
|
||||
pipe IS the resource, no dial); it would need its own ADR, not a
|
||||
silent addition to `local`.
|
||||
2. A consumer reports confusion about when to use alktunnels vs alktty
|
||||
for a stdio-like flow — the composition boundary in OQ-TN-14 needs
|
||||
for a stdio-like flow; the composition boundary in OQ-TN-14 needs
|
||||
sharpening in the docs, not code.
|
||||
|
||||
If neither arrives, this task closes with the implementation phase.
|
||||
@@ -42,7 +42,7 @@ If neither arrives, this task closes with the implementation phase.
|
||||
## Verification
|
||||
|
||||
- [ ] Tracked (this file existing and being reviewed at triage time IS
|
||||
the tracking mechanism — no code or doc change is expected unless
|
||||
the tracking mechanism; no code or doc change is expected unless
|
||||
a boundary condition above arrives)
|
||||
|
||||
## Out of scope
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
id: tunnels/bindfirst-params
|
||||
name: Bind-first params — ListenParams, the forwarded-op spec, the bind-first plan type (ADR-008 as amended)
|
||||
name: Bind-first params; ListenParams, the forwarded-op spec, the bind-first plan type (ADR-008 as amended)
|
||||
status: completed
|
||||
depends_on: []
|
||||
scope: narrow
|
||||
@@ -13,14 +13,14 @@ tags: [wire, params, bind-first, forwarded-op, correlation-token]
|
||||
## Description
|
||||
|
||||
Implement the bind-first wire surface's params half (ADR-008, as
|
||||
amended 2026-09-18 — Amendment 1): the listen-open reply fields
|
||||
amended 2026-09-18, Amendment 1): the listen-open reply fields
|
||||
(`bound` + `listen`), the forwarded-op params + spec, and the
|
||||
bind-first plan payload type. Split from the establisher/pump task
|
||||
(`tunnels/bindfirst-producer`) so the wire-stable types land and get
|
||||
pinned first — the same ordering `tunnels/params` used for the base
|
||||
pinned first; the same ordering `tunnels/params` used for the base
|
||||
op.
|
||||
|
||||
### params.rs — constants + types
|
||||
### params.rs: constants + types
|
||||
|
||||
```rust
|
||||
pub const OP_TUNNEL_FORWARDED: &str = "channels/tunnel/forwarded";
|
||||
@@ -50,28 +50,28 @@ pub struct TunnelForwardedParams {
|
||||
pub fn tunnel_forwarded_spec() -> OperationSpec
|
||||
```
|
||||
|
||||
- **The correlation token (Amendment 1):** `listen` (u32) — minted by
|
||||
the bind-first establisher, returned in the reply as the `listen`
|
||||
field, stamped into each forwarded open's `listen` param. NOT the
|
||||
listen channel's ID (the pre-amendment key is superseded — the
|
||||
- **The correlation token (Amendment 1):** `listen` (u32), minted by
|
||||
the bind-first establisher and returned in the reply as the
|
||||
`listen` field, stamped into each forwarded open's `listen` param.
|
||||
NOT the listen channel's ID (the pre-amendment key is superseded: the
|
||||
serving-side handler never sees its own channel ID, and generic
|
||||
relays don't rewrite params; ADR-008 Amendment 1 has the full
|
||||
rationale). `SubstrateAddr` is defined by `tunnels/direct-op` —
|
||||
rationale). `SubstrateAddr` is defined by `tunnels/direct-op`, the
|
||||
same value shape (`{host, port}` / `{path}`); if that task has not
|
||||
landed, define it here and let `tunnels/direct-op` consume it
|
||||
(either order works; the type lands once).
|
||||
- `tunnel_forwarded_spec()` per wire.md §The Forwarded Op:
|
||||
`Sub`-typed, `Visibility::External`, `ChannelOpenSpec::new(TUNNEL_ALPN)`,
|
||||
scope `["tunnel:forwarded"]` (ADR-006 Amendment 1 — ingress, a
|
||||
scope `["tunnel:forwarded"]` (ADR-006 Amendment 1: ingress, a
|
||||
separate grant), input schema pinning all three fields required
|
||||
(`listen` integer > 0; `peer` an object with the per-substrate
|
||||
shape), output schema `{channel_id: integer > 0}`, description
|
||||
carried. **No establisher** on the forwarded op — the data plane
|
||||
starts immediately (ADR-008 §3).
|
||||
- `TunnelListenReply` is the consumer-side reader (the wire itself is
|
||||
plain JSON reply fields — additive, unknown fields ignored by old
|
||||
consumers; the struct is the typed view, not a deny-unknown wire
|
||||
gate on the reply — NOTE: the reply arrives as
|
||||
carried. **No establisher** on the forwarded op, because the data
|
||||
plane starts immediately (ADR-008 §3).
|
||||
- `TunnelListenReply` is the consumer-side reader (the wire itself
|
||||
is plain JSON reply fields: additive, with unknown fields ignored
|
||||
by old consumers; the struct is the typed view, not a deny-unknown
|
||||
wire gate on the reply). NOTE: the reply arrives as
|
||||
`{channel_id, bound?, listen?}`; parse it with a dedicated
|
||||
reply-viewing function that extracts the optional fields from the
|
||||
full output Value, not with `deny_unknown_fields` deserialization
|
||||
@@ -84,29 +84,30 @@ pub fn tunnel_forwarded_spec() -> OperationSpec
|
||||
/// The bind-first establisher's plan payload: the listener handle
|
||||
/// (typed-opaque to the pump via downcast) + the correlation token
|
||||
/// the accept dispatcher stamps into forwarded opens (ADR-008
|
||||
/// Amendment 1 — the handler cannot learn its own channel ID, so the
|
||||
/// Amendment 1: the handler cannot learn its own channel ID, so the
|
||||
/// establisher-minted token is the correlation key).
|
||||
pub struct BindFirstPlan {
|
||||
pub listener: TargetHandle, // the listener's halves? — NO: see the shape note
|
||||
pub listener: TargetHandle, // the listener's halves? NO; see the shape note
|
||||
pub token: u32,
|
||||
}
|
||||
```
|
||||
|
||||
- **Shape note (decide during implementation, document the choice):**
|
||||
the listener is NOT `TargetHandle` halves — a listener is accepted
|
||||
FROM, not pumped. The plan carries whatever the assembly's `BindFn`
|
||||
produced (opaque to the protocol), plus the token. Honest shape:
|
||||
`pub struct BindFirstPlan { pub listener: Arc<dyn Any + Send + Sync>, pub token: u32 }`
|
||||
— the accept dispatcher downcasts the `Any` to the assembly's
|
||||
the listener is NOT `TargetHandle` halves, since a listener is
|
||||
accepted FROM, not pumped. The plan carries whatever the assembly's
|
||||
`BindFn` produced (opaque to the protocol), plus the token. Honest
|
||||
shape:
|
||||
`pub struct BindFirstPlan { pub listener: Arc<dyn Any + Send + Sync>, pub token: u32 }`.
|
||||
The accept dispatcher downcasts the `Any` to the assembly's
|
||||
listener type via a type-erased accept closure (the same injection
|
||||
pattern as `DialFn`/`AcceptFn`). The ADR's "listener handle" in the
|
||||
plan is assembly-typed; the protocol sees `BindFirstPlan { inner,
|
||||
token }`.
|
||||
- The token mint: `std::sync::atomic::AtomicU32` counter seeded
|
||||
non-zero (e.g. 1) inside the establisher closure state; wraps are
|
||||
acceptable (u32, per-producer uniqueness is all that's required —
|
||||
ADR-008 Amendment 1's token-space note; a wrap collision requires
|
||||
2^32 concurrent opens on one producer).
|
||||
acceptable (u32; per-producer uniqueness is all that's required,
|
||||
per ADR-008 Amendment 1's token-space note; a wrap collision
|
||||
requires 2^32 concurrent opens on one producer).
|
||||
- Wire-stability note for the implementer: `listen`'s u32 type and
|
||||
field name, the forwarded params field names, and the op id are
|
||||
one-way doors (ADR-008 Consequences). `deny_unknown_fields` on
|
||||
@@ -120,7 +121,7 @@ pub struct BindFirstPlan {
|
||||
- `TunnelListenReply` projected parse: full reply
|
||||
`{channel_id: 7, bound: {...}, listen: 918}` → both fields; bare
|
||||
`{channel_id: 7}` → both `None` (accept-first/dial shape); unknown
|
||||
extra reply fields tolerated (the additive posture — the reply
|
||||
extra reply fields tolerated (the additive posture; the reply
|
||||
viewer must not deny unknown fields).
|
||||
- Spec-shape conformance: op id, Sub type, scope, schema structure
|
||||
(all three required; peer object), ALPN marker, description.
|
||||
@@ -141,45 +142,46 @@ pub struct BindFirstPlan {
|
||||
## References
|
||||
|
||||
- docs/architecture/decisions/008-listen-metadata-forwarded-op.md
|
||||
(§2 as amended — the `listen` reply field; §3 as amended — the
|
||||
forwarded params; Amendment 1 — the token rationale)
|
||||
(§2 as amended, the `listen` reply field; §3 as amended, the
|
||||
forwarded params; Amendment 1, the token rationale)
|
||||
- docs/architecture/wire.md §The Listen Open Op's Reply +
|
||||
§The Forwarded Op
|
||||
- tasks/tunnels/params.md (the ordering precedent: wire types first)
|
||||
- tasks/tunnels/direct-op.md (`SubstrateAddr` — shared type)
|
||||
- tasks/tunnels/direct-op.md (`SubstrateAddr`, the shared type)
|
||||
|
||||
## Notes
|
||||
|
||||
- `SubstrateAddr` had already landed (via `tunnels/direct-op`) —
|
||||
consumed, not redefined (the "either order works" branch resolved
|
||||
to the consuming side).
|
||||
- `SubstrateAddr` had already landed (via `tunnels/direct-op`), so
|
||||
it is consumed, not redefined (the "either order works" branch
|
||||
resolved to the consuming side).
|
||||
- The reply viewer is `parse_listen_reply(&Value) ->
|
||||
Result<TunnelListenReply, TunnelError>`: parses the FULL success
|
||||
output (the `open_channel_with_reply` shape), tolerates unknown
|
||||
extra reply fields (the additive posture — no deny-unknown gate on
|
||||
the reply), requires a positive `channel_id` (its absence/zero =
|
||||
not an open reply — loud), and fails loud on malformed `bound`
|
||||
(not substrate-shaped) or `listen` (not a positive u32) via the
|
||||
new `TunnelError::MalformedReply` variant (error.rs). `null`
|
||||
fields parse as `None` (defensive; a well-formed reply omits them).
|
||||
extra reply fields (the additive posture: no deny-unknown gate on
|
||||
the reply), requires a positive `channel_id` (its absence/zero is
|
||||
not an open reply and fails loud), and fails loud on malformed
|
||||
`bound` (not substrate-shaped) or `listen` (not a positive u32)
|
||||
via the new `TunnelError::MalformedReply` variant (error.rs).
|
||||
`null` fields parse as `None` (defensive; a well-formed reply
|
||||
omits them).
|
||||
- `TunnelForwardedParams.listen` carries a serde-level
|
||||
`positive_u32` pin behind the schema's `minimum: 1` (the task's
|
||||
"listen: 0 rejected (schema minimum 1 + serde-level pin)" — both
|
||||
"listen: 0 rejected (schema minimum 1 + serde-level pin)": both
|
||||
gates, test-pinned).
|
||||
- The `peer` sub-schema follows the direct op's if/then precedent
|
||||
(tcp/udp → `{host, port}` with `additionalProperties: false` +
|
||||
`maximum: 65535`; unix → `{path}`), structural assertions per the
|
||||
no-jsonschema-dep precedent. The untagged-peer lenient-extra-fields
|
||||
asymmetry carries over from the direct op (schema = the shape gate;
|
||||
serde = the backstop) — documented in the test.
|
||||
serde = the backstop), documented in the test.
|
||||
- `BindFirstPlan` lives in `producer.rs` (the plan-payload family,
|
||||
next to `TargetHandle`): `{ listener: Arc<dyn Any + Send + Sync>,
|
||||
token: u32 }` per the honest shape — the listener is NOT
|
||||
token: u32 }` per the honest shape, with the listener NOT
|
||||
`TargetHandle` halves (a listener is accepted FROM, not pumped);
|
||||
the accept dispatcher downcasts via a type-erased accept closure.
|
||||
The token-mint discipline (AtomicU32 seeded non-zero, per-open
|
||||
fresh, per-producer unique, wraps acceptable) is pinned in the
|
||||
type's doc — the establisher that implements it lands with
|
||||
type's doc; the establisher that implements it lands with
|
||||
`tunnels/bindfirst-producer`.
|
||||
- Custom `Debug` impls (matching `TargetHandle`'s precedent): the
|
||||
listener renders `<opaque>`, the token renders plainly.
|
||||
@@ -194,17 +196,18 @@ pub struct BindFirstPlan {
|
||||
## Summary
|
||||
|
||||
Implemented the bind-first wire surface's params half per ADR-008 as
|
||||
amended: `params.rs` — `OP_TUNNEL_FORWARDED` / `TUNNEL_FORWARDED_
|
||||
SCOPE` constants; `TunnelForwardedParams {listen, substrate, peer}`
|
||||
amended: `params.rs` gained the `OP_TUNNEL_FORWARDED` /
|
||||
`TUNNEL_FORWARDED_SCOPE` constants; `TunnelForwardedParams {listen,
|
||||
substrate, peer}`
|
||||
(deny-unknown, `listen` positive via schema `minimum: 1` + the serde
|
||||
pin); `tunnel_forwarded_spec()` (`Sub`-typed, External,
|
||||
`ChannelOpenSpec::new(TUNNEL_ALPN)`, scope `["tunnel:forwarded"]`,
|
||||
per-substrate if/then `peer` sub-schema, output
|
||||
`{channel_id: integer > 0}`, description, no establisher);
|
||||
`TunnelListenReply` + `parse_listen_reply` (the projected reply
|
||||
viewer — unknown extra fields tolerated, `channel_id` presence
|
||||
viewer: unknown extra fields tolerated, `channel_id` presence
|
||||
required, malformed fields fail loud via `TunnelError::
|
||||
MalformedReply`). `producer.rs` — `BindFirstPlan {listener:
|
||||
MalformedReply`). `producer.rs` defines `BindFirstPlan {listener:
|
||||
Arc<dyn Any + Send + Sync>, token: u32}` with the mint discipline
|
||||
pinned in docs (non-zero-seeded AtomicU32, per-open fresh,
|
||||
per-producer unique, wraps acceptable). Re-exported through
|
||||
@@ -214,5 +217,5 @@ types land with `tunnels/bindfirst-producer`.
|
||||
Verified: `cargo test` green (105 default; 125 with `--all-
|
||||
features`), clippy `--all-targets -D warnings` clean (native +
|
||||
wasm32), fmt clean, `wasm32-unknown-unknown` check passes (pure
|
||||
protocol types — the default crate stays wasm-clean), `cargo doc
|
||||
protocol types, so the default crate stays wasm-clean), `cargo doc
|
||||
--no-deps` clean. CHANGELOG updated (Unreleased → Added).
|
||||
@@ -13,16 +13,16 @@ tags: [producer, establisher, bind-first, accept-dispatcher, forwarded-op]
|
||||
## Description
|
||||
|
||||
Implement the bind-first establisher (producer.md §The Establisher,
|
||||
shape 3 — ADR-008 §1): the establisher binds (via the injected
|
||||
shape 3; ADR-008 §1): the establisher binds (via the injected
|
||||
`BindFn`), mints the correlation token, contributes `bound` + `listen`
|
||||
to the reply, and returns the plan; the pump handler is the **accept
|
||||
dispatcher** — it accepts the channel's `BiStream`, runs the
|
||||
dispatcher**: it accepts the channel's `BiStream`, runs the
|
||||
assembly-provided accept loop, and per accept opens
|
||||
`channels/tunnel/forwarded` back toward the listen opener and pumps
|
||||
the accepted halves against the adopted channel. Plus
|
||||
`register_tunnel_bindfirst_openable` wiring it.
|
||||
|
||||
### The establisher (shape 3 — bind-first)
|
||||
### The establisher (shape 3, bind-first)
|
||||
|
||||
```rust
|
||||
pub type BindFn = Arc<
|
||||
@@ -45,7 +45,7 @@ pub fn bindfirst_establisher(
|
||||
```
|
||||
|
||||
- Flow: parse `TunnelParams` → registry namespace gate
|
||||
(`unknown_resource` on miss — same posture as
|
||||
(`unknown_resource` on miss, the same posture as
|
||||
`listen_establisher`) → mint the correlation token
|
||||
(per-open-fresh, per-producer-unique; `tunnels/bindfirst-params`)
|
||||
→ `bind(substrate, backing)` → contribute reply fields
|
||||
@@ -68,51 +68,51 @@ pub fn make_bindfirst_accept_handler(
|
||||
) -> OpenHandler
|
||||
```
|
||||
|
||||
- **Injection seams (the design pin — decide the exact closure
|
||||
- **Injection seams (the design pin: decide the exact closure
|
||||
signatures during implementation, document in Notes):** the
|
||||
dispatcher needs two assembly-provided capabilities the protocol
|
||||
cannot own:
|
||||
1. **The accept loop** — pops accepted connections from the
|
||||
1. **The accept loop**, which pops accepted connections from the
|
||||
`BindFirstPlan`'s opaque listener and yields
|
||||
`(SubstrateAddr /* peer */, TargetHandle /* accepted halves */)`.
|
||||
Substrate code lives behind the closure (ADR-004).
|
||||
2. **The forwarded-open caller** — opens `OP_TUNNEL_FORWARDED`
|
||||
2. **The forwarded-open caller**: opens `OP_TUNNEL_FORWARDED`
|
||||
with `TunnelForwardedParams { listen: token, substrate, peer }`
|
||||
on the channel-0 call plane toward the listen opener, adopts the
|
||||
opener-allocated ID (`adopt_channel` on the producer-side
|
||||
manager), and returns the streams. **This needs the call
|
||||
plane**: the per-channel `Connection` the handler receives is
|
||||
stream-less (`open_bi` is `StreamClosed` — ADR-035) and carries
|
||||
stream-less (`open_bi` is `StreamClosed`; ADR-035) and carries
|
||||
no call surface, so the registration must close over the
|
||||
serving session's `Arc<CallConnection>` (the reverse POC's
|
||||
`open_reverse_channel` shape: `call_with_payload` on channel 0 →
|
||||
extract `channel_id` from the reply → `manager.adopt_channel`).
|
||||
The registration function takes the call-plane handle (the
|
||||
assembly has it — it constructed the serving session); document
|
||||
assembly has it: it constructed the serving session); document
|
||||
the wiring.
|
||||
- Handler contract (producer.md §The Pump Handler item 5 + R-02):
|
||||
`conn.accept_bi()` FIRST (the yield-once contract — the wrapper's
|
||||
`conn.accept_bi()` FIRST (the yield-once contract; the wrapper's
|
||||
accepted-flag telemetry requires it), then run the accept loop
|
||||
concurrently with holding the channel's stream. The listen
|
||||
channel's own `BiStream` stays idle (no pumps on it); **its EOF
|
||||
cancels the loop and tears the listener down** — `select!` on the
|
||||
cancels the loop and tears the listener down** via `select!` on the
|
||||
idle stream's EOF vs the accept loop (read from the stream and
|
||||
observe EOF; the zero-byte sentinel is the opener dropping the
|
||||
listener session). Per-accept data planes are `pump_bidi`
|
||||
(convention 8 — never a hand-rolled loop over one stream): each
|
||||
(convention 8, never a hand-rolled loop over one stream): each
|
||||
accepted handle pumps against its adopted forwarded channel inside
|
||||
a spawned task tracked by the handler's JoinHandle set.
|
||||
- **Lifetime (R-02):** the handler's returned JoinHandle tracks the
|
||||
whole accept loop + every per-accept pump it spawned (collect the
|
||||
pump handles; the handler task awaits them on loop exit — a
|
||||
pump handles; the handler task awaits them on loop exit, since a
|
||||
forwarded pump outliving its listener is a leak). Handler exit →
|
||||
wrapper teardown (the listen channel reaps) → the far listener
|
||||
tears down (the `BindFn`'s listener drops with the plan/loop).
|
||||
- Error posture per accept: a forwarded-open failure (opener without
|
||||
the scope → FORBIDDEN; old opener → NOT_FOUND; correlation miss →
|
||||
handler_error-class) logs and drops that accepted connection — the
|
||||
loop continues; the listener's overall health is not hostage to one
|
||||
failed accept. (Decide: retry/backoff — v1 says no; document.)
|
||||
handler_error-class) logs and drops that accepted connection; the
|
||||
loop continues, and the listener's overall health is not hostage to
|
||||
one failed accept. (Decide: retry/backoff. v1 says no; document.)
|
||||
|
||||
### Registration
|
||||
|
||||
@@ -131,16 +131,16 @@ pub fn register_tunnel_bindfirst_openable(
|
||||
```
|
||||
|
||||
- Same spec (`tunnel_open_spec()`) + scope gate (`tunnel:open`) as the
|
||||
dial/listen variants — the bind-first shape is a per-registration
|
||||
dial/listen variants; the bind-first shape is a per-registration
|
||||
establisher choice over the same op (ADR-008 §5's per-registration
|
||||
posture). One establisher per op id per registry — same note as
|
||||
`register_tunnel_listen_openable`.
|
||||
posture). One establisher per op id per registry (same note as
|
||||
`register_tunnel_listen_openable`).
|
||||
- The forwarded-op SERVING side (opener side) is a separate
|
||||
registration shape (`register_openable` — no establisher) and lands
|
||||
registration shape (`register_openable`, with no establisher) and lands
|
||||
with `tunnels/forwarded-receipt`; this task's tests still need an
|
||||
opener-side stub (see Tests).
|
||||
|
||||
### Tests (integration — the duplex harness, listen-topology extension)
|
||||
### Tests (integration: the duplex harness, listen-topology extension)
|
||||
|
||||
- **The full bind-first flow e2e:** opener opens
|
||||
`channels/tunnel/sub` with the bind-first producer registered →
|
||||
@@ -177,7 +177,7 @@ pub fn register_tunnel_bindfirst_openable(
|
||||
idle-stream EOF cancels the loop + tears the listener down,
|
||||
per-accept `pump_bidi` tasks tracked by the handler JoinHandle
|
||||
- [x] The forwarded-open caller rides the call plane
|
||||
(`call_with_payload` + adopt — the reverse POC's shape); the
|
||||
(`call_with_payload` + adopt, the reverse POC's shape); the
|
||||
registration wiring documents where the call plane comes from
|
||||
- [x] `register_tunnel_bindfirst_openable` wires spec + establisher +
|
||||
dispatcher + injections
|
||||
@@ -192,7 +192,7 @@ pub fn register_tunnel_bindfirst_openable(
|
||||
|
||||
- docs/architecture/decisions/008-listen-metadata-forwarded-op.md
|
||||
(§1 shape 3, §2 the reply fields, §3 the forwarded op, Amendment 1
|
||||
— the token; §6 the hub policy sentence)
|
||||
on the token; §6 the hub policy sentence)
|
||||
- docs/architecture/producer.md §The Establisher (shape 3) + §The
|
||||
Pump Handler (item 5) + §The Forwarded Op
|
||||
- tasks/tunnels/bindfirst-params.md (the types this consumes)
|
||||
@@ -200,8 +200,8 @@ pub fn register_tunnel_bindfirst_openable(
|
||||
harness + registration shapes this extends)
|
||||
- alkcall surface: `Establishment::with_reply_field` (ADR-049
|
||||
amendment 3, landed 0.8.0), the `OpenHandler` signature (no channel
|
||||
ID — Amendment 1's premise), `ChannelRelay` reply-field pass-through
|
||||
(ADR-051 — the relay-transparency property the tests should pin)
|
||||
ID; Amendment 1's premise), `ChannelRelay` reply-field pass-through
|
||||
(ADR-051, the relay-transparency property the tests should pin)
|
||||
|
||||
## Notes
|
||||
|
||||
@@ -209,27 +209,27 @@ pub fn register_tunnel_bindfirst_openable(
|
||||
`BindFirstAcceptLoop` / `ForwardedOpener` follow the type-erased
|
||||
closure precedent:
|
||||
- `BindFn = Arc<dyn Fn(Substrate, &str) -> BoxFuture<'static,
|
||||
Result<BoundListener, TunnelEstablishError>> + Send + Sync>` —
|
||||
Result<BoundListener, TunnelEstablishError>> + Send + Sync>`,
|
||||
same `(substrate, backing)` form as `DialFn` (the registry
|
||||
backing string rides the same signature; the bind closure stays
|
||||
substrate-agnostic from the protocol's view).
|
||||
- `BindFirstAcceptLoop = Arc<dyn Fn(Arc<dyn Any + Send + Sync>) ->
|
||||
BoxFuture<'static, Result<(SubstrateAddr, TargetHandle),
|
||||
TunnelEstablishError>> + Send + Sync>` — the closure receives the
|
||||
plan's OPAQUE listener (`Arc<dyn Any + Send + Sync>`, no
|
||||
TunnelEstablishError>> + Send + Sync>`, and the closure receives
|
||||
the plan's OPAQUE listener (`Arc<dyn Any + Send + Sync>`, no
|
||||
downcast in the protocol: the assembly downcasts to ITS concrete
|
||||
type inside the closure; the same injection pattern as
|
||||
`DialFn`/`AcceptFn`). A returned `Err` is LOOP-CRITICAL (the
|
||||
substrate accept source broke) — the dispatcher ends the loop;
|
||||
substrate accept source broke), so the dispatcher ends the loop;
|
||||
per-accept forwarded-open failures never come from this closure.
|
||||
- `ForwardedOpener = Arc<dyn Fn(TunnelForwardedParams) ->
|
||||
BoxFuture<'static, Result<(MpscSendStream, MpscRecvStream),
|
||||
TunnelEstablishError>> + Send + Sync>` — closes over the serving
|
||||
session's `Arc<CallConnection>` + the producer-side
|
||||
TunnelEstablishError>> + Send + Sync>`, which closes over the
|
||||
serving session's `Arc<CallConnection>` + the producer-side
|
||||
`ChannelManager` (the reverse POC's `open_reverse_channel`
|
||||
shape: `call_with_payload` on channel 0 → extract `channel_id` →
|
||||
`manager.adopt_channel`). **Where the call plane comes from:** the
|
||||
assembly layer holds it — it constructed the serving session
|
||||
assembly layer holds it, since it constructed the serving session
|
||||
(the harness's `wire_bindfirst` shows the wiring: the
|
||||
`ForwardedOpener` closure closes over the `Arc<ChannelClient>`
|
||||
and calls `call_open_op` + `adopt_channel`; in production the
|
||||
@@ -237,42 +237,42 @@ pub fn register_tunnel_bindfirst_openable(
|
||||
- **Registration signature deviation (documented):** the task sketch
|
||||
had 8 parameters (clippy's `too_many_arguments`); the injected
|
||||
seams bundle into `BindFirstInjections {bind, accept_loop,
|
||||
open_forwarded}` — one coherent injection point passed beside the
|
||||
open_forwarded}`, one coherent injection point passed beside the
|
||||
core pair + the optional witness. Same posture as `DialFn` riding
|
||||
the dial registration. The `identity_witness` seam is wired via a
|
||||
small `witness_establisher` wrapper (records the per-call opener
|
||||
identity, then delegates — the same CF-006 seam the dial/direct
|
||||
identity, then delegates; the same CF-006 seam the dial/direct
|
||||
variants inline).
|
||||
- **The token mint** (`mint_token`, private): `AtomicU32` counter
|
||||
seeded at 1 inside the establisher's closure state; `fetch_add`
|
||||
returns the PREVIOUS value, so the minted stream is 1, 2, 3, … —
|
||||
returns the PREVIOUS value, so the minted stream is 1, 2, 3, …;
|
||||
token 0 (an illegal wire value) is never minted, and a u32 wrap
|
||||
that lands on 0 re-increments (the loop skips it). Per-open fresh,
|
||||
per-producer unique; unit-pinned including the wrap shape.
|
||||
- **The idle-stream EOF watch:** a 1-byte `read` on the split idle
|
||||
half inside a `select!` against the accept loop — `Ok(0)` (the
|
||||
half inside a `select!` against the accept loop: `Ok(0)` (the
|
||||
zero-byte EOF sentinel), a read error, or (defensively) any
|
||||
non-empty read on an idle stream all cancel the loop. The listen
|
||||
channel's stream is never pumped (no `pump_bidi` on it).
|
||||
- **Per-accept failure posture (v1, documented):** a forwarded-open
|
||||
failure (scope-less opener → FORBIDDEN; old opener → NOT_FOUND;
|
||||
correlation miss → handler_error-class) logs a warning and drops
|
||||
that accepted connection — the loop continues; NO retry/backoff
|
||||
that accepted connection; the loop continues, with NO retry/backoff
|
||||
(v1 posture per the task).
|
||||
- **Lifetime (R-02):** the handler collects every per-accept
|
||||
`pump_bidi` `JoinHandle` in a `Vec` and awaits them all on loop
|
||||
exit (before the handler task returns) — a forwarded pump never
|
||||
exit (before the handler task returns); a forwarded pump never
|
||||
outlives its listener. Handler exit → wrapper teardown → the listen
|
||||
channel reaps → the plan's listener drops with the loop.
|
||||
- **Harness changes (test infrastructure):** `wire_bindfirst`
|
||||
(RegistrationMode::BindFirst); the consumer's install hook gained a
|
||||
shared serving registry + the accept-side identity propagation
|
||||
(the transport identity attaches to channel 0 — the same posture
|
||||
(the transport identity attaches to channel 0, the same posture
|
||||
`wire_forward` models; needed because the forwarded opens arrive
|
||||
ON the consumer's serving path); `Topology.consumer_registry`
|
||||
exposed; the forwarded-op serving stub
|
||||
(`register_forwarded_stub_on_consumer` /
|
||||
`make_forwarded_stub_handler` — correlate by `listen` token →
|
||||
`make_forwarded_stub_handler`: correlate by `listen` token →
|
||||
accept the `BiStream` → `pump_bidi` against the correlated halves;
|
||||
the opener-side registration shape lands for real with
|
||||
`tunnels/forwarded-receipt`).
|
||||
@@ -281,9 +281,9 @@ pub fn register_tunnel_bindfirst_openable(
|
||||
of a raw duplex; the stub's pump bridges forwarded channel ↔ those
|
||||
halves; the accepted side's echo task closes the loop). The
|
||||
forwarded channel on the opener side is wrapper-managed (the
|
||||
serving wrapper allocated it — `adopt_channel` on it a second time
|
||||
fails `ChannelExists`), so the test drives the client end, not a
|
||||
re-adopt.
|
||||
serving wrapper allocated it, so `adopt_channel` on it a second
|
||||
time fails `ChannelExists`), so the test drives the client end, not
|
||||
a re-adopt.
|
||||
- **EOF-teardown observability:** the test pins the listen channel
|
||||
reaping on the producer side after the opener drops its session
|
||||
(`has_channel` false) and the accept loop's death via the queue
|
||||
@@ -292,28 +292,28 @@ pub fn register_tunnel_bindfirst_openable(
|
||||
## Summary
|
||||
|
||||
Implemented the bind-first producer (shape 3, ADR-008 as amended) per
|
||||
the task: `src/producer.rs` — `BindFn` + `BoundListener` (the
|
||||
the task. `src/producer.rs`: `BindFn` + `BoundListener` (the
|
||||
platform-boundary bind seam); the token mint (non-zero-seeded
|
||||
`AtomicU32`, per-open fresh, per-producer unique, token 0 never
|
||||
minted — the wrap shape unit-pinned); `bindfirst_establisher`
|
||||
minted, the wrap shape unit-pinned); `bindfirst_establisher`
|
||||
(registry namespace gate → mint → bind → `bound` + `listen` reply
|
||||
fields → the `BindFirstPlan` plan; the typed errors pass through —
|
||||
fields → the `BindFirstPlan` plan; the typed errors pass through:
|
||||
bind failure `dial_failed`, exhaustion `resource_shortage`, registry
|
||||
miss `unknown_resource`); `BindFirstAcceptLoop` (the type-erased
|
||||
accept closure receiving the opaque listener); `ForwardedOpener` (the
|
||||
call-plane forwarded-open caller: call + adopt);
|
||||
call-plane forwarded-open caller: call + adopt);
|
||||
`make_bindfirst_accept_handler` (the accept dispatcher: `accept_bi`
|
||||
first, the idle-stream EOF sentinel cancels the loop via `select!`,
|
||||
per-accept `pump_bidi` in spawned tasks tracked by the handler's
|
||||
`JoinHandle`, per-accept failure isolation, loop-critical accept
|
||||
errors end the loop); `register_tunnel_bindfirst_openable` (same
|
||||
spec + scope gate, `BindFirstInjections` bundle + witness seam) —
|
||||
re-exported through `lib.rs`. `tests/harness.rs` —
|
||||
spec + scope gate, `BindFirstInjections` bundle + witness seam),
|
||||
re-exported through `lib.rs`. `tests/harness.rs`:
|
||||
`RegistrationMode::BindFirst` + `wire_bindfirst` (the consumer hook
|
||||
gained the accept-side identity propagation + a shared serving
|
||||
registry, `Topology.consumer_registry` exposed) + the forwarded-op
|
||||
serving stub (`make_forwarded_stub_handler`). `tests/
|
||||
producer_bindfirst.rs` — 6 integration tests: the full flow e2e (the
|
||||
producer_bindfirst.rs` has 6 integration tests: the full flow e2e (the
|
||||
acceptance gate: reply projection → token correlation → forwarded
|
||||
open → byte round-trip through the whole path), the dial-open
|
||||
neither-field projection, EOF teardown (listen reaped, loop gone),
|
||||
@@ -321,8 +321,8 @@ per-accept isolation (scope-less opener; the loop serves the next
|
||||
accept), typed establishment errors, and the token discipline (two
|
||||
opens distinct tokens; each dispatcher stamps its own plan's token).
|
||||
Plus 6 unit tests in producer.rs (the mint discipline incl. the wrap
|
||||
shape, the establisher flow at the `Establishment` surface — plan
|
||||
token == reply token — distinct tokens per open, registry miss,
|
||||
shape, the establisher flow at the `Establishment` surface (plan
|
||||
token == reply token), distinct tokens per open, registry miss,
|
||||
bind-error passthrough, the witness wrapper).
|
||||
|
||||
Verified: `cargo test` green (118 default; 138 `--all-features`),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
id: tunnels/consumer-session
|
||||
name: Consumer half — TunnelSession (open/adopt, data planes, teardown)
|
||||
name: Consumer half, TunnelSession (open/adopt, data planes, teardown)
|
||||
status: completed
|
||||
depends_on: [tunnels/params, tunnels/wire-codec, tunnels/producer-open-op]
|
||||
scope: broad
|
||||
@@ -12,26 +12,26 @@ tags: [consumer, session, teardown]
|
||||
|
||||
## Description
|
||||
|
||||
Implement `src/consumer.rs` per consumer.md + ADR-005: `TunnelSession` —
|
||||
Implement `src/consumer.rs` per consumer.md + ADR-005: `TunnelSession`,
|
||||
the typed client, both construction paths, the substrate-shaped data
|
||||
plane, and teardown ownership. The reverse POC's consumer
|
||||
(`/workspace/alktunnels-reverse-poc/src/consumer.rs` — `ReverseTunnel`)
|
||||
(`/workspace/alktunnels-reverse-poc/src/consumer.rs`, `ReverseTunnel`)
|
||||
is the seed; this task generalizes it to the spec's session type.
|
||||
|
||||
### Construction
|
||||
|
||||
- **Forward path:** `TunnelSession::open(client:
|
||||
&ChannelClient, params: TunnelParams) -> Result<Self,
|
||||
TunnelOpenError>` — `client.open_channel(OP_TUNNEL_OPEN, params,
|
||||
TunnelOpenError>` via `client.open_channel(OP_TUNNEL_OPEN, params,
|
||||
TUNNEL_ALPN)` → adopt → split the channel `BiStream` → data plane by
|
||||
substrate. Typed errors (ADR-049 §4; never a phantom session).
|
||||
- **Reverse path:** `TunnelSession::adopt(manager: &ChannelManager,
|
||||
channel_id: u32, alpn: impl Into<String>) -> Result<Self,
|
||||
TunnelOpenError>` — adopt a worker-allocated ID (early arrivals
|
||||
TunnelOpenError>`: adopt a worker-allocated ID (early arrivals
|
||||
parked), install the data plane from the adopted halves.
|
||||
`open_reverse_channel(hub_call, params, auth_token) -> Result<u32,
|
||||
ReverseOpenError>` as the free function the assembly layer calls
|
||||
before `adopt` (the POC's shape — the hub's call surface is a
|
||||
before `adopt` (the POC's shape: the hub's call surface is a
|
||||
`CallConnection`, not a `ChannelClient`, so the two-step is the
|
||||
honest API).
|
||||
|
||||
@@ -40,30 +40,30 @@ is the seed; this task generalizes it to the spec's session type.
|
||||
- **Stream variant:** `stream_halves()` →
|
||||
`(&mut dyn AsyncRead, &mut dyn AsyncWrite)` (borrowed access);
|
||||
`take_halves(self)` → owned boxed halves (the session's halves ARE
|
||||
the tunnel — raw pass-through).
|
||||
the tunnel, i.e. raw pass-through).
|
||||
- **Datagram variant:** `send_datagram(&[u8]) -> Result<(),
|
||||
TunnelIoError>` (frame → write → flush; `Oversize` >65535);
|
||||
`recv_datagram() -> Result<Option<Bytes>, TunnelIoError>` —
|
||||
`recv_datagram() -> Result<Option<Bytes>, TunnelIoError>`:
|
||||
`Some(bytes)` per datagram (possibly empty, `len=0` legal), `None`
|
||||
only on stream EOF (the F-2 layering; the POC's
|
||||
`read_one_datagram` incremental loop).
|
||||
- Wrong-substrate operations are `WrongSubstrate` errors (the POC's
|
||||
shape).
|
||||
|
||||
### Pump ownership + teardown (ADR-005 — the point)
|
||||
### Pump ownership + teardown (ADR-005, the point)
|
||||
|
||||
- `pump_against(self, accepted: impl AsyncRead + AsyncWrite + ...)` —
|
||||
- `pump_against(self, accepted: impl AsyncRead + AsyncWrite + ...)`,
|
||||
for the reverse path: spawn `pump_bidi(channel_bistream,
|
||||
accepted_read, accepted_write)`, hold the returned handle. Returns
|
||||
the session (builder-style) or takes self and returns the handle —
|
||||
pick the shape that makes holding easy; document it.
|
||||
- `close(self) -> bool` — abort the pump (if session-owned) +
|
||||
the session (builder-style) or takes self and returns the handle.
|
||||
Pick the shape that makes holding easy; document it.
|
||||
- `close(self) -> bool`: abort the pump (if session-owned) +
|
||||
`teardown_channel` (ungraceful path).
|
||||
- `join(self) -> (u64, u64, bool)` — await pump completion, then reap;
|
||||
- `join(self) -> (u64, u64, bool)`: await pump completion, then reap;
|
||||
copy counts for observability. **Pump-less sessions** (after
|
||||
`take_halves`): completes immediately, reaps only, `(0, 0, reaped)`
|
||||
(consumer.md's pinned semantics).
|
||||
- `Drop` — abort + sync `teardown_channel` (best-effort; never leak
|
||||
- `Drop`: abort + sync `teardown_channel` (best-effort; never leak
|
||||
the entry). No `Clone`.
|
||||
|
||||
### Tests
|
||||
@@ -73,8 +73,8 @@ halves drive a duplex), reverse open+adopt+pump_against (the POC's
|
||||
`ReverseTunnel` tests: round-trip, half-close W4, join_and_reap copy
|
||||
counts, out-of-band close + self-reaping, pump-less join), datagram
|
||||
variant (round-trip incl. empty datagram via the codec), teardown
|
||||
matrix (close/join/Drop paths — no leaked channel entries asserted via
|
||||
`channel_ids()`).
|
||||
matrix (close/join/Drop paths, with no leaked channel entries asserted
|
||||
via `channel_ids()`).
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
@@ -102,11 +102,13 @@ matrix (close/join/Drop paths — no leaked channel entries asserted via
|
||||
- **`adopt` takes `substrate: Substrate`** (a deviation from the task
|
||||
sketch's `adopt(manager, channel_id, alpn)`): the substrate shapes
|
||||
the data plane (raw halves vs the UDP codec) and cannot be inferred
|
||||
from the channel ID. The ALPN is observability-only (alkcall
|
||||
from the channel ID, so passing it to `adopt` keeps the two calls
|
||||
consistent.
|
||||
The ALPN is observability-only (alkcall
|
||||
manager semantics); the substrate rode the open call that produced
|
||||
the ID — passing it to `adopt` keeps the two calls consistent.
|
||||
the ID.
|
||||
- **`pump_against` takes the whole accepted stream** (`S: AsyncRead +
|
||||
AsyncWrite`) and splits internally — `tokio::io::split` at the
|
||||
AsyncWrite`) and splits internally with `tokio::io::split` at the
|
||||
pump boundary, matching the POC's `pump_against(accepted)`. Returns
|
||||
the session (builder-style); the pump handle is session-owned and
|
||||
private (close/join/Drop are the only handles needed).
|
||||
@@ -114,24 +116,24 @@ matrix (close/join/Drop paths — no leaked channel entries asserted via
|
||||
}`** (not bare halves): the session must stay alive as the
|
||||
channel-entry owner (reap via `close`/`join`/`Drop`); returning
|
||||
bare halves would strand the entry. `join` on the held session
|
||||
completes immediately (`(0, 0, reaped)` — pump-less).
|
||||
- **`open_reverse_channel`'s hub_call is `&CallConnection`** (not
|
||||
`&Arc<CallConnection>`) — borrowing is the honest shape; callers
|
||||
hold the Arc.
|
||||
completes immediately (`(0, 0, reaped)`, because pump-less).
|
||||
- **`open_reverse_channel`'s hub_call is `&CallConnection`**
|
||||
(not `&Arc<CallConnection>`); borrowing is the honest shape;
|
||||
callers hold the Arc.
|
||||
- **Fields are `Option` + idempotent `Drop`:** a `Drop`-impl type
|
||||
cannot be destructured, so `take_halves`/`pump_against`/`close`/
|
||||
`join` take fields out via `.take()`; an emptied session drops as
|
||||
a no-op. Teardown is idempotent (`teardown_channel` on a reaped
|
||||
entry is `Err(UnknownChannel)`, ignored).
|
||||
- **Bogus-ID adopt succeeds by design** (the manager parks early
|
||||
arrivals for any not-yet-seen ID — the adoption-race cover,
|
||||
arrivals for any not-yet-seen ID, the adoption-race cover,
|
||||
alkcall ADR-047 §5); the honest `AdoptFailed` probe is the
|
||||
ID-collision path (same ID adopted twice), pinned in the tests.
|
||||
- **`recv_datagram` keeps a pending queue**: one chunk may batch
|
||||
several frames; `DatagramReader::feed` decodes all completions per
|
||||
feed, so frames beyond the first queue for subsequent
|
||||
`recv_datagram` calls (dropped frames were a bug the tests caught).
|
||||
- **Harness gained `wire_forward`** (`ForwardTopology`): the forward
|
||||
- **Harness gained `wire_forward`, a `ForwardTopology`:** the forward
|
||||
path's consumer is the connect side (`from_connection`, pure
|
||||
consumer) and the producer accepts via the adapter; the accept-side
|
||||
transport-identity posture attaches the dialer's identity in the
|
||||
@@ -149,11 +151,11 @@ adopt_channel` + `open_reverse_channel` on the `CallConnection`),
|
||||
the substrate-shaped data planes (raw halves for stream; the ADR-003
|
||||
codec with a pending frame queue for UDP), pump ownership
|
||||
(`pump_against` spawns `pump_bidi` and holds the handle), and the
|
||||
full teardown matrix — `close` (abort + ungraceful reap), `join`
|
||||
(await + reap + copy counts; pump-less `(0, 0, reaped)`), `Drop`
|
||||
full teardown matrix: `close` (abort + ungraceful reap), `join`
|
||||
(await + reap + copy counts; pump-less `(0, 0, reaped)`), and `Drop`
|
||||
(abort + sync reap, never leaks). No `Clone` (compile_fail doc-test).
|
||||
|
||||
Tests: `tests/consumer_session.rs` — 12 integration tests over the
|
||||
Tests: `tests/consumer_session.rs` has 12 integration tests over the
|
||||
two harness topologies: forward round-trip (session halves), take-
|
||||
halves + pump-less join, datagram round-trip incl. the F-2 empty
|
||||
datagram, wrong-substrate typed errors, oversize-at-frame-time,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
id: tunnels/corrmiss-adr
|
||||
name: "ADR: the forwarded op's establishment phase + the correlation-miss vocabulary (resolves review 003 — decisions)"
|
||||
name: "ADR: the forwarded op's establishment phase + the correlation-miss vocabulary (resolves review 003's decisions)"
|
||||
status: completed
|
||||
depends_on: []
|
||||
scope: narrow
|
||||
@@ -16,7 +16,7 @@ Land the ADR ink for the settled decisions in
|
||||
`docs/plans/003-correlation-miss-resolve.md` §Decisions (1-4): the
|
||||
forwarded op's serving side gains an establishment phase; a stale/
|
||||
missing `listen` token fails the open as a typed `channel:open_failed`
|
||||
(rewriting plan decision 2 — the wire vocabulary already pins
|
||||
(rewriting plan decision 2: the wire vocabulary already pins
|
||||
`unknown_resource`, so no wire-vocabulary extension and no
|
||||
reason-class ADR); the residual post-open decline (the narrow
|
||||
establisher-pass/handler-miss race) keeps teardown-at-birth and adopts
|
||||
@@ -28,7 +28,7 @@ Also record the tri-vocabulary history the decision retires:
|
||||
alkssh `correlation-miss` (debug, `decline_arrival`), alksocks prose
|
||||
("forwarded open with no waiting session", warn,
|
||||
`src/composed/mod.rs:497`), alktunnels `handler_error`-class generic
|
||||
arm (warn, the bind-first accept loop's failed-open branch) — review
|
||||
arm (warn, the bind-first accept loop's failed-open branch); review
|
||||
003's situation section plus the plan's §Verified findings are the
|
||||
primary sources for it.
|
||||
|
||||
@@ -37,9 +37,9 @@ Doc-only change; no code in this task.
|
||||
### Shape to decide while writing (the plan's "ADR-008 amendment
|
||||
### (or a small successor ADR)" choice)
|
||||
|
||||
Recommendation: an **Amendment 2 to ADR-008** — this amends the
|
||||
Recommendation: an **Amendment 2 to ADR-008**. This amends the
|
||||
forwarded op's serving shape exactly as §3 pins it (that section's
|
||||
sentence "no establisher — the forwarded channel needs no
|
||||
sentence "no establisher: the forwarded channel needs no
|
||||
establishment; its data plane starts immediately" is the text being
|
||||
amended), the wire vocabulary is unextended, and the amendment keeps
|
||||
the whole forwarded-op decision record in one ADR. Take a small
|
||||
@@ -51,23 +51,25 @@ format precedent).
|
||||
|
||||
### The invariants the ADR must pin
|
||||
|
||||
- The gate is injected — the protocol crate cannot hold the assembly's
|
||||
- The gate is injected: the protocol crate cannot hold the assembly's
|
||||
correlation map; the establisher's check is an assembly-provided
|
||||
closure the crate wraps into the alkcall `OpenEstablisher` dance
|
||||
(the plan's W1 decision; assemblies must not hand-roll the alkcall
|
||||
establisher plumbing).
|
||||
- The gate rides `register_openable_with_establisher` (alkcall 0.10.0,
|
||||
verified): establisher failure → typed `channel:open_failed` with
|
||||
`details.reason`, "never a phantom channel" is ADR-049's posture —
|
||||
the forwarded op currently rides the phantom-shaped serving side
|
||||
`details.reason`; "never a phantom channel" is ADR-049's posture. The
|
||||
forwarded op currently rides the phantom-shaped serving side
|
||||
(establisher-less everywhere), and this closes it for the miss class.
|
||||
- `unknown_resource` is reused as-is (the waiting session IS a resource
|
||||
that is gone) — `details.reason`'s wire vocabulary is NOT extended.
|
||||
- The scope gate (`tunnel:forwarded`) precedes the establisher — pin
|
||||
that is gone), so `details.reason`'s wire vocabulary is NOT
|
||||
extended.
|
||||
- The scope gate (`tunnel:forwarded`) precedes the establisher; pin
|
||||
the order (the registry runs the ACL before the open op's
|
||||
establishment phase; alkcall's open-op wrapper order).
|
||||
- The residual post-open race keeps teardown-at-birth (the fallback
|
||||
shape — ADR-008 §3's opener-half posture, unchanged) and its event
|
||||
shape, which is ADR-008 §3's opener-half posture, unchanged), and
|
||||
its event
|
||||
name is `correlation-miss` at `debug` (assembly-side; the protocol
|
||||
crate's built-in handler holds no map, so the name is the
|
||||
assemblies' convention this ADR records, not code this crate runs).
|
||||
@@ -83,13 +85,13 @@ format precedent).
|
||||
- [x] The amended/superseded §3 sentence is marked, not silently
|
||||
replaced
|
||||
- [x] Wire-vocabulary non-extension is explicit (`unknown_resource`
|
||||
reused; `wire.md` §The Open Ops untouched — state that)
|
||||
reused; `wire.md` §The Open Ops untouched; state that)
|
||||
- [x] Status block of
|
||||
`docs/reviews/003-correlation-miss-arrival-class-alkssh-input.md`
|
||||
is NOT updated here (the doc-sync task owns it — this task is
|
||||
is NOT updated here (the doc-sync task owns it; this task is
|
||||
the ADR itself)
|
||||
- [x] No code changes; doc build clean (`cargo doc --no-deps` clean is
|
||||
NOT required for pure docs/ edits — verify md renders coherently
|
||||
NOT required for pure docs/ edits; verify md renders coherently
|
||||
instead)
|
||||
|
||||
## References
|
||||
@@ -101,32 +103,32 @@ format precedent).
|
||||
(the serving shape being amended; Amendment 1 is the format
|
||||
precedent)
|
||||
- docs/architecture/wire.md §The Open Ops (the pinned
|
||||
`details.reason` vocabulary — unextended)
|
||||
`details.reason` vocabulary, unextended)
|
||||
- alkcall ADR-049 (the establisher posture being extended to the
|
||||
forwarded op's serving side); alkcall 0.10.0
|
||||
`register_openable_with_establisher` (verified present)
|
||||
|
||||
## Notes
|
||||
|
||||
- Shape chosen: **Amendment 2 to ADR-008** (the recommendation held —
|
||||
it did not balloon; §4/§5 needed no restating).
|
||||
- Shape chosen: **Amendment 2 to ADR-008** (the recommendation held:
|
||||
it did not balloon, and §4/§5 needed no restating).
|
||||
- Before writing, verified the ground against the shipped sources:
|
||||
alkcall 0.10.1 `EstablishmentError` (variants
|
||||
`DialFailed`/`UnknownResource`/`ResourceShortage`/`HandlerError`;
|
||||
`.reason()` maps `UnknownResource → "unknown_resource"`,
|
||||
operations.rs:592-596) and `register_openable_with_establisher`
|
||||
(operations.rs:761); the open-op wrapper order
|
||||
(`policy.check_open` — the ACL — then allocate → establisher →
|
||||
(`policy.check_open`, the ACL, then allocate → establisher →
|
||||
handler, operations.rs:1085-1103) pins the "scope gate precedes the
|
||||
establisher" invariant. Cargo.toml pins `alkcall = "0.10.1"`, so
|
||||
the ADR says "0.10.x, verified present".
|
||||
- The amended §3 sentence (lines 187-196, the serving-shape bullet)
|
||||
is marked inline with "**amended by Amendment 2**" — the
|
||||
is marked inline with "**amended by Amendment 2**"; the
|
||||
pre-amendment posture is recorded, not replaced (the Amendment 1
|
||||
style).
|
||||
- Deliberately NOT touched: `wire.md` (the amendment states its
|
||||
`details.reason` vocabulary is unextended; the one serving-shape
|
||||
prose line — "no establisher" under §The Forwarded Op — syncs with
|
||||
prose line ("no establisher" under §The Forwarded Op) syncs with
|
||||
the API task per the amendment's note) and review 003's status
|
||||
block (doc-sync task owns it).
|
||||
|
||||
@@ -135,7 +137,7 @@ format precedent).
|
||||
Amendment 2 landed at
|
||||
`docs/architecture/decisions/008-listen-metadata-forwarded-op.md`
|
||||
(2026-11-03): the forwarded op's serving side gains an establishment
|
||||
phase — an assembly-provided gate closure (over
|
||||
phase: an assembly-provided gate closure (over
|
||||
`TunnelForwardedParams`) the crate wraps into the alkcall
|
||||
`OpenEstablisher` dance via `register_openable_with_establisher`,
|
||||
with failures mapped to `EstablishmentError::UnknownResource` so a
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
id: tunnels/corrmiss-alksocks-adopt
|
||||
name: "W3 (pointer, alksocks repo): adopt the establisher — CorrelationRegistry lookup becomes the gate; the prose warn retires"
|
||||
name: "W3 (pointer, alksocks repo): adopt the establisher (CorrelationRegistry lookup becomes the
|
||||
gate); the prose warn retires"
|
||||
status: pending
|
||||
depends_on: [tunnels/corrmiss-review]
|
||||
scope: moderate
|
||||
@@ -12,20 +13,20 @@ tags: [pointer, cross-repo, alksocks, correlation-miss, review-003, wave-2]
|
||||
|
||||
## Description
|
||||
|
||||
**CROSS-REPO POINTER — executed in the alksocks repo, not here.**
|
||||
**Cross-repo pointer: executed in the alksocks repo, not here.**
|
||||
Dependency-graph carrier for the plan's third wave; the work session
|
||||
is booked in alksocks. Runs in parallel with W2 (alkssh) once W1's
|
||||
reviewed surface is landed.
|
||||
|
||||
Per plan 003 §W3 (read that section — it is the spec for this session;
|
||||
Per plan 003 §W3 (read that section, since it is the spec for this session;
|
||||
this task only carries the dependency + the acceptance summary):
|
||||
|
||||
- Move the `CorrelationRegistry` lookup (alksocks
|
||||
`src/composed/mod.rs` — `make_forwarded_handler`) into the new
|
||||
`src/composed/mod.rs`, `make_forwarded_handler`) into the new
|
||||
`register_tunnel_forwarded_openable_with_establisher` gate
|
||||
(consumed from alktunnels at the version W1 landed).
|
||||
- Retire the prose warn ("forwarded open with no waiting session",
|
||||
`src/composed/mod.rs:497`) — the miss becomes a typed failure at
|
||||
`src/composed/mod.rs:497`), so the miss becomes a typed failure at
|
||||
the open.
|
||||
- Residual logging aligns to the canonical `correlation-miss` name at
|
||||
`debug` (the family one-way vocabulary; alkssh's shipped form).
|
||||
@@ -44,7 +45,7 @@ this task only carries the dependency + the acceptance summary):
|
||||
|
||||
- docs/plans/003-correlation-miss-resolve.md §W3 (THE spec)
|
||||
- docs/reviews/003-correlation-miss-arrival-class-alkssh-input.md §Verified
|
||||
findings (the third variant's provenance —
|
||||
findings (the third variant's provenance,
|
||||
`src/composed/mod.rs:497`)
|
||||
- The alksocks repo: `src/composed/mod.rs` (`make_forwarded_handler`,
|
||||
`CorrelationRegistry`)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
id: tunnels/corrmiss-alkssh-adopt
|
||||
name: "W2 (pointer, alkssh repo): adopt the establisher — pump_forwarded_arrival's lookup becomes the gate"
|
||||
name: "W2 (pointer, alkssh repo): adopt the establisher (pump_forwarded_arrival's lookup becomes the gate)"
|
||||
status: pending
|
||||
depends_on: [tunnels/corrmiss-review]
|
||||
scope: moderate
|
||||
@@ -12,26 +12,26 @@ tags: [pointer, cross-repo, alkssh, correlation-miss, review-003, wave-2]
|
||||
|
||||
## Description
|
||||
|
||||
**CROSS-REPO POINTER — executed in the alkssh repo, not here.** This
|
||||
**Cross-repo pointer: executed in the alkssh repo, not here.** This
|
||||
task exists in this repo's decomposition so the dependency graph and
|
||||
the plan's ordering (`W1 → {W2, W3}`) stay visible; the work session
|
||||
is booked in alkssh (its wave-3 review gate item 5 lineage —
|
||||
is booked in alkssh (its wave-3 review gate item 5 lineage from
|
||||
`tasks/implementation/review-wave-3.md` item 5 and
|
||||
`docs/plans/implementation.md` "Still booked with their owners").
|
||||
|
||||
Per plan 003 §W2 (read that section — it is the spec for this session;
|
||||
Per plan 003 §W2 (read that section, since it is the spec for this session;
|
||||
this task only carries the dependency + the acceptance summary):
|
||||
|
||||
- Move `pump_forwarded_arrival`'s route lookup (alkssh
|
||||
`src/tunnel_map.rs` — the `ListenRouter` lookup) into the new
|
||||
`src/tunnel_map.rs`, the `ListenRouter` lookup) into the new
|
||||
`register_tunnel_forwarded_openable_with_establisher` gate
|
||||
(consumed from alktunnels at the version W1 landed).
|
||||
- The residual handler keeps `decline_arrival("correlation-miss", …)`
|
||||
at its shipped `debug` level for the narrow post-open race only.
|
||||
- Verify the fabric-side silence fix: a stale-token arrival now fails
|
||||
the open (alkssh-side typed error; the producer-side generic arm
|
||||
fires with a parseable reason) — the race window no longer produces
|
||||
a zero-signal pump.
|
||||
fires with a parseable reason, so the race window no longer
|
||||
produces a zero-signal pump.
|
||||
- Fold alkssh's em-dash doc cleanup into its release for this change
|
||||
(user-owned, out of plan scope).
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ Close out W1's remaining doc bookkeeping (the plan-level AC's "review
|
||||
resolve the review's doc trail once the ADR + API + tests + review
|
||||
checkpoint are landed.
|
||||
|
||||
- `docs/reviews/003-…alkssh-input.md`: update the Status block —
|
||||
- `docs/reviews/003-…alkssh-input.md`: update the Status block,
|
||||
resolved for W1 (this repo absorbed the ask via the ADR + the
|
||||
establisher variant); W2/W3 pointed at the sibling repos
|
||||
(`docs/plans/003-correlation-miss-resolve.md` is the pointer). Match
|
||||
@@ -25,13 +25,13 @@ checkpoint are landed.
|
||||
- `docs/plans/003-correlation-miss-resolve.md`: tick the W1 share of
|
||||
§Acceptance criteria (the first checkbox's clauses that are now
|
||||
true). Leave the W2/W3 checkboxes and the plan-level "W2: …" /
|
||||
"W3: …" boxes UNTICKED — they are sibling-repo sessions, unticked
|
||||
until those repos land.
|
||||
"W3: …" boxes UNTICKED, since they are sibling-repo sessions,
|
||||
unticked until those repos land.
|
||||
- `docs/plans/003-…` status header: planned → W1 landed (keep the
|
||||
file; it records the tri-vocabulary decision's ground).
|
||||
- If any doc comment referencing the miss is stale after the API task
|
||||
(a `grep` for `correlation miss` / `handler_error`-class across
|
||||
`src/`, `docs/architecture/`) — align it. Convention: stale
|
||||
`src/`, `docs/architecture/`), align it. Convention: stale
|
||||
TODOs/comments pointing at superseded designs get removed, not
|
||||
annotated.
|
||||
|
||||
@@ -52,7 +52,7 @@ checkpoint are landed.
|
||||
(the status block)
|
||||
- docs/plans/003-correlation-miss-resolve.md §Acceptance criteria
|
||||
(the plan-level boxes)
|
||||
- tasks/tunnels/review-002-doc-sync.md (the doc-sync precedent —
|
||||
- tasks/tunnels/review-002-doc-sync.md (the doc-sync precedent, the
|
||||
same job shape for review 002)
|
||||
|
||||
## Notes
|
||||
@@ -66,25 +66,25 @@ the ADR amendment itself). Three STALE spots found and aligned:
|
||||
|
||||
- `src/producer.rs:838-854` (the bind-first accept loop's failed-open
|
||||
arm): the in-comment class-distinction still read
|
||||
"correlation miss → handler_error-class" — the exact prose review
|
||||
"correlation miss → handler_error-class", the exact prose review
|
||||
003 flagged. Reworded: a stale/missing token is now the typed
|
||||
`unknown_resource` open_failed under the serving side's establisher
|
||||
variant (ADR-008 Amendment 2); other establisher failures ride
|
||||
handler_error-class. (Deliberately a comment-update, not removal —
|
||||
the class distinctions are the arm's working notes; convention 1's
|
||||
handler_error-class. (Deliberately a comment-update, not removal,
|
||||
since the class distinctions are the arm's working notes; convention 1's
|
||||
removal rule is for pointers at superseded designs, and this one
|
||||
now points at the current one.)
|
||||
- `wire.md` §The Forwarded Op "Reply / errors": "a producer-side
|
||||
accept correlation miss is the typed
|
||||
`channel:open_failed`/`handler_error`-class error" — pre-amendment
|
||||
attribution. Now records the amendment posture: the miss is the
|
||||
`channel:open_failed`/`handler_error`-class error", which was
|
||||
pre-amendment attribution. Now records the amendment posture: the miss is the
|
||||
typed `unknown_resource` open_failed (establisher variant,
|
||||
recommended); under the base shape an in-session miss produces no
|
||||
call error — the assembly-side post-open decline,
|
||||
call error: the assembly-side post-open decline,
|
||||
`correlation-miss` at `debug`.
|
||||
- `producer.md` §The Forwarded Op: "(ADR-008 §3) … no establisher,
|
||||
the data plane starts immediately" — now the two-shape posture with
|
||||
the Amendment 2 pointer; the correlate step notes the gate rides
|
||||
the data plane starts immediately" now carries the two-shape
|
||||
posture with the Amendment 2 pointer; the correlate step notes the gate rides
|
||||
the establisher with the handler-side correlation as the residual
|
||||
fallback. `last_updated` header bumped.
|
||||
|
||||
@@ -97,7 +97,7 @@ via plan 003 §Decisions 1–4 + ADR-008 Amendment 2 (typed
|
||||
vocabulary unextended), with W2 (alkssh) / W3 (alksocks) pointed at
|
||||
the plan's §W2/§W3 and recorded as open. Plan 003's status header is
|
||||
"W1 landed; W2/W3 open (sibling repos)" and only the W1 plan-level AC
|
||||
box is ticked — W2/W3 and the family-coherence box stay untouched
|
||||
box is ticked, and W2/W3 plus the family-coherence box stay untouched
|
||||
until those repos land. The stale-comment sweep aligned three
|
||||
stragglers (the accept-loop arm's comment, wire.md's failure line,
|
||||
producer.md's forwarded-op section); everything else already carried
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
id: tunnels/corrmiss-forwarded-gate
|
||||
name: "API: the forwarded-op establisher variant — ForwardedGate + register_tunnel_forwarded_openable_with_establisher"
|
||||
name: "API: the forwarded-op establisher variant (ForwardedGate + register_tunnel_forwarded_openable_with_establisher)"
|
||||
status: completed
|
||||
depends_on: [tunnels/corrmiss-adr]
|
||||
scope: moderate
|
||||
@@ -16,13 +16,13 @@ Implement the additive establisher variant for the forwarded op's
|
||||
serving half (plan 003 W1's API item): an assembly-injected
|
||||
correlation gate that the crate wraps into the alkcall
|
||||
`OpenEstablisher` dance, registered via
|
||||
`register_openable_with_establisher` — a stale `listen` token now
|
||||
`register_openable_with_establisher`: a stale `listen` token now
|
||||
returns a typed `channel:open_failed` before the channel's data plane
|
||||
is served, instead of succeeding at the call plane and declining
|
||||
post-open (the phantom-channel shape the review records as
|
||||
fully silent on the fabric side).
|
||||
|
||||
Candidate shape (the plan's W1 design decision to finalize — this task
|
||||
Candidate shape (the plan's W1 design decision to finalize, which this task
|
||||
finalizes it):
|
||||
|
||||
```rust
|
||||
@@ -44,36 +44,36 @@ pub fn register_tunnel_forwarded_openable_with_establisher(
|
||||
### The design decisions to finalize (and pin in doc comments)
|
||||
|
||||
1. **Gate input vs raw input.** The gate receives the PARSED
|
||||
`TunnelForwardedParams` — the establisher parses (the params'
|
||||
serde pins — `deny_unknown_fields`, positive `listen` — are the
|
||||
`TunnelForwardedParams`; the establisher parses (the params'
|
||||
serde pins, `deny_unknown_fields`, positive `listen`, are the
|
||||
loud gates; `parse` failure at the establisher maps through
|
||||
`EstablishmentError::HandlerError` → a typed `channel:open_failed`,
|
||||
which is an improvement over the base shape's silent
|
||||
birth-teardown for a malformed ingress; pin this deliberately, do
|
||||
not drift it). The handler still receives the raw input and
|
||||
re-parses as it does today — keep `make_forwarded_op_handler`
|
||||
re-parses as it does today, so keep `make_forwarded_op_handler`
|
||||
unchanged (the double parse is cheap and preserves deny-unknown at
|
||||
both layers; the handler must stay usable by the base shape).
|
||||
2. **The establishment answer.** A gate pass produces no resource
|
||||
handle (the correlation map is assembly state; the handler needs
|
||||
nothing it does not re-derive) — return `Establishment::new(plan)`
|
||||
nothing it does not re-derive). It returns `Establishment::new(plan)`
|
||||
with an empty/marker plan payload (same typed-opaque `ChannelPlan`
|
||||
posture as ADR-049 amendment 2; `()` or an empty marker struct is
|
||||
the payload choice to pin). No reply-field contribution (the
|
||||
forwarded op's reply stays the wrapper's `channel_id`).
|
||||
3. **Error mapping.** A gate `Err(TunnelEstablishError)` maps through
|
||||
the EXISTING `From<TunnelEstablishError> for EstablishmentError`
|
||||
table (`src/producer.rs:142-154`) — a stale token is
|
||||
table (`src/producer.rs:142-154`); a stale token is
|
||||
`UnknownResource` → `details.reason == "unknown_resource"` on the
|
||||
wire. Do not mint new variants. The gate SHOULD document that a
|
||||
miss is `TunnelEstablishError::UnknownResource` (that is the
|
||||
canonical reason the ADR pins), but the crate passes whatever the
|
||||
gate returns through.
|
||||
4. **No timeout change** — `timeout: None` (the alkcall 10s default)
|
||||
4. **No timeout change**: `timeout: None` (the alkcall 10s default)
|
||||
as the other registrations do; the gate is an in-memory map lookup.
|
||||
5. **No scope-table change** — the same `tunnel_forwarded_spec()` /
|
||||
5. **No scope-table change**: the same `tunnel_forwarded_spec()` /
|
||||
`tunnel:forwarded` gate runs (the registry runs the ACL before the
|
||||
establishment phase — alkcall's open-op wrapper order; pin the
|
||||
establishment phase, alkcall's open-op wrapper order; pin the
|
||||
order in a doc comment + a test, see the tests task).
|
||||
|
||||
### Also in this task (the doc half of W1's API item)
|
||||
@@ -81,7 +81,7 @@ pub fn register_tunnel_forwarded_openable_with_establisher(
|
||||
- Update the in-code docs that describe the miss as
|
||||
assembly-side-only: `make_forwarded_op_handler`'s
|
||||
correlation-miss-semantics block (`src/producer.rs:1014-1020`) and
|
||||
`register_tunnel_forwarded_openable`'s doc (1047-1056) — both gain
|
||||
`register_tunnel_forwarded_openable`'s doc (1047-1056); both gain
|
||||
the two-shape posture: the establisher variant is the recommended
|
||||
shape (the miss is a typed failure at the open); the base shape
|
||||
stays for compatibility and keeps the post-open teardown-at-birth
|
||||
@@ -89,11 +89,12 @@ pub fn register_tunnel_forwarded_openable_with_establisher(
|
||||
variant).
|
||||
- Re-export the new surface from `lib.rs` (convention 16).
|
||||
- The base `register_tunnel_forwarded_openable` stays
|
||||
byte-for-byte compatible (additive-only — plan decision 4).
|
||||
byte-for-byte compatible (additive-only, per plan decision 4).
|
||||
|
||||
NOT in this task: the sibling-repo adoptions (alkssh W2 / alksocks W3
|
||||
— pointer tasks exist), the gate-miss tests (the dedicated tests task
|
||||
— but do not land the API without landing its tests task after).
|
||||
NOT in this task: the sibling-repo adoptions (alkssh W2 / alksocks
|
||||
W3, pointer tasks exist) and the gate-miss tests (the dedicated
|
||||
tests task; but do not land the API without landing its tests task
|
||||
after).
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
@@ -119,15 +120,15 @@ NOT in this task: the sibling-repo adoptions (alkssh W2 / alksocks W3
|
||||
- docs/plans/003-correlation-miss-resolve.md §W1 (the API bullet;
|
||||
the decisions this finalizes)
|
||||
- docs/architecture/decisions/008-listen-metadata-forwarded-op.md §3
|
||||
+ the ADR this task's predecessor lands (Amendment 2 or successor —
|
||||
+ the ADR this task's predecessor lands (Amendment 2 or successor;
|
||||
cite whichever the ADR task landed)
|
||||
- src/producer.rs:982-1082 (the base serving shape — `ForwardedHandler`,
|
||||
- src/producer.rs:982-1082 (the base serving shape: `ForwardedHandler`,
|
||||
`make_forwarded_op_handler`, `register_tunnel_forwarded_openable`);
|
||||
142-154 (the `From<TunnelEstablishError>` table); 641-673
|
||||
(the `bindfirst_establisher` — the wrap-into-`OpenEstablisher`
|
||||
(the `bindfirst_establisher`, the wrap-into-`OpenEstablisher`
|
||||
closure pattern to mirror, including the `Box::pin` shape)
|
||||
- alkcall 0.10.0 `ChannelCore::register_openable_with_establisher`
|
||||
(src/channels/operations.rs:761 — the establisher runs AFTER
|
||||
(src/channels/operations.rs:761; the establisher runs AFTER
|
||||
allocation, BEFORE the reply; failure → typed `channel:open_failed`,
|
||||
no phantom channel; `timeout: None` = the 10s default)
|
||||
- AGENTS.md conventions 11 (the ADR-049 establishment surface is
|
||||
@@ -138,24 +139,24 @@ NOT in this task: the sibling-repo adoptions (alkssh W2 / alksocks W3
|
||||
|
||||
- `ForwardedGate` (sync closure over the parsed `TunnelForwardedParams`)
|
||||
wraps into a private `make_forwarded_gate_establisher` →
|
||||
`register_openable_with_establisher(spec, Some(...), make_forwarded_op_handler(handler), registry, auth, None)`
|
||||
— the base handler is reused byte-for-byte, so the base registration
|
||||
stays untouched (additive-only).
|
||||
`register_openable_with_establisher(spec, Some(...), make_forwarded_op_handler(handler), registry, auth, None)`,
|
||||
so the base handler is reused byte-for-byte and the base
|
||||
registration stays untouched (additive-only).
|
||||
- The design decisions were finalized exactly as the task sketched:
|
||||
(1) gate receives the PARSED params; establisher parse failure maps
|
||||
`TunnelEstablishError::HandlerError` → typed `channel:open_failed`
|
||||
(pinned in the establisher doc — an improvement over the base
|
||||
(pinned in the establisher doc, an improvement over the base
|
||||
shape's silent birth-teardown for malformed ingress); the handler
|
||||
re-parses the raw input (deny-unknown at both layers, handler
|
||||
unchanged); (2) gate pass → `Establishment::new(plan)` with an
|
||||
EMPTY MARKER plan payload — pinned as a new pub `ForwardedPlan`
|
||||
EMPTY MARKER plan payload, pinned as a new pub `ForwardedPlan`
|
||||
unit struct (chosen over `()`: self-documenting, downcast-able by
|
||||
assemblies that want to assert the gate ran — same typed-opaque
|
||||
assemblies that want to assert the gate ran; the same typed-opaque
|
||||
posture as ADR-049 amendment 2); no reply-field contribution;
|
||||
(3) gate errors pass through the existing
|
||||
`From<TunnelEstablishError>` table, no new variants — the gate doc
|
||||
`From<TunnelEstablishError>` table, no new variants; the gate doc
|
||||
pins `UnknownResource` as the canonical stale-token reason;
|
||||
(4) `timeout: None` (10s default); (5) no scope-table change — the
|
||||
(4) `timeout: None` (10s default); (5) no scope-table change; the
|
||||
wrapper order (schema → ACL → allocate → establisher → handler) is
|
||||
pinned in the establisher doc; the order-pinning test lands with
|
||||
the tests task.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
id: tunnels/corrmiss-gate-tests
|
||||
name: "Tests: the forwarded gate — typed miss at the open, gate-pass flow, residual race, scope-gate ordering"
|
||||
name: "Tests: the forwarded gate: typed miss at the open, gate-pass flow, residual race, scope-gate ordering"
|
||||
status: completed
|
||||
depends_on: [tunnels/corrmiss-forwarded-gate]
|
||||
scope: narrow
|
||||
@@ -14,8 +14,8 @@ tags: [tests, establisher, forwarded-op, correlation-miss, review-003]
|
||||
|
||||
Land W1's test item (plan 003 §W1 "Tests:"): pin the four behaviors
|
||||
the establisher variant introduces, mirroring the shapes the existing
|
||||
suite uses (`tests/producer_forwarded.rs` — the serving-half e2e +
|
||||
scope-gate + malformed-params + correlation-miss tests are the
|
||||
suite uses (`tests/producer_forwarded.rs`, whose serving-half e2e,
|
||||
scope-gate, malformed-params, and correlation-miss tests are the
|
||||
precedents; the harness's `wire_with` + `Topology.consumer_registry`
|
||||
shape needs no harness changes per the `forwarded-receipt` task's
|
||||
notes).
|
||||
@@ -26,7 +26,7 @@ The four pins:
|
||||
with no waiting session → the open fails as a
|
||||
`channel:open_failed` reply with `details.reason ==
|
||||
"unknown_resource"`; the transiently allocated channel is torn
|
||||
down by the wrapper (ADR-049 §5 — the reply is the typed error,
|
||||
down by the wrapper (ADR-049 §5: the reply is the typed error,
|
||||
not a phantom channel); the pump handler NEVER runs. Probe by
|
||||
opening the forwarded op with a token the gate rejects.
|
||||
2. **Gate pass → the existing serving flow is unchanged.** A token the
|
||||
@@ -38,13 +38,13 @@ The four pins:
|
||||
3. **The residual race shape still reaps cleanly.** Gate passes, route
|
||||
gone at handler time → the injected handler's error path drops the
|
||||
stream and returns → teardown-at-birth reaps the forwarded channel
|
||||
(the base shape's pinned semantics — re-pin them under the
|
||||
(the base shape's pinned semantics; re-pin them under the
|
||||
establisher variant; the narrow establisher-pass→handler-run window
|
||||
is the residual `correlation-miss` class).
|
||||
4. **Scope gate precedes the establisher.** An identity WITHOUT
|
||||
`tunnel:forwarded` → FORBIDDEN and the gate closure is never
|
||||
invoked (a witness-style `Arc<AtomicBool>` or captured flag the
|
||||
gate sets on entry; assert unmoved) — the registry runs the ACL
|
||||
gate sets on entry; assert unmoved); the registry runs the ACL
|
||||
before the open op's establishment phase; pin the order.
|
||||
|
||||
If the establisher variant's parse-failure behavior (malformed params
|
||||
@@ -81,65 +81,65 @@ explicitly (do not silently assume).
|
||||
the FORBIDDEN gate, the malformed-params gates, the
|
||||
correlation-miss teardown)
|
||||
- tasks/tunnels/corrmiss-forwarded-gate.md (the surface being tested;
|
||||
design decisions 1 + 3 — the parse-failure and error-mapping pins)
|
||||
design decisions 1 + 3 (the parse-failure and error-mapping pins))
|
||||
- docs/architecture/wire.md §The Open Ops (the
|
||||
`details.reason == "unknown_resource"` vocabulary — the assertion
|
||||
`details.reason == "unknown_resource"` vocabulary; the assertion
|
||||
target)
|
||||
- alkcall 0.10.0 `src/channels/operations.rs:761` (the wrapper's
|
||||
establisher failure → typed open_failed semantics; ADR-049 §5)
|
||||
|
||||
## Notes
|
||||
|
||||
- New test file `tests/producer_forwarded_gate.rs` — the
|
||||
- New test file `tests/producer_forwarded_gate.rs` with the
|
||||
establisher-variant probes live in their own file (the base shape's
|
||||
`tests/producer_forwarded.rs` stays untouched), reusing the
|
||||
harness's `wire_with` + `Topology.consumer_registry` shape with no
|
||||
harness changes, exactly as the `forwarded-receipt` task's notes
|
||||
predicted.
|
||||
- The gate under test closes over a `std::sync::Mutex<HashSet<u32>>`
|
||||
of waiting tokens (the gate is a sync closure — no awaits) and sets
|
||||
of waiting tokens (the gate is a sync closure, with no awaits) and sets
|
||||
an `Arc<AtomicBool>` witness on entry; the witness is what the
|
||||
miss/ordering pins assert against. Poisoned-lock handling follows
|
||||
convention 2 (`unwrap_or_else(|e| e.into_inner())`).
|
||||
- The five tests:
|
||||
1. `gate_miss_fails_typed_no_phantom_no_handler` — AC 1: open with
|
||||
1. `gate_miss_fails_typed_no_phantom_no_handler` (AC 1: open with
|
||||
an absent token → `Err(CallError)` with
|
||||
`code == "channel:open_failed"` and
|
||||
`details.reason == "unknown_resource"` (the existing
|
||||
`From<TunnelEstablishError>` table, 1:1); after a settle sleep
|
||||
the opener-side manager has no channels (the wrapper's transient
|
||||
allocation was torn down — ADR-049 §5, no phantom); the gate's
|
||||
allocation was torn down; ADR-049 §5, no phantom); the gate's
|
||||
witness IS set (the miss is at the gate, not before it) and the
|
||||
pump handler's witness is unmoved (the handler never ran).
|
||||
2. `gate_pass_serves_the_unchanged_serving_flow` — AC 2: token in
|
||||
2. `gate_pass_serves_the_unchanged_serving_flow` (AC 2: token in
|
||||
the map + correlated halves → the open resolves with the
|
||||
wrapper's `channel_id`; the injected handler is witnessed with
|
||||
the parsed params verbatim; the full dispatcher-pump dance
|
||||
(adopt + driving echo) round-trips bytes end to end — the
|
||||
(adopt + driving echo) round-trips bytes end to end, i.e. the
|
||||
`producer_forwarded.rs` e2e shape driven through the
|
||||
establisher-registered variant.
|
||||
3. `residual_race_still_reaps_cleanly` — AC 3: the token is in the
|
||||
3. `residual_race_still_reaps_cleanly` (AC 3: the token is in the
|
||||
map (the gate passes, the open RESOLVES at the call plane) but
|
||||
the halves vec is empty at handler time → the handler's error
|
||||
path drops the stream and returns → teardown-at-birth reaps.
|
||||
Re-pins the base shape's `forwarded_correlation_miss_
|
||||
tears_channel_down` semantics under the establisher variant —
|
||||
tears_channel_down` semantics under the establisher variant, where
|
||||
the establisher-pass→handler-run window is the residual
|
||||
`correlation-miss` class.
|
||||
4. `scope_gate_precedes_the_establisher` — AC 4: an identity
|
||||
4. `scope_gate_precedes_the_establisher` (AC 4: an identity
|
||||
holding only `tunnel:open` → `FORBIDDEN` naming the missing
|
||||
scope, and the gate's witness stays unmoved even though the
|
||||
token WOULD have passed the gate (the tie-break is the order —
|
||||
schema → ACL → allocate → establisher → handler — pinning that
|
||||
the registry runs the ACL before the establishment phase).
|
||||
5. `malformed_params_fail_typed_at_the_establisher` — AC 5: the
|
||||
token WOULD have passed the gate. The tie-break is the order
|
||||
(schema → ACL → allocate → establisher → handler), which pins
|
||||
that the registry runs the ACL before the establishment phase).
|
||||
5. `malformed_params_fail_typed_at_the_establisher` (AC 5: the
|
||||
establisher-shape malformed-params delta IS separately
|
||||
testable through the public surface, so it is pinned: the
|
||||
schema gates (`listen: 0`, missing `peer`) fire first exactly
|
||||
as in the base shape (`INVALID_INPUT`, the test explicitly
|
||||
re-checks both), but an unknown top-level field — passes the
|
||||
schema's additive posture, fails the establisher's serde parse
|
||||
— becomes a TYPED `channel:open_failed` with
|
||||
re-checks both), but an unknown top-level field passes the
|
||||
schema's additive posture while failing the establisher's serde
|
||||
parse, so it becomes a TYPED `channel:open_failed` with
|
||||
`details.reason == "handler_error"` (vs the base shape's
|
||||
success-with-channel_id + silent birth-teardown: the improvement
|
||||
design decision 1 pins). The establisher's parse precedes the
|
||||
@@ -149,7 +149,7 @@ explicitly (do not silently assume).
|
||||
`cargo clippy --target wasm32-unknown-unknown --all-targets` cannot
|
||||
compile on this repo's baseline (the wasm clippy's `--all-targets`
|
||||
pulls the test/dev targets whose `mio`/`socket2` deps do not
|
||||
compile for wasm) — verified by removing the new test file and
|
||||
compile for wasm); verified by removing the new test file and
|
||||
re-running: identical failure without the change. The repo's
|
||||
AGENTS.md verification list specifies wasm clippy WITHOUT
|
||||
`--all-targets`
|
||||
@@ -163,14 +163,14 @@ explicitly (do not silently assume).
|
||||
|
||||
Landed W1's test item: five tests in the new
|
||||
`tests/producer_forwarded_gate.rs` pinning the establisher variant's
|
||||
behaviors — (1) gate miss → typed `channel:open_failed` /
|
||||
behaviors: (1) gate miss → typed `channel:open_failed` /
|
||||
`details.reason == "unknown_resource"` / no phantom channel / handler
|
||||
never runs; (2) gate pass → the base serving flow unchanged (params
|
||||
witnessed verbatim + full byte round-trip through the
|
||||
establisher-registered variant); (3) the residual race (gate pass,
|
||||
route gone at handler time) reaps cleanly via teardown-at-birth,
|
||||
re-pinning the base shape's correlation-miss semantics under the
|
||||
establisher variant; (4) scope-gate ordering — a scope-less identity
|
||||
establisher variant; (4) scope-gate ordering: a scope-less identity
|
||||
is FORBIDDEN and the gate closure is never invoked (witness unmoved);
|
||||
(5) the establisher-variant malformed-params delta pinned explicitly:
|
||||
schema gates unchanged (`INVALID_INPUT`) but an unknown top-level
|
||||
@@ -179,11 +179,11 @@ field becomes a typed `channel:open_failed` with
|
||||
Verification green: `cargo test` (14 suites, 0 failures),
|
||||
`cargo clippy --all-targets -- -D warnings` (native) clean,
|
||||
wasm clippy `cargo clippy --target wasm32-unknown-unknown -- -D
|
||||
warnings` clean (see Notes for the `--all-targets`-wasm deviation —
|
||||
pre-existing, dev-deps do not target wasm),
|
||||
warnings` clean (see Notes for the pre-existing
|
||||
`--all-targets`-wasm deviation: dev-deps do not target wasm),
|
||||
`cargo fmt --check` clean,
|
||||
`cargo check --target wasm32-unknown-unknown` clean (default crate
|
||||
stays wasm-clean), `cargo test --all-features` green, `cargo doc
|
||||
--no-deps` clean. Harness unchanged. Follow-ups: the sibling-repo
|
||||
adoptions (`corrmiss-alkssh-adopt`, `corrmiss-alksocks-adopt`) — the
|
||||
adoptions (`corrmiss-alkssh-adopt`, `corrmiss-alksocks-adopt`). The
|
||||
W1 wave's tests are green.
|
||||
@@ -12,12 +12,12 @@ tags: [review, checkpoint, establisher, review-003]
|
||||
|
||||
## Description
|
||||
|
||||
Review checkpoint before the parallel consumer waves (W2 — alkssh,
|
||||
W3 — alksocks) adopt the landed establisher variant: the surface these
|
||||
Review checkpoint before the parallel consumer waves (W2, alkssh,
|
||||
W3, alksocks) adopt the landed establisher variant: the surface these
|
||||
sessions build on must be right before two sibling repos burn sessions
|
||||
on it. Wire-stability context: the REPLY-side semantics (a typed
|
||||
`channel:open_failed` with the pre-pinned `unknown_resource` reason)
|
||||
are what the consumers ride — once alkssh ships the gate shape, the
|
||||
are what the consumers ride: once alkssh ships the gate shape, the
|
||||
API surface is de-facto wire-stable for them.
|
||||
|
||||
Check:
|
||||
@@ -31,17 +31,17 @@ Check:
|
||||
registrations, and lib.rs's re-export surface
|
||||
- [x] The tests actually pin the wire-observable contract
|
||||
(`channel:open_failed` / `unknown_resource`, gate ordering,
|
||||
residual-race reaping) — not just internal shapes
|
||||
residual-race reaping), not just internal shapes
|
||||
- [x] Patterns are consistent with the existing establisher shapes
|
||||
(the `bindfirst_establisher` mirror; the
|
||||
`From<TunnelEstablishError>` table reuse; no new error axes)
|
||||
- [x] `cargo test`, clippy native + wasm32 `-D warnings`, fmt — green
|
||||
- [x] `cargo test`, clippy native + wasm32 `-D warnings`, and fmt are green
|
||||
(re-confirm, they gate the review)
|
||||
- [x] CHANGELOG updated (Added: the establisher variant) if the
|
||||
implementation tasks did not already
|
||||
|
||||
Filing guidance: if the surface needs a shape change (naming, gate
|
||||
arity, plan payload), fix it in this checkpoint — it is the last cheap
|
||||
arity, plan payload), fix it in this checkpoint; it is the last cheap
|
||||
moment before the consumers fork.
|
||||
|
||||
## Acceptance Criteria
|
||||
@@ -56,7 +56,7 @@ moment before the consumers fork.
|
||||
- docs/plans/003-correlation-miss-resolve.md §Ordering (the W1 →
|
||||
{W2, W3} fork this review gates)
|
||||
- docs/architecture/decisions/008-listen-metadata-forwarded-op.md
|
||||
(the amended ADR — the shape source)
|
||||
(the amended ADR, the shape source)
|
||||
- tasks/tunnels/corrmiss-adr.md, corrmiss-forwarded-gate.md,
|
||||
corrmiss-gate-tests.md (the W1 trio being reviewed)
|
||||
- src/producer.rs (the landed surface)
|
||||
@@ -64,7 +64,7 @@ moment before the consumers fork.
|
||||
## Notes
|
||||
|
||||
Review performed 2026-11-03 (review checkpoint gating the W1 → {W2,
|
||||
W3} fork). Outcome: **surface approved — no shape change needed; two
|
||||
W3} fork). Outcome: **surface approved with no shape change needed; two
|
||||
doc-level fixes applied and re-verified.**
|
||||
|
||||
- **API vs the ADR ink (check 1):** all pins hold.
|
||||
@@ -75,10 +75,10 @@ doc-level fixes applied and re-verified.**
|
||||
matches.
|
||||
- Error-mapping pin: gate `Err` passes through the existing
|
||||
`From<TunnelEstablishError> for EstablishmentError` table
|
||||
(`src/producer.rs:142`) — no new variants minted; the gate doc
|
||||
(`src/producer.rs:142`); no new variants are minted, and the gate doc
|
||||
pins `UnknownResource` as the canonical stale-token reason.
|
||||
- Establisher parse failure → `HandlerError` → typed
|
||||
`channel:open_failed` — deliberately pinned in the establisher
|
||||
`channel:open_failed`, deliberately pinned in the establisher
|
||||
doc (an improvement over the base shape's silent birth-teardown),
|
||||
exactly as corrmiss-forwarded-gate's decision 1 recorded.
|
||||
- Timeout: `timeout: None` (the 10s default) in the registration.
|
||||
@@ -86,7 +86,7 @@ doc-level fixes applied and re-verified.**
|
||||
`tunnel:forwarded` gate runs; the wrapper order pin (schema → ACL
|
||||
→ allocate → establisher → handler) is in the establisher doc.
|
||||
- The plan payload is the pinned `ForwardedPlan` marker struct (the
|
||||
`()`-alternative was rejected in the API task's Notes —
|
||||
`()`-alternative was rejected in the API task's Notes, as
|
||||
self-documenting, downcast-able; consistent with the typed-opaque
|
||||
posture of `TargetHandle`/`BindFirstPlan`).
|
||||
- **Two-shape posture consistency (check 2):** present and consistent
|
||||
@@ -97,7 +97,8 @@ doc-level fixes applied and re-verified.**
|
||||
Forwarded Op's two-shape prose, `tunnel_forwarded_spec()`'s serving
|
||||
registration block). ONE drift found and fixed: the handler doc's
|
||||
HEADER still read "the no-establisher serving shape" while it now
|
||||
serves both registrations — retitled "the serving-shape wrapper
|
||||
serves both registrations, so it was retitled "the serving-shape
|
||||
wrapper
|
||||
both registrations share" (the two-shape block below it already
|
||||
carried the posture; this aligns the header).
|
||||
- **Tests vs the wire contract (check 3):** the five tests in
|
||||
@@ -109,7 +110,7 @@ doc-level fixes applied and re-verified.**
|
||||
the residual race asserts the call-plane RESOLVES then the channel
|
||||
reaps; the malformed-params delta asserts the typed `handler_error`
|
||||
reason and parse-before-gate. Each contract assertion is at the
|
||||
`CallError`/reply surface — a wire change would break them.
|
||||
`CallError`/reply surface, and a wire change would break them.
|
||||
- **Pattern consistency (check 4):** `make_forwarded_gate_establisher`
|
||||
mirrors the `bindfirst_establisher` closure shape (the
|
||||
`Arc::clone`-into-`Box::pin` pattern, the same
|
||||
@@ -120,10 +121,10 @@ doc-level fixes applied and re-verified.**
|
||||
crate-owned-surface rule (assemblies cannot hand-roll the alkcall
|
||||
plumbing).
|
||||
- **CHANGELOG (check 6):** the implementation tasks did NOT update it
|
||||
(Unreleased was empty) — added the "[Unreleased] / Added" entry for
|
||||
(Unreleased was empty); added the "[Unreleased] / Added" entry for
|
||||
the establisher variant per this task's checklist.
|
||||
- **Verification (check 5), run at this review:** `cargo test` green
|
||||
(14 suites, 0 failures — includes the five gate tests), clippy
|
||||
(14 suites, 0 failures, which includes the five gate tests), clippy
|
||||
`--all-targets -- -D warnings` clean (native), wasm clippy
|
||||
`cargo clippy --target wasm32-unknown-unknown -- -D warnings` clean,
|
||||
`cargo check --target wasm32-unknown-unknown` clean (default crate
|
||||
@@ -147,6 +148,6 @@ the `CallError` surface; patterns mirror `bindfirst_establisher` with
|
||||
no new error axes. Found and fixed: the stale "no-establisher serving
|
||||
shape" handler-doc header, and the missing CHANGELOG entry (Added
|
||||
under [Unreleased]). Re-verification all green (test, clippy native +
|
||||
wasm, fmt, wasm check, --all-features, doc). No shape change needed —
|
||||
the surface is ready for the consumer waves (W2 alkssh, W3 alksocks)
|
||||
wasm, fmt, wasm check, --all-features, doc). No shape change is
|
||||
needed, and the surface is ready for the consumer waves (W2 alkssh, W3 alksocks)
|
||||
to adopt.
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
id: tunnels/coverage-weak-spots
|
||||
name: "Coverage weak spots — CLOSED (2026-09-09 second pass): the UdpHalf sender-block recipe found, feed infallible, wrappers covered"
|
||||
name: "Coverage weak spots, CLOSED (2026-09-09 second pass): the UdpHalf sender-block recipe found, feed infallible, wrappers covered"
|
||||
status: completed
|
||||
depends_on: [tunnels/review-impl]
|
||||
scope: single
|
||||
@@ -18,16 +18,16 @@ The second pass found the sender-block recipe, took **option 1**, and
|
||||
coverage moved **92.86% → 93.91% lines** (50 → 41 uncovered). Three
|
||||
commits:
|
||||
|
||||
1. **`DatagramReader::feed` is infallible** — the same zombie category
|
||||
1. **`DatagramReader::feed` is infallible**, the same zombie category
|
||||
as `4014ba6`'s `InvalidLength`: with the oversize bound enforced at
|
||||
frame time, the incremental decode cannot fail, so the `Vec<Result>`
|
||||
shape and `local/mod.rs`'s dead `InvalidData` mapping were removed
|
||||
(pre-consumer window open per ADR-005's N-3).
|
||||
2. **The sender-block machinery test** (`udp_half_write_side_blocks_
|
||||
stashes_and_drains_on_resolve`) — the recipe that reached the
|
||||
stashes_and_drains_on_resolve`): the recipe that reached the
|
||||
WouldBlock stash, the readiness-Pending arms, AND the high-water
|
||||
backpressure in one coherent behavior (option 1).
|
||||
3. **The thin wrappers** — `connect_udp`'s resolve-failure arm
|
||||
3. **The thin wrappers**, `connect_udp`'s resolve-failure arm
|
||||
(`.invalid` NXDOMAIN → `dial_failed`) and the no-witness
|
||||
`tunnel_establisher` (a harness `DialNoWitness` mode + a full
|
||||
open round-trip through the wrapper).
|
||||
@@ -44,16 +44,16 @@ persistently reports `WouldBlock`/`ENOBUFS`. The recipe (validated
|
||||
unresolved neighbor while charged to `sk_wmem_alloc`; with a
|
||||
shrunken `SO_SNDBUF` the charge exceeds the buffer → persistent
|
||||
`EAGAIN`/`ENOBUFS` (the drain reports 105=ENOBUFS alongside
|
||||
EAGAIN — both mean "the socket cannot take this datagram now",
|
||||
EAGAIN; both mean "the socket cannot take this datagram now",
|
||||
which the `pump_queue_to_socket` stash arm now matches too).
|
||||
- **Jumbo MTU (65535)** on the fixture: a 60 KB datagram crosses as
|
||||
ONE frame — no IP fragmentation, so no fragment-reassembly races
|
||||
ONE frame, with no IP fragmentation, so no fragment-reassembly races
|
||||
at the peer (fragment-ID collisions under the drain burst merged
|
||||
datagrams on an MTU-1500 veth; the peer reassembled 5-6 datagrams
|
||||
into single ~43947-byte payloads).
|
||||
- **The block-state probe runs on a THROWAWAY socket**: an oversized
|
||||
probe datagram's partially-built skb chain stays corked in
|
||||
`sk_write_queue` on EAGAIN and flushes on the next successful send —
|
||||
`sk_write_queue` on EAGAIN and flushes on the next successful send,
|
||||
poisoning the socket with a stale partial datagram. The test's
|
||||
real socket is created fresh after the probe.
|
||||
- **Assigning the peer address + binding the peer** resolves ARP; the
|
||||
@@ -63,74 +63,75 @@ persistently reports `WouldBlock`/`ENOBUFS`. The recipe (validated
|
||||
|
||||
The first pass's kernel findings still hold and are why the loopback
|
||||
recipes failed there: a non-reading peer's full receive queue DROPS
|
||||
datagrams silently (loopback/veth/netem — the sender never blocks);
|
||||
datagrams silently (loopback/veth/netem: the sender never blocks);
|
||||
netem with `limit` still did not block (2M sends Ok in a probe).
|
||||
|
||||
## Negative result: the ICMP ECONNREFUSED arm is unreachable through the poll drive
|
||||
|
||||
A connected UDP socket + a closed peer port elicits an ICMP port
|
||||
unreachable that `ECONNREFUSED`s the next `recvfrom` (a plain std
|
||||
nonblocking recv surfaces it deterministically — probed rootless).
|
||||
nonblocking recv surfaces it deterministically; probed rootless).
|
||||
But **tokio 1.53 structurally masks it out of the poll drive** (the
|
||||
adapter's shape): the `EPOLLOUT|EPOLLERR` event maps to
|
||||
`WRITABLE|ERROR|WRITE_CLOSED`; `poll_recv_ready`'s Direction::Read
|
||||
mask is `READABLE|READ_CLOSED` (ERROR excluded) → Pending forever;
|
||||
`try_io(READABLE)` intersects empty → WouldBlock. Only the async
|
||||
`recv()` (`async_io` with `READABLE|ERROR`) surfaces `ECONNREFUSED`
|
||||
— and `UdpHalf::poll_read` is a poll impl. Verified by probes
|
||||
`recv()` (`async_io` with `READABLE|ERROR`) surfaces `ECONNREFUSED`,
|
||||
but `UdpHalf::poll_read` is a poll impl. Verified by probes
|
||||
(`poll_recv`, `poll_recv_ready`, `try_recv` all Elapsed; the async
|
||||
`recv` surfaced it exactly once, racing the waiter registration —
|
||||
nondeterministic). `local/mod.rs:356` therefore joins the defensive
|
||||
`recv` surfaced it exactly once, racing the waiter registration,
|
||||
hence nondeterministic). `local/mod.rs:356` therefore joins the defensive
|
||||
list: no deterministic test can reach it through the pump's drive on
|
||||
tokio 1.53.
|
||||
|
||||
## The final uncovered-line inventory (41 lines, all deliberate)
|
||||
|
||||
- `src/local/mod.rs:136-137` — the no-addresses resolve arm: a name
|
||||
- `src/local/mod.rs:136-137`, the no-addresses resolve arm: a name
|
||||
that RESOLVES to zero addresses is unreachable via getaddrinfo
|
||||
(NXDOMAIN errors first — the `.invalid` test hits the error arm).
|
||||
- `src/local/mod.rs:321-333` — the `Oversize` mapping at the recv
|
||||
scratch (a >65535-byte datagram cannot exist on a real wire — the
|
||||
(NXDOMAIN errors first; the `.invalid` test hits the error arm).
|
||||
- `src/local/mod.rs:321-333`: the `Oversize` mapping at the recv
|
||||
scratch (a >65535-byte datagram cannot exist on a real wire; the
|
||||
IP theoretical max is 65507).
|
||||
- `src/local/mod.rs:351-352,356` — the recv-side readiness-Pending
|
||||
arms (genuine WouldBlock) + the ICMP error mapping (the negative
|
||||
- `src/local/mod.rs:351-352,356`, the recv-side readiness-Pending
|
||||
arms (genuine WouldBlock) plus the ICMP error mapping (the negative
|
||||
result above).
|
||||
- `src/local/mod.rs:378-379,383` — the high-water drain's
|
||||
- `src/local/mod.rs:378-379,383`: the high-water drain's
|
||||
Ready(Ok)/Ready(Err) sub-arms (the observed over-line poll rides
|
||||
the Pending sub-arm; the Ok sub-arm needs the drain to succeed
|
||||
within that single poll — the FAILED-cycle release timing).
|
||||
- `src/local/mod.rs:476-477` — `pump_queue_to_socket`'s
|
||||
within that single poll, the FAILED-cycle release timing).
|
||||
- `src/local/mod.rs:476-477`, `pump_queue_to_socket`'s
|
||||
`poll_send_ready` Ready(Ok)/Err sub-arms (the Pend sub-arm is the
|
||||
covered one).
|
||||
- `src/consumer.rs:91-94,405-406` — `serde_json::to_value` failures
|
||||
- `src/consumer.rs:91-94,405-406`: `serde_json::to_value` failures
|
||||
on `TunnelParams` (impossible by construction).
|
||||
- `src/consumer.rs:205,290` — `unreachable!` guards on spent sessions.
|
||||
- `src/consumer.rs:285` — the stale-pump abort (defensive only).
|
||||
- `src/consumer.rs:325-330` — the pump `JoinError` telemetry arm
|
||||
(needs the spawned pump to panic/abort — not producible through
|
||||
the session API).
|
||||
- `src/producer.rs:85-86` — the `HandlerError` mapping (the schema
|
||||
- `src/consumer.rs:205,290`, `unreachable!` guards on spent sessions.
|
||||
- `src/consumer.rs:285`, the stale-pump abort (defensive only).
|
||||
- `src/consumer.rs:325-330`, the pump `JoinError` telemetry arm
|
||||
(needs the spawned pump to panic/abort, which is not producible
|
||||
through the session API).
|
||||
- `src/producer.rs:85-86`: the `HandlerError` mapping (the schema
|
||||
gate preempts it; pinned by `schema_gate_rejects_missing_substrate_…`).
|
||||
- `src/producer.rs:338-347` — the pump-handler telemetry early-returns
|
||||
- `src/producer.rs:338-347`, the pump-handler telemetry early-returns
|
||||
(`accept_bi` failure needs a dead channel; the plan is always
|
||||
`TargetHandle`; `Arc::try_unwrap` needs a second clone).
|
||||
- `src/wire.rs:77-79` — the `Default` impl shim (clippy's
|
||||
- `src/wire.rs:77-79`: the `Default` impl shim (clippy's
|
||||
`new_without_default`).
|
||||
- `src/wire.rs:106` — the `unreachable!` invariant in `feed`'s loop.
|
||||
- `src/wire.rs:177` — a unit test's own panic line (the negative
|
||||
- `src/wire.rs:106`, the `unreachable!` invariant in `feed`'s loop.
|
||||
- `src/wire.rs:177`, a unit test's own panic line (the negative
|
||||
branch of `datagram_split_across_awkward_chunks_reassembles`).
|
||||
|
||||
## Verification (2026-09-09, second pass)
|
||||
|
||||
- `cargo llvm-cov --all-features --workspace --summary-only`
|
||||
— **93.91% lines** (from 92.86%; 50 → 41 uncovered, all deliberate).
|
||||
- `cargo test --all-features` — **91 tests green** (15 lib + 16
|
||||
- `cargo llvm-cov --all-features --workspace --summary-only` reports
|
||||
**93.91% lines** (from 92.86%; 50 → 41 uncovered, all deliberate).
|
||||
- `cargo test --all-features` passes with **91 tests green** (15 lib
|
||||
+ 16
|
||||
consumer_session + 18 end_to_end + 13 doc/lib + 19 local_halves
|
||||
+ 9 producer_listen + 13 producer_open_op + 1 doc-test compile_fail).
|
||||
- `cargo test` (default) — green; the root-gated veth test skips
|
||||
- `cargo test` (default) is green; the root-gated veth test skips
|
||||
loud-free without root.
|
||||
- `cargo clippy --all-targets --all-features -- -D warnings` and
|
||||
`cargo fmt --check` — clean.
|
||||
`cargo fmt --check` are clean.
|
||||
- `cargo check --target wasm32-unknown-unknown` +
|
||||
`cargo clippy --target wasm32-unknown-unknown -- -D warnings` —
|
||||
`cargo clippy --target wasm32-unknown-unknown -- -D warnings` are
|
||||
clean (the default crate stays wasm-clean).
|
||||
+16
-16
@@ -22,8 +22,8 @@ fill in.
|
||||
|
||||
```rust
|
||||
// src/lib.rs
|
||||
//! alktunnels — arbitrary bidirectional tunnels over alkcall channels.
|
||||
//! (crate docs per overview.md §What — the two-paragraph shape)
|
||||
//! alktunnels: arbitrary bidirectional tunnels over alkcall channels.
|
||||
//! (crate docs per overview.md §What, the two-paragraph shape)
|
||||
|
||||
pub mod error;
|
||||
pub mod params;
|
||||
@@ -35,32 +35,32 @@ pub mod consumer;
|
||||
// Public API surface: re-exports filled in by subsequent tasks (convention 16).
|
||||
```
|
||||
|
||||
- `src/error.rs` — `TunnelError` (thiserror; convention 2) + the typed
|
||||
- `src/error.rs`: `TunnelError` (thiserror; convention 2) + the typed
|
||||
open-error surface re-exported from alkcall (`ChannelOpenError`,
|
||||
`establishment_reason`-shaped helper). Skeleton only; filled by
|
||||
`tunnels/params` and `tunnels/consumer-session`.
|
||||
- `src/params.rs` — `TunnelParams {resource, substrate}` +
|
||||
- `src/params.rs`: `TunnelParams {resource, substrate}` +
|
||||
`Substrate` enum (`tcp | udp | unix`) + `tunnel_open_spec()` builder +
|
||||
`TUNNEL_OPEN_SCOPE`/`OP_TUNNEL_OPEN`/`TUNNEL_ALPN` constants. Skeleton
|
||||
with type definitions; the schema builder + tests land in
|
||||
`tunnels/params`.
|
||||
- `src/wire.rs` — the codec module doc (ADR-003 summary + bast.md
|
||||
- `src/wire.rs`: the codec module doc (ADR-003 summary + bast.md
|
||||
pointer); skeleton for `frame_datagram`/`DatagramReader`/
|
||||
`DatagramCodecError`, filled by `tunnels/wire-codec`.
|
||||
- `src/producer.rs` — module doc (producer.md summary); skeleton for
|
||||
- `src/producer.rs`: module doc (producer.md summary); skeleton for
|
||||
`tunnel_open_spec` wiring, `register_tunnel_openable`, the establisher
|
||||
shape, the pump handler — filled by `tunnels/producer-open-op` +
|
||||
shape, and the pump handler, filled by `tunnels/producer-open-op` +
|
||||
`tunnels/producer-listen`.
|
||||
- `src/consumer.rs` — module doc (consumer.md summary); skeleton for
|
||||
`TunnelSession` — filled by `tunnels/consumer-session`.
|
||||
- `src/consumer.rs`: module doc (consumer.md summary); skeleton for
|
||||
`TunnelSession`, filled by `tunnels/consumer-session`.
|
||||
|
||||
### Keep in place / verify
|
||||
|
||||
- Cargo.toml: alkcall 0.7.0 pin, the wasm-clean tokio subset, the empty
|
||||
`[features]` table (the `local` feature is added by `tunnels/local-socket-halves`).
|
||||
- `bytes`, `serde`, `serde_json`, `thiserror`, `tracing`, `futures` stay;
|
||||
drop `async-trait` IF the skeleton confirms no trait is needed (ADR-004 —
|
||||
it likely is not; note the decision in Summary if dropped).
|
||||
drop `async-trait` IF the skeleton confirms no trait is needed
|
||||
(per ADR-004, it likely is not; note the decision in Summary if dropped).
|
||||
- The default crate stays wasm-clean: `cargo check --target
|
||||
wasm32-unknown-unknown` must pass at every task in this graph
|
||||
(convention 4; the structural guard).
|
||||
@@ -88,17 +88,17 @@ pub mod consumer;
|
||||
docs pointing at the spec docs + ADRs and `tunnels/*` tasks that fill
|
||||
them in.
|
||||
- Constants placed early (`OP_TUNNEL_OPEN`, `TUNNEL_ALPN`,
|
||||
`TUNNEL_OPEN_SCOPE` in params.rs — they are part of the wire surface,
|
||||
`TUNNEL_OPEN_SCOPE` in params.rs, which are part of the wire surface,
|
||||
not producer-only, so they live in the shared module per the module
|
||||
map's "params.rs … + open-op input schema").
|
||||
- `establishment_reason` is a thin delegation to
|
||||
`ChannelOpenError::establishment_reason` (alkcall 0.7.0 has the
|
||||
method upstream); the crate-level helper keeps the POC's free-fn
|
||||
shape for consumers and re-exports the typed error.
|
||||
- `MAX_DATAGRAM_LEN` (65535) placed in wire.rs — the one codec constant
|
||||
- `MAX_DATAGRAM_LEN` (65535) placed in wire.rs, the one codec constant
|
||||
ADR-003 pins regardless of the codec functions landing later.
|
||||
- **`async-trait` dropped** per the task's IF: the skeleton confirms no
|
||||
trait is needed (ADR-004 — halves functions, not traits);
|
||||
trait is needed (ADR-004 calls for halves functions, not traits);
|
||||
`cargo check` passes without it.
|
||||
|
||||
## Summary
|
||||
@@ -108,5 +108,5 @@ docs + re-exports); Cargo.toml unchanged except `async-trait` dropped
|
||||
(ADR-004 confirmation). Verified: `cargo check`, `cargo clippy
|
||||
--all-targets -- -D warnings`, `cargo fmt --check`, `cargo test`,
|
||||
`cargo check --target wasm32-unknown-unknown`,
|
||||
`cargo clippy --target wasm32-unknown-unknown -- -D warnings` — all
|
||||
clean.
|
||||
`cargo clippy --target wasm32-unknown-unknown -- -D warnings`:
|
||||
all clean.
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
id: tunnels/decide-take-framed-halves
|
||||
name: "N-3 decision — take_halves on datagram sessions hands RAW FRAMED bytes; decide the API posture before the first external consumer"
|
||||
name: "N-3 decision: take_halves on datagram sessions hands RAW FRAMED bytes; decide the API posture before the first external consumer"
|
||||
status: completed
|
||||
depends_on: [tunnels/review-impl]
|
||||
scope: single
|
||||
@@ -16,7 +16,7 @@ completed: 2026-09-08
|
||||
Review 001 finding N-3
|
||||
(`docs/reviews/001-implementation-review.md`): a datagram session
|
||||
exposes `send_datagram`/`recv_datagram` (codec applied) until
|
||||
`take_halves` — then the caller owns RAW halves whose wire bytes carry
|
||||
`take_halves`; then the caller owns RAW halves whose wire bytes carry
|
||||
the codec framing (`[len: u16 BE][payload]`, documented on
|
||||
`take_halves`). The doc covers it, but it is a footgun asymmetry: the
|
||||
same session flips from "codec applied" to "codec is yours" depending
|
||||
@@ -36,23 +36,23 @@ before then. The options on the table:
|
||||
3. **Re-shape `take_halves` for datagram sessions** (breaking, before
|
||||
the window closes): e.g. a datagram session's `take_halves` returns
|
||||
framed-preserving halves by default, raw access via a distinct
|
||||
explicit method — removes the footgun entirely, at the cost of
|
||||
explicit method, which removes the footgun entirely at the cost of
|
||||
diverging `take_halves` semantics per substrate.
|
||||
|
||||
The decision should weigh: the pump_against shape (a UDP session
|
||||
pumped against an accepted local half needs the FRAMED adapter on the
|
||||
accepted side — the same framing-responsibility seam N-11's doc note
|
||||
accepted side (the same framing-responsibility seam N-11's doc note
|
||||
covers), what an actual consumer (alkcall's assembly layer, a
|
||||
DNS-proxy shape) reaches for first, and whether a `DatagramReader` +
|
||||
write-frame wrapper pair is small enough to just ship (option 2 is a
|
||||
thin compose of existing public pieces — `src/wire.rs` already
|
||||
thin compose of existing public pieces (`src/wire.rs` already
|
||||
exposes both codec halves of the job).
|
||||
|
||||
## Work
|
||||
|
||||
- Re-read review 001 §N-3 + ADR-005 (the API-stability window);
|
||||
confirm no external consumer has pinned the `take_halves` import
|
||||
style yet (if one has, the window is closed — record that and
|
||||
style yet. If one has, the window is closed; record that and
|
||||
choose the additive path (2) or docs-only (1)).
|
||||
- Write the decision as an ADR amendment (ADR-005) or a new ADR if
|
||||
the posture adds a public method; file the OQ only if the decision
|
||||
@@ -75,20 +75,20 @@ exposes both codec halves of the job).
|
||||
|
||||
## Resolution (2026-09-08)
|
||||
|
||||
**Option 1 — keep as-is, docs carry it.** Recorded as ADR-005's
|
||||
**Option 1: keep as-is, docs carry it.** Recorded as ADR-005's
|
||||
§Amendment (2026-09-08, N-3). Decision inputs:
|
||||
|
||||
- **No external consumer exists** (workspace grep verified 2026-09-08)
|
||||
— the window is open, so the breaking reshape (option 3) was
|
||||
so the window is open and the breaking reshape (option 3) was
|
||||
available but nothing demonstrates the need.
|
||||
- One `take_halves` semantic across substrates (the tunnel's wire
|
||||
bytes, uninterpreted) beats per-substrate divergence; the framing
|
||||
asymmetry is ADR-003's spec, not drift.
|
||||
- The `pump_against` seam composes the framed ADAPTER (`UdpHalf`,
|
||||
ADR-003's boundary codec — landed with the U-1 remediation the same
|
||||
ADR-003's boundary codec, which landed with the U-1 remediation the same
|
||||
day), not framed halves; option 2 would not feed that use.
|
||||
- Option 2 (`take_framed_halves`) is additive and can land after the
|
||||
first consumer defines its shape without breaking anything —
|
||||
first consumer defines its shape without breaking anything, so
|
||||
deferring costs nothing. Design note recorded in the ADR: an
|
||||
`AsyncRead`-faced wrapper re-collides with F-2's
|
||||
empty-datagram/EOF ambiguity; the codec-side framing wants
|
||||
|
||||
+35
-35
@@ -1,6 +1,6 @@
|
||||
---
|
||||
id: tunnels/direct-op
|
||||
name: Direct op — params, spec, establisher, consumer open_direct (ADR-007)
|
||||
name: Direct op, params, spec, establisher, consumer open_direct (ADR-007)
|
||||
status: completed
|
||||
depends_on: []
|
||||
scope: broad
|
||||
@@ -16,12 +16,12 @@ Implement the direct op (`channels/tunnel/direct`, ADR-007) end to
|
||||
end: the params type + spec builder (`src/params.rs`), the establisher
|
||||
+ registration (`src/producer.rs`), and the consumer constructor
|
||||
(`src/consumer.rs`). The op rides the same `alk/tunnel` ALPN (ADR-002
|
||||
Amendment 1's flavor-form op ids — the upstream discovery derivation
|
||||
Amendment 1's flavor-form op ids; the upstream discovery derivation
|
||||
landed in alkcall 0.8.0, ADR-047 amendment 3). This is the ssh
|
||||
`direct-tcpip` / SOCKS5 CONNECT egress capability with structural ACL
|
||||
separation from `tunnel:open`.
|
||||
|
||||
### params.rs — types + spec
|
||||
### params.rs: types + spec
|
||||
|
||||
```rust
|
||||
pub const OP_TUNNEL_DIRECT: &str = "channels/tunnel/direct";
|
||||
@@ -29,7 +29,7 @@ pub const TUNNEL_DIRECT_SCOPE: &str = "tunnel:direct";
|
||||
|
||||
/// The substrate-shaped address object (ADR-007 §2): keyed by the
|
||||
/// sibling `substrate` field of the params. Also the `peer` value
|
||||
/// shape of the forwarded op (ADR-008 §3) — define once here.
|
||||
/// shape of the forwarded op (ADR-008 §3); define once here.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum SubstrateAddr {
|
||||
@@ -50,10 +50,10 @@ pub fn tunnel_direct_spec() -> OperationSpec
|
||||
|
||||
- Spec per wire.md §The Direct Op: `Sub`-typed, `Visibility::External`,
|
||||
`ChannelOpenSpec::new(TUNNEL_ALPN)`, scope `["tunnel:direct"]`
|
||||
(**never implied by `tunnel:open`** — ADR-006 Amendment 1), output
|
||||
(**never implied by `tunnel:open`**, ADR-006 Amendment 1), output
|
||||
schema `{channel_id: integer > 0}`.
|
||||
- `input_schema` pins both fields required and the target sub-schema
|
||||
per-substrate — a `oneOf` on the sibling `substrate` value
|
||||
per-substrate, as a `oneOf` on the sibling `substrate` value
|
||||
(`tcp`/`udp` → `{host, port}`, `unix` → `{path}`), so malformed
|
||||
targets fail the registry's schema validation (the typed
|
||||
`INVALID_INPUT` class) **before** the establisher runs. The
|
||||
@@ -62,9 +62,9 @@ pub fn tunnel_direct_spec() -> OperationSpec
|
||||
- `deny_unknown_fields` on both params types (the loud-rejection
|
||||
posture, ADR-001's precedent).
|
||||
- The `Substrate` enum is reused as the discriminator (no new
|
||||
substrate values — ADR-001's additive rule).
|
||||
substrate values (ADR-001's additive rule).
|
||||
|
||||
### producer.rs — the establisher + registration
|
||||
### producer.rs: the establisher + registration
|
||||
|
||||
```rust
|
||||
pub fn direct_establisher(dial: DialFn) -> OpenEstablisher
|
||||
@@ -82,28 +82,28 @@ pub fn register_tunnel_direct_openable(
|
||||
`TunnelDirectParams`, renders the target to the same backing-string
|
||||
form the registry backings use (`"host:port"` for tcp/udp, the path
|
||||
for unix), and calls the injected `DialFn(substrate, rendered)`.
|
||||
No second dial type — a producer that registered `dial_tcp` for the
|
||||
No second dial type: a producer that registered `dial_tcp` for the
|
||||
base op registers the direct op with zero new substrate code
|
||||
(ADR-007's `local` interaction note). The UDP framed-adapter wrap
|
||||
(ADR-003) happens inside the dial closure (the `local` feature's
|
||||
`connect_udp` already does this) — the establisher stays
|
||||
`connect_udp` already does this), so the establisher stays
|
||||
substrate-agnostic.
|
||||
- **No registry lookup** — `unknown_resource` can never fire
|
||||
- **No registry lookup**: `unknown_resource` can never fire
|
||||
(ADR-007 §2). Error mapping (ADR-007 §4's table minus the registry
|
||||
row): the serde-parse failure of a schema-valid-shaped target maps
|
||||
`HandlerError` (the schema gate should have caught it — the parse
|
||||
`HandlerError` (the schema gate should have caught it; the parse
|
||||
is the backstop); dial failures pass through the `DialFn`'s own
|
||||
typed errors unchanged (`dial_failed` / `resource_shortage` /
|
||||
`handler_error`).
|
||||
- The per-call `auth` carries the opener identity (CF-006) — the
|
||||
- The per-call `auth` carries the opener identity (CF-006); the
|
||||
witness seam records it, same as the base op (the direct op is the
|
||||
arbitrary-egress capability; identity visibility here matters more,
|
||||
not less).
|
||||
- Registration uses the SAME pump handler (`make_tunnel_pump_handler`
|
||||
— the direct op's plan payload is a `TargetHandle` exactly like the
|
||||
base op's; plan-flow is unchanged).
|
||||
(the direct op's plan payload is a `TargetHandle` exactly like the
|
||||
base op's, and plan-flow is unchanged).
|
||||
|
||||
### consumer.rs — `open_direct`
|
||||
### consumer.rs: `open_direct`
|
||||
|
||||
```rust
|
||||
pub async fn open_direct(
|
||||
@@ -116,27 +116,27 @@ pub async fn open_direct(
|
||||
- A separate constructor, not a params enum (ADR-007 §5: two scopes
|
||||
are two capabilities; the caller states which one it holds).
|
||||
Serializes `{substrate, target}`, `open_channel(OP_TUNNEL_DIRECT, …,
|
||||
TUNNEL_ALPN)`, `from_halves` — identical session type, data planes,
|
||||
and teardown API as `open`.
|
||||
TUNNEL_ALPN)`, `from_halves`, with an identical session type, data
|
||||
planes, and teardown API as `open`.
|
||||
- Uses plain `open_channel` (no reply fields expected on the direct
|
||||
op — dial establishers never bind; wire.md §The Direct Op).
|
||||
op, because dial establishers never bind; wire.md §The Direct Op).
|
||||
- Documented error surface: the base open errors minus
|
||||
`unknown_resource`; malformed targets are the schema/
|
||||
`invalid_input`-class failure (registry-side); an old producer
|
||||
rejects the op loudly (`NOT_FOUND` — the SSH posture).
|
||||
rejects the op loudly (`NOT_FOUND`, the SSH posture).
|
||||
|
||||
### Tests
|
||||
|
||||
Unit (params.rs): `TunnelDirectParams` round-trip per substrate;
|
||||
untagged `SubstrateAddr` disambiguation (`{host, port}` vs `{path}`);
|
||||
`deny_unknown_fields` rejection; spec-shape conformance to wire.md §The
|
||||
Direct Op (op id, scope, oneOf schema structure — structural
|
||||
Direct Op (op id, scope, oneOf schema structure; structural
|
||||
assertions, no jsonschema dep here, per `tunnels/params`'s precedent).
|
||||
|
||||
Integration (extend the duplex harness from `tunnels/producer-open-op`):
|
||||
- `open_direct` e2e round-trip through `pump_bidi` (tcp duplex target).
|
||||
- `open_direct` udp e2e (the framed adapter wraps at the dial closure —
|
||||
datagram boundary preserved).
|
||||
- `open_direct` udp e2e (the framed adapter wraps at the dial closure, so the datagram
|
||||
boundary is preserved).
|
||||
- Malformed target (unix without `path`) → schema-class failure, no
|
||||
phantom session.
|
||||
- Producer without the direct op registered → `NOT_FOUND`-class.
|
||||
@@ -175,7 +175,7 @@ Integration (extend the duplex harness from `tunnels/producer-open-op`):
|
||||
- The `input_schema` pins the per-substrate target sub-schema as two
|
||||
`if`/`then` branches under `allOf`, keyed on the sibling
|
||||
`substrate` value (tcp/udp → `{host, port}` with
|
||||
`additionalProperties: false`; unix → `{path}`, same posture) —
|
||||
`additionalProperties: false`; unix → `{path}`, same posture);
|
||||
JSON Schema `if/then` composes through alkcall's jsonschema 0.46
|
||||
compile, and the integration tests prove the gate fires
|
||||
(`INVALID_INPUT` before the establisher). Structural assertions
|
||||
@@ -189,11 +189,11 @@ Integration (extend the duplex harness from `tunnels/producer-open-op`):
|
||||
`direct_establisher` maps `TunnelEstablishError::HandlerError`).
|
||||
This asymmetry is documented in both the type docs and the test.
|
||||
- `TunnelDirectParams::render_target()` centralizes the backing-string
|
||||
form (`"host:port"` / path) — the establisher injects the SAME
|
||||
form (`"host:port"` / path); the establisher injects the SAME
|
||||
`DialFn` with the rendered string; the render is pinned by test to
|
||||
the registry-backing form.
|
||||
- `TunnelSession::open_direct` uses plain `open_channel` (no reply
|
||||
fields — dial establishers never bind); `SubstrateAddr` re-exports
|
||||
fields, since dial establishers never bind); `SubstrateAddr` re-exports
|
||||
as the future forwarded-op `peer` shape (ADR-008 §3, defined once).
|
||||
- Harness: `RegistrationMode::Direct`, `wire_direct` /
|
||||
`wire_direct_with_identity` (the raw-call topology),
|
||||
@@ -206,7 +206,7 @@ Integration (extend the duplex harness from `tunnels/producer-open-op`):
|
||||
|
||||
Implemented the direct op end to end per ADR-007:
|
||||
|
||||
- **params.rs** — `OP_TUNNEL_DIRECT` / `TUNNEL_DIRECT_SCOPE`
|
||||
- **params.rs**: `OP_TUNNEL_DIRECT` / `TUNNEL_DIRECT_SCOPE`
|
||||
constants; `SubstrateAddr` (untagged `{host, port}` | `{path}`,
|
||||
the ADR-008 §3 `peer` shape too); `TunnelDirectParams`
|
||||
(`deny_unknown_fields`, `render_target()`); `tunnel_direct_spec()`
|
||||
@@ -215,27 +215,27 @@ Implemented the direct op end to end per ADR-007:
|
||||
per-substrate `if/then` target sub-schema). 7 unit tests: round-
|
||||
trips per substrate, untagged disambiguation, loud unknown-field
|
||||
rejection, spec-shape conformance, schema structure.
|
||||
- **producer.rs** — `direct_establisher` /
|
||||
- **producer.rs**: `direct_establisher` /
|
||||
`direct_establisher_with_witness` (parse → render → injected
|
||||
`DialFn`; no registry lookup; typed-error mapping per ADR-007 §4
|
||||
minus the registry row; CF-006 witness seam) +
|
||||
`register_tunnel_direct_openable` (direct spec + establisher + the
|
||||
SAME `make_tunnel_pump_handler`, plan-flow unchanged).
|
||||
- **consumer.rs** — `TunnelSession::open_direct(client, target,
|
||||
- **consumer.rs**: `TunnelSession::open_direct(client, target,
|
||||
substrate)`: separate constructor, serializes `{substrate, target}`,
|
||||
plain `open_channel(OP_TUNNEL_DIRECT, …, TUNNEL_ALPN)`,
|
||||
`from_halves` — identical session/data-planes/teardown as `open`.
|
||||
- **lib.rs** — the new surface re-exported.
|
||||
`from_halves`, with identical session/data-planes/teardown as `open`.
|
||||
- **lib.rs**: the new surface re-exported.
|
||||
|
||||
Tests: `tests/producer_direct_op.rs` — 15 integration tests over the
|
||||
Tests: `tests/producer_direct_op.rs` has 15 integration tests over the
|
||||
duplex harness: tcp + udp e2e round-trips through `pump_bidi`
|
||||
(datagram boundary preserved via the framed dial closure), malformed
|
||||
targets (`unix` without `path`, `tcp` without `port`, missing target)
|
||||
→ `INVALID_INPUT` with no phantom channel/session, dial-failure
|
||||
pass-through (`dial_failed`), NOT_FOUND on a producer without the op,
|
||||
scope separation both ways (`tunnel:open` ↛ `tunnel:direct` and
|
||||
`tunnel:direct` ↛ `tunnel:open` — FORBIDDEN, raw-call AND session-
|
||||
level), CF-006 witness on the direct establisher (raw + session
|
||||
`tunnel:direct` ↛ `tunnel:open`, all FORBIDDEN (raw-call AND
|
||||
session-level), CF-006 witness on the direct establisher (raw + session
|
||||
paths), the establisher parse backstop (`handler_error`), and the
|
||||
target-rendering pin (the dial closure observes the exact
|
||||
backing-string form). Harness: `Direct` registration mode +
|
||||
@@ -243,6 +243,6 @@ backing-string form). Harness: `Direct` registration mode +
|
||||
|
||||
Verified: `cargo test` green (93 native; 94 with `--all-features`),
|
||||
clippy `-D warnings` clean (native + wasm32), fmt clean, wasm32
|
||||
check passes (default crate stays wasm-clean — no substrate code
|
||||
check passes (default crate stays wasm-clean; no substrate code was
|
||||
added), `cargo doc --no-deps` clean. CHANGELOG updated (Unreleased →
|
||||
Added).
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
id: tunnels/end-to-end-suite
|
||||
name: End-to-end suite — forward, reverse, listen, datagram, teardown matrix
|
||||
name: End-to-end suite: forward, reverse, listen, datagram, teardown matrix
|
||||
status: completed
|
||||
depends_on: [tunnels/consumer-session, tunnels/producer-listen, tunnels/local-socket-halves]
|
||||
scope: broad
|
||||
@@ -15,7 +15,7 @@ tags: [tests, integration, phase-gate]
|
||||
Consolidate the integration coverage into `tests/` as the crate's
|
||||
end-to-end suite: every validated POC behavior, re-expressed against the
|
||||
crate's public API (not the POCs' internals). This is the quality gate
|
||||
before the implementation review — the suite is the spec's executable
|
||||
before the implementation review; the suite is the spec's executable
|
||||
form.
|
||||
|
||||
### Suites
|
||||
@@ -24,8 +24,8 @@ form.
|
||||
`pump_bidi` → round-trip; 1 MiB backpressure; typed errors
|
||||
(unknown_resource / dial_failed / FORBIDDEN / timeout-adjacent);
|
||||
per-call opener identity witness (CF-006).
|
||||
2. **Reverse (`-R`)**: hub initiator — `open_reverse_channel` +
|
||||
`adopt` + `pump_against`; odd-ID allocation asserted (ADR-047 §5);
|
||||
2. **Reverse (`-R`)**: hub initiator (`open_reverse_channel` +
|
||||
`adopt` + `pump_against`); odd-ID allocation asserted (ADR-047 §5);
|
||||
identity precedence chain (transport alone / `ServingConfig.
|
||||
identity` override / token override / identity-less fail-closed);
|
||||
half-close (W4); out-of-band `channel/close` + self-reaping (W3);
|
||||
@@ -33,16 +33,17 @@ form.
|
||||
3. **Listen producer**: the accept-queue flow end-to-end + its typed
|
||||
errors (from `tunnels/producer-listen`).
|
||||
4. **Datagram (`udp`)**: forward + reverse datagram sessions via the
|
||||
codec — round-trip, empty datagram (the F-2 layering), chunk-split
|
||||
codec: round-trip, empty datagram (the F-2 layering), chunk-split
|
||||
survival, TCP+UDP concurrent channels on one connection.
|
||||
5. **Teardown matrix** (ADR-005): close / join (with + without pump) /
|
||||
Drop — `channel_ids()` asserts no leaks on either side after each.
|
||||
Drop, with `channel_ids()` asserting no leaks on either side after
|
||||
each.
|
||||
6. **`local` feature suite**: real sockets end-to-end (from
|
||||
`tunnels/local-socket-halves` — runs after that task lands; the
|
||||
`tunnels/local-socket-halves`; runs after that task lands, though the
|
||||
duplex-transport suites above do not need it).
|
||||
|
||||
Transport stand-in: `tokio::io::duplex` (both POCs' harness; the
|
||||
alkcall layer owns real transports — unchanged scope). The suite may
|
||||
alkcall layer owns real transports; unchanged scope). The suite may
|
||||
share one harness module (`tests/common/`); the POC harnesses
|
||||
(`/workspace/alktunnels-udp-poc/src/harness.rs`,
|
||||
`/workspace/alktunnels-reverse-poc/src/harness.rs`) are the two
|
||||
@@ -50,18 +51,18 @@ topologies to unify.
|
||||
|
||||
### The spec-conformance assertions to keep visible
|
||||
|
||||
- A failed open never returns a `channel_id` (no phantom channel —
|
||||
- A failed open never returns a `channel_id` (no phantom channel;
|
||||
both sides' `channel_ids()` hold only 0 afterward).
|
||||
- A failed adopt never leaks the session (Drop reaps).
|
||||
- The pump handler's `JoinHandle` tracks the data plane (a test that
|
||||
simulates early-return would hang/EOF-instantly — the R-02 shape;
|
||||
assert the correct shape survives).
|
||||
simulates early-return would hang/EOF-instantly, which is the
|
||||
R-02 shape; assert the correct shape survives).
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] All 6 suites present; ≥20 integration tests total
|
||||
- [ ] `cargo test` green; `cargo test --features local` green
|
||||
- [ ] Repeat-run stable (3× clean — the POCs' flakiness bar)
|
||||
- [ ] Repeat-run stable (3× clean, the POCs' flakiness bar)
|
||||
- [ ] Clippy/fmt clean; wasm32 check passes
|
||||
|
||||
## References
|
||||
@@ -84,21 +85,21 @@ task consolidated the remaining POC-validated behaviors they didn't
|
||||
cover into `tests/end_to_end.rs` (18 tests, the suites as enumerated)
|
||||
and extended `tests/harness.rs`:
|
||||
|
||||
- `RegistrationMode::Timeout(Duration)` — a hanging establisher with
|
||||
- `RegistrationMode::Timeout(Duration)`: a hanging establisher with
|
||||
an explicit per-registration establishment timeout (the ADR-049 §2
|
||||
deadline-expiry probe; the wrapper maps expiry to reason `timeout`
|
||||
with no channel surviving).
|
||||
- `wire_serving_identity` — the `ServingConfig.identity` override
|
||||
- `wire_serving_identity`, the `ServingConfig.identity` override
|
||||
probe shape (CF-005 (a)).
|
||||
|
||||
Gaps the suite filled (behaviors the per-task suites did not cover):
|
||||
1 MiB backpressure round-trip (both POCs' sizing probe); the
|
||||
deadline→`timeout` wire mapping; odd-ID allocation asserts (ADR-047 §5)
|
||||
plus distinctness; the FULL identity precedence chain — the
|
||||
plus distinctness; the FULL identity precedence chain, including the
|
||||
`ServingConfig.identity` override and the token-over-transport
|
||||
precedence with a DISTINCT resolved id (the existing token test
|
||||
resolved to the same id as the transport identity, so it proved
|
||||
authorization but not precedence — the new `DistinctTokenProvider`
|
||||
authorization but not precedence. The new `DistinctTokenProvider`
|
||||
probe proves precedence); reverse datagram sessions via the codec
|
||||
(pump-less `(0, 0, reaped)` join shape); 40-datagram bursts with
|
||||
batching (the pending-queue cover, both directions); the 7-byte
|
||||
@@ -109,15 +110,15 @@ failed-adopt no-leak arm; R-02's JoinHandle-tracks-the-data-plane
|
||||
assertion (alive-and-pumpable long after the open, completion only at
|
||||
both-direction EOF).
|
||||
|
||||
### Test-side findings (not product bugs — recorded for the record)
|
||||
### Test-side findings (not product bugs; recorded for the record)
|
||||
|
||||
- **Parked ≠ adopted.** `teardown_channel` on a reverse-open'd but
|
||||
never-adopted channel id is `UnknownChannel` — the manager parks
|
||||
never-adopted channel id is `UnknownChannel`; the manager parks
|
||||
early arrivals for unseen ids (the adoption-race cover, ADR-047 §5)
|
||||
but does not install a channel entry until adopt. Cleanup for such
|
||||
ids is adopt-then-close (what the tests do). Worth knowing when an
|
||||
assembly layer errors between `open_reverse_channel` and `adopt`.
|
||||
- **`read(&mut Vec)` vs `read(buf)`** — an unpopulated `Vec` passed to
|
||||
- **`read(&mut Vec)` vs `read(buf)`**: an unpopulated `Vec` passed to
|
||||
`AsyncReadExt::read` has len 0 and reads nothing (instant EOF); the
|
||||
1 MiB loop needed a size-`expected` buffer with `&mut received[got..]`.
|
||||
- The empty-queue-vs-error posture from `tunnels/producer-listen`
|
||||
@@ -149,6 +150,6 @@ Existing suites untouched. Verification: `cargo test` green (68 tests
|
||||
incl. unit + doc), `cargo test --features local` green (75), 3×
|
||||
repeat-run clean (both feature configs), clippy `-D warnings` clean
|
||||
(all-targets, `--features local`, wasm32), `cargo fmt --check` clean,
|
||||
wasm32 check passes. No product-code changes were needed — the
|
||||
wasm32 check passes. No product-code changes were needed; the
|
||||
failures during development were all test-harness bugs (zero-len read
|
||||
buffer, teardown-on-parked-id), recorded above.
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
id: tunnels/fix-udp-framed-adapter
|
||||
name: "U-1 remediation — make UdpHalf the FRAMED adapter (ADR-003 at the local boundary)"
|
||||
name: "U-1 remediation: make UdpHalf the FRAMED adapter (ADR-003 at the local boundary)"
|
||||
status: completed
|
||||
depends_on: [tunnels/review-impl]
|
||||
scope: moderate
|
||||
@@ -15,17 +15,17 @@ completed: 2026-09-08
|
||||
|
||||
Review 001 finding U-1 (`docs/reviews/001-implementation-review.md`):
|
||||
the `local` feature's `UdpHalf` (`src/local/mod.rs`) is NOT the framed
|
||||
adapter ADR-003 mandates at the substrate boundary — it is
|
||||
adapter ADR-003 mandates at the substrate boundary: it is
|
||||
boundary-preserving (one datagram per poll_read/poll_write) but raw.
|
||||
The `[len: u16 BE]` codec exists only on the session side, so
|
||||
UDP-over-`local` tunnels:
|
||||
|
||||
1. deliver codec-framed bytes to real targets (the target sees
|
||||
`[len]payload`, not payload — executable-pinned in the review
|
||||
`[len]payload`, not payload; executable-pinned in the review
|
||||
probes);
|
||||
2. tear the tunnel down on an empty datagram (a raw zero-length
|
||||
datagram is `Ok(())` with 0 bytes filled = EOF-shaped to
|
||||
`tokio::io::copy` — the F-2 violation end-to-end; the session-side
|
||||
`tokio::io::copy`, the F-2 violation end-to-end; the session-side
|
||||
codec exists precisely to prevent this);
|
||||
3. split datagrams > ~8 KiB at the target (`tokio::io::copy`'s
|
||||
`DEFAULT_BUF_SIZE` bounds each read; the raw adapter sends each
|
||||
@@ -34,38 +34,38 @@ UDP-over-`local` tunnels:
|
||||
codec parses a raw datagram's first two bytes as a length header).
|
||||
|
||||
The session-side tests pass because the harness's
|
||||
`framed_udp_echo_dial` frames symmetrically at the test boundary — a
|
||||
`framed_udp_echo_dial` frames symmetrically at the test boundary, a
|
||||
correct-shape stand-in for what `connect_udp` itself must do.
|
||||
|
||||
### The fix
|
||||
|
||||
Apply ADR-003's placement: `UdpHalf` becomes the framed adapter at the
|
||||
substrate boundary — **as seen from the pump (the channel side)**:
|
||||
substrate boundary, **as seen from the pump (the channel side)**:
|
||||
|
||||
- `poll_write` is fed CHANNEL bytes (already `[len: u16 BE][payload]`
|
||||
frames from the session's codec) → **de-frame** (an incremental
|
||||
`DatagramReader` held on the write half) and send each decoded
|
||||
payload to the target as one raw datagram. `len=0` decodes to a
|
||||
zero-payload datagram — a real empty datagram, NOT EOF.
|
||||
zero-payload datagram, i.e. a real empty datagram, NOT EOF.
|
||||
- `poll_read` yields bytes destined for the CHANNEL (the session's
|
||||
codec decodes there) → **frame** (`frame_datagram`) each raw target
|
||||
datagram and hand the frame bytes to the pump.
|
||||
|
||||
Both halves of the association then speak the same framing on both
|
||||
faces; the target sees payload-only bytes, and an empty datagram
|
||||
crosses the pump as its 2-byte frame (unambiguous with EOF — the F-2
|
||||
crosses the pump as its 2-byte frame (unambiguous with EOF, the F-2
|
||||
fix end-to-end).
|
||||
|
||||
**Orientation caution** (the review's §U-1 sketch had this inverted
|
||||
and was corrected in re-validation — read the review's §Re-validation
|
||||
and was corrected in re-validation; read the review's §Re-validation
|
||||
before implementing): "frame on write, de-frame on read" describes
|
||||
the TARGET's perspective. `UdpHalf`'s caller is the pump; the channel
|
||||
side is the framed side. Getting the orientation wrong double-frames
|
||||
the channel→target direction and leaves unframed target→channel
|
||||
traffic undecodable — the U-1 defects survive the wrong fix.
|
||||
traffic undecodable; the U-1 defects survive the wrong fix.
|
||||
|
||||
The recv scratch (U-3 interplay): the de-frame path needs a FULL
|
||||
65535-byte recv (tokio's `try_recv` truncates silently — a smaller
|
||||
65535-byte recv (tokio's `try_recv` truncates silently, so a smaller
|
||||
scratch corrupts the framing before any size check runs). Any memory
|
||||
mitigation must keep the full-size read underneath; see U-3 below.
|
||||
|
||||
@@ -76,7 +76,7 @@ mitigation must keep the full-size read underneath; see U-3 below.
|
||||
(session `recv_datagram` yields `Some(b"")`, channel alive).
|
||||
2. A >8 KiB datagram round-trips as ONE datagram at the target.
|
||||
3. An unframed target-initiated datagram resolves at the session.
|
||||
4. The existing `local_halves` suite stays green — EXCEPT
|
||||
4. The existing `local_halves` suite stays green, EXCEPT
|
||||
`truncation_fails_loud_oq_tn_13`, whose assertion surface may
|
||||
legitimately change: once framing composes, a >caller-buffer frame
|
||||
is a partial-drain read (or an equivalent typed failure) rather
|
||||
@@ -89,31 +89,31 @@ mitigation must keep the full-size read underneath; see U-3 below.
|
||||
|
||||
### Riders (from the same review, same file)
|
||||
|
||||
- U-2 (bind posture, sharpened during review follow-up — read the
|
||||
- U-2 (bind posture, sharpened during review follow-up; read the
|
||||
whole bullet before implementing): the crate must NOT wildcard-bind
|
||||
implicitly. Two distinct cases:
|
||||
- `connect_udp` binds `("0.0.0.0", 0)` — this is a CLIENT socket
|
||||
- `connect_udp` binds `("0.0.0.0", 0)`: this is a CLIENT socket
|
||||
(bind-ephemeral → `connect(target)`; the kernel delivers only
|
||||
datagrams from the connected peer), so its internet-exposure
|
||||
profile is small — but the v4 hardcoded bind breaks v6-only
|
||||
profile is small, but the v4 hardcoded bind breaks v6-only
|
||||
targets at connect, and wildcard binds are a bad default
|
||||
regardless. Fix: resolve the target first, bind the UNSPECIFIED
|
||||
address of the MATCHING family (`0.0.0.0:0` for v4 targets,
|
||||
`[::]:0` for v6). Do NOT bind loopback (`127.0.0.1:0`) — that
|
||||
`[::]:0` for v6). Do NOT bind loopback (`127.0.0.1:0`); that
|
||||
would break every non-loopback target (a query to `8.8.8.8` can't
|
||||
route from a loopback-bound socket). No user-supplied bind address
|
||||
here unless a caller actually needs source-address selection —
|
||||
here unless a caller actually needs source-address selection;
|
||||
keep the signature unless that need exists.
|
||||
- `bind_tcp` (the listener half) already REQUIRES the caller to
|
||||
supply the address — that is the correct posture (the assembly
|
||||
supply the address; that is the correct posture (the assembly
|
||||
layer decides loopback vs a public interface). Keep it; add a doc
|
||||
warning that a wildcard bind (`0.0.0.0` / `::`) is the caller's
|
||||
explicit choice and exposes the listener per the host's firewall.
|
||||
- Note: `::1` is loopback (the v6 `127.0.0.1`), NOT a wildcard —
|
||||
- Note: `::1` is loopback (the v6 `127.0.0.1`), NOT a wildcard, as
|
||||
the risky v6 address is `::`. The rule is "no implicit wildcard
|
||||
binds", not "no v6 loopback".
|
||||
- U-3: the per-tunnel 65535-byte recv scratch is correctness-
|
||||
load-bearing ON THE DE-FRAME PATH — tokio's `try_recv` truncates
|
||||
load-bearing ON THE DE-FRAME PATH, because tokio's `try_recv` truncates
|
||||
SILENTLY, so a scratch smaller than the largest in-flight datagram
|
||||
would corrupt the framing before any size check could reject it
|
||||
(a truncated frame decodes as a shorter declared length,
|
||||
@@ -139,19 +139,19 @@ All acceptance criteria met. Key implementation notes beyond the
|
||||
sketch (pinned in ADR-003's §Amendment):
|
||||
|
||||
- **Orientation** (the caution held): de-frame on write, frame on
|
||||
read — the channel side is the framed side.
|
||||
read; the channel side is the framed side.
|
||||
- **The write half accepts ALL offered bytes** and queues completed
|
||||
datagrams for the socket: tokio's `copy` re-offers a FULL window
|
||||
unsatisfied until drained (its `read_done` path never tops up), so
|
||||
the "hold the partial tail, re-feed" shape deadlocked gate 2 (the
|
||||
9000-byte test) and `Ready(Ok(0))` on a partial-only feed tripped
|
||||
the copy's `WriteZero` abrupt-close. The `DatagramReader`'s
|
||||
partial-frame state is bounded by construction (u16 cap — review
|
||||
partial-frame state is bounded by construction (u16 cap, per review
|
||||
001 N-2); backpressure is an accepted-byte high-water mark
|
||||
(128 KiB), and the socket drain pumps on write/flush/shutdown
|
||||
(copy polls flush after every window drain).
|
||||
- **A >caller-buffer frame stages across reads** (length prefix
|
||||
first, remainder on the next read) — gate 4's predicted
|
||||
first, remainder on the next read), which is gate 4's predicted
|
||||
assertion-surface change. The fail-loud probe is now the 65507-byte
|
||||
max datagram reassembling through 16 KiB reads
|
||||
(`truncation_fails_loud_oq_tn_13`, rewritten) plus the direct
|
||||
@@ -161,7 +161,7 @@ sketch (pinned in ADR-003's §Amendment):
|
||||
wildcard-exposure doc warning. Pinned by a real `::1` round-trip
|
||||
(`connect_udp_binds_the_matching_family_v6_targets_dial`).
|
||||
- **U-3**: the 65535 scratch allocates lazily on first recv (the
|
||||
full-size read kept — the MTU-shrink idea confirmed unsafe).
|
||||
full-size read kept; the MTU-shrink idea confirmed unsafe).
|
||||
|
||||
Verification: 63 default / 76 `local` tests green ×3 repeat runs;
|
||||
clippy `-D warnings` (all-targets + wasm32), fmt, doc, publish
|
||||
@@ -170,7 +170,7 @@ dry-run, wasm check all clean.
|
||||
## References
|
||||
|
||||
- `docs/reviews/001-implementation-review.md` §U-1 (executable-pinned
|
||||
probe evidence), §U-2, §U-3, §N-1 — and §Re-validation (2026-09-08:
|
||||
probe evidence), §U-2, §U-3, §N-1, and §Re-validation (2026-09-08:
|
||||
findings re-confirmed by fresh probes; the fix orientation
|
||||
corrected; gates 4 and U-3 adjusted as above)
|
||||
- `docs/architecture/decisions/003-codec-and-udp-framing.md` (the
|
||||
|
||||
@@ -13,7 +13,7 @@ tags: [opener, forwarded-op, serving, adopt, correlation]
|
||||
## Description
|
||||
|
||||
Implement the forwarded op's SERVING side (the listen-opener half,
|
||||
ADR-008 §3): `register_tunnel_forwarded_openable` — the
|
||||
ADR-008 §3): `register_tunnel_forwarded_openable`, the
|
||||
`register_openable` shape (no establisher; the data plane starts
|
||||
immediately), scope-gated `tunnel:forwarded`, whose handler parses
|
||||
`TunnelForwardedParams`, hands the correlation material to the
|
||||
@@ -46,38 +46,40 @@ pub fn register_tunnel_forwarded_openable(
|
||||
) -> Result<(), String>
|
||||
```
|
||||
|
||||
- **No establisher** (`register_openable` — the forwarded channel
|
||||
needs no establishment; its data plane starts immediately — ADR-008
|
||||
- **No establisher** (`register_openable`; the forwarded channel
|
||||
needs no establishment, since its data plane starts immediately,
|
||||
ADR-008
|
||||
§3). The wrapper's `channel_id` allocation IS the reply (the
|
||||
producer adopts it).
|
||||
- The built-in handler shape: parse
|
||||
`TunnelForwardedParams` (`deny_unknown_fields`; a malformed open →
|
||||
the handler logs + returns early — teardown-at-birth is correct for
|
||||
the handler logs + returns early, because teardown-at-birth is
|
||||
correct for
|
||||
a malformed ingress), accept the `BiStream`, invoke the injected
|
||||
assembly handler with the parsed params + the stream. The assembly
|
||||
correlates (via `listen`), adopts nothing (the WRAPPER owns this
|
||||
channel — the serving side allocated it; the assembly pumps the
|
||||
channel: the serving side allocated it; the assembly pumps the
|
||||
BiStream against its local halves via `pump_bidi`, and the wrapper's
|
||||
R-02 tracking reaps on handler exit).
|
||||
- **Pump responsibility split (the honest-shape decision to make and
|
||||
document):** the injected `ForwardedHandler` receives the channel
|
||||
`BiStream` and the params — the ASSEMBLY owns the pump
|
||||
`BiStream` and the params; the ASSEMBLY owns the pump
|
||||
(`pump_bidi(forwarded_bidi, local_read, local_write)`), because the
|
||||
local halves are assembly state (the SOCKS5 BIND client's socket).
|
||||
The protocol-side built-in handler only bridges wrapper→assembly.
|
||||
Await the injected handler's JoinHandle inline (R-02 — the
|
||||
Await the injected handler's JoinHandle inline (R-02: the
|
||||
forwarded channel's lifetime tracks it).
|
||||
- Identity: the opener-side gate sees the PRODUCER's per-call
|
||||
identity (CF-005 precedence — the producer may carry `auth_token`
|
||||
identity (CF-005 precedence: the producer may carry `auth_token`
|
||||
when it is itself forwarding); the `peer` param is
|
||||
producer-asserted metadata, not transport truth (producer.md's
|
||||
posture — ACL trusts the grant, app treats `peer` as metadata).
|
||||
posture: ACL trusts the grant, app treats `peer` as metadata).
|
||||
The registration's `auth` + the per-call overlay handle this
|
||||
automatically (upstream machinery); nothing to implement, but the
|
||||
test suite pins it.
|
||||
- The `listen`-token correlation failure (no matching session) is the
|
||||
ASSEMBLY's rejection (it holds the map): the injected handler's
|
||||
error path — drop the stream, return (teardown-at-birth reaps the
|
||||
error path drops the stream and returns (teardown-at-birth reaps the
|
||||
channel). Document that a correlation miss tears the forwarded
|
||||
channel down; the producer's accept loop drops the accepted
|
||||
connection on the open's failure (the shapes mirror).
|
||||
@@ -86,7 +88,7 @@ pub fn register_tunnel_forwarded_openable(
|
||||
|
||||
```rust
|
||||
/// Extract the bind-first reply fields from an open-op success reply
|
||||
/// (the full output Value — `channel_id` + optional `bound`/`listen`).
|
||||
/// (the full output Value: `channel_id` + optional `bound`/`listen`).
|
||||
pub fn listen_reply_fields(reply: &Value) -> TunnelListenReply
|
||||
```
|
||||
|
||||
@@ -109,7 +111,7 @@ pub fn listen_reply_fields(reply: &Value) -> TunnelListenReply
|
||||
handler logs + early return → the channel tears down (no phantom).
|
||||
- The reply reader: full/empty/extra-field replies parse per the
|
||||
additive posture (unit).
|
||||
- Old-opener posture is upstream's registry lookup (`NOT_FOUND`) —
|
||||
- Old-opener posture is upstream's registry lookup (`NOT_FOUND`);
|
||||
pin it in the producer-side test in `tunnels/bindfirst-producer`
|
||||
(cross-referenced, not duplicated here).
|
||||
|
||||
@@ -129,21 +131,21 @@ pub fn listen_reply_fields(reply: &Value) -> TunnelListenReply
|
||||
## References
|
||||
|
||||
- docs/architecture/decisions/008-listen-metadata-forwarded-op.md
|
||||
(§3 the forwarded op — the opener-side serving shape; Amendment 1)
|
||||
(§3 the forwarded op, the opener-side serving shape; Amendment 1)
|
||||
- docs/architecture/producer.md §The Forwarded Op; consumer.md §The
|
||||
Listen Flow; wire.md §The Forwarded Op
|
||||
- docs/architecture/decisions/006-access-control-posture.md Amendment 1
|
||||
- tasks/tunnels/bindfirst-params.md (the types);
|
||||
tasks/tunnels/bindfirst-producer.md (the caller half — the tests
|
||||
tasks/tunnels/bindfirst-producer.md (the caller half; the tests
|
||||
compose)
|
||||
|
||||
## Notes
|
||||
|
||||
- **`ForwardedHandler` (the injected assembly handler):** `Arc<dyn
|
||||
Fn(TunnelForwardedParams, BiStream) -> tokio::task::JoinHandle<()> +
|
||||
Send + Sync>` — the correlation material the opener-side handler
|
||||
Send + Sync>`, the correlation material the opener-side handler
|
||||
yields per forwarded open. The assembly owns the pump (the honest
|
||||
split: the local halves — the SOCKS5 BIND client's socket — are
|
||||
split: the local halves, the SOCKS5 BIND client's socket, are
|
||||
assembly state), correlating via the `listen` token against its own
|
||||
waiting-listener map; the protocol provides the op + params, not the
|
||||
wait state machine (ADR-008 §3's posture, realized).
|
||||
@@ -151,26 +153,27 @@ pub fn listen_reply_fields(reply: &Value) -> TunnelListenReply
|
||||
`TunnelForwardedParams` (`deny_unknown_fields` + the landed
|
||||
positive-`listen` serde pin), accepts the channel `BiStream`
|
||||
(yield-once; accepting BEFORE the assembly handler runs keeps the
|
||||
R-02 accepted-flag telemetry truthful — a parse-gated early return
|
||||
R-02 accepted-flag telemetry truthful: a parse-gated early return
|
||||
never accepted, which the wrapper's birth-teardown hint reports),
|
||||
then awaits the injected handler's `JoinHandle` inline (R-02 — the
|
||||
forwarded channel's lifetime tracks it; the wrapper's tracking reaps
|
||||
then awaits the injected handler's `JoinHandle` inline (R-02, since
|
||||
the forwarded channel's lifetime tracks it; the wrapper's tracking
|
||||
reaps
|
||||
on handler exit).
|
||||
- **No establisher** — `register_openable` exactly (the forwarded
|
||||
channel needs no establishment; the data plane starts immediately —
|
||||
- **No establisher**, `register_openable` exactly (the forwarded
|
||||
channel needs no establishment; the data plane starts immediately,
|
||||
ADR-008 §3). The wrapper's `channel_id` allocation IS the reply (the
|
||||
producer adopts it) — nothing to implement, upstream machinery.
|
||||
producer adopts it): nothing to implement, upstream machinery.
|
||||
- **Identity posture (upstream machinery, test-pinned):** the
|
||||
opener-side gate sees the PRODUCER's per-call identity (CF-005
|
||||
precedence — payload token > `ServingConfig.identity` >
|
||||
precedence: payload token > `ServingConfig.identity` >
|
||||
transport); the `peer` param is producer-asserted metadata, not
|
||||
transport truth. Nothing implemented here; the FORBIDDEN probe
|
||||
pins the gate runs (identity without the grant → FORBIDDEN with the
|
||||
missing scope named, never implied by `tunnel:open` — probed with a
|
||||
missing scope named, never implied by `tunnel:open`; probed with a
|
||||
`tunnel:open`-only identity).
|
||||
- **Correlation-miss semantics (documented + test-pinned):** the
|
||||
`listen`-token miss is the ASSEMBLY's rejection (it holds the map):
|
||||
the injected handler's error path drops the stream and returns —
|
||||
the injected handler's error path drops the stream and returns;
|
||||
teardown-at-birth reaps the forwarded channel. The producer's accept
|
||||
loop drops the accepted connection on the open's failure (the shapes
|
||||
mirror; pinned producer-side in `tunnels/bindfirst-producer`'s
|
||||
@@ -178,31 +181,32 @@ pub fn listen_reply_fields(reply: &Value) -> TunnelListenReply
|
||||
- **Reply reader lands in `src/params.rs`** as `listen_reply_fields`
|
||||
(the lenient, infallible session-surface projection) DELEGATING to
|
||||
the landed `parse_listen_reply` (the loud typed viewer): unknown
|
||||
extra reply fields tolerated (the additive posture — no deny-unknown
|
||||
extra reply fields tolerated (the additive posture; no deny-unknown
|
||||
gate on the reply), a malformed shape logs `tracing::error!` loud
|
||||
and surfaces both-`None` (the session presents `Option` fields and
|
||||
never refuses construction over an informational field; use
|
||||
`parse_listen_reply` directly when the loud typed distinction
|
||||
matters). Both `Option` — absent for dial/accept-first producers;
|
||||
matters). Both `Option`, absent for dial/accept-first producers;
|
||||
the projection is unit-testable independent of the session type
|
||||
(this task's placement rationale), consumed by `TunnelListener`
|
||||
(`tunnels/tunnel-listener`, pending).
|
||||
- **Test-data-plane shape (the harness):** the e2e drives the
|
||||
forwarded channel through BOTH pumps — the opener-side assembly
|
||||
forwarded channel through BOTH pumps: the opener-side assembly
|
||||
pump pops correlated duplex halves for the token (pushed BEFORE the
|
||||
forwarded open — the waiting-session posture; a late push is the
|
||||
miss shape, probed separately) and the producer-side test adopts the
|
||||
opener-allocated ID and pumps it against a driving echo duplex —
|
||||
then round-trips bytes from the client end through the whole path
|
||||
forwarded open, the waiting-session posture; a late push is the
|
||||
miss shape, probed separately), and the producer-side test adopts
|
||||
the opener-allocated ID and pumps it against a driving echo
|
||||
duplex, then round-trips bytes from the client end through the
|
||||
whole path
|
||||
(assembly pump → forwarded channel → adopted pump → driving echo →
|
||||
back). The malformed-params probes pin the no-phantom property at
|
||||
both gates: `listen: 0` / missing `peer` fail the registry's schema
|
||||
(`INVALID_INPUT`, no channel ever); an unknown top-level field
|
||||
passes the schema (additive posture) but fails the serde parse →
|
||||
passes the schema (additive posture) but fails the serde parse, so
|
||||
the channel (whose ID the reply already carried) reaps promptly.
|
||||
- **Harness note:** the suite reuses the existing harness's
|
||||
`wire_with` + `Topology.consumer_registry` (the
|
||||
`bindfirst-producer` shape) — no harness changes needed; the
|
||||
`bindfirst-producer` shape), so no harness changes are needed; the
|
||||
forwarded-op serving stub helpers (`make_forwarded_stub_handler`
|
||||
etc.) stay as-is (they remain the producer-leg stub stand-in; the
|
||||
real opener-side registration is this task's, exercised directly).
|
||||
@@ -210,30 +214,30 @@ pub fn listen_reply_fields(reply: &Value) -> TunnelListenReply
|
||||
## Summary
|
||||
|
||||
Implemented the forwarded op's SERVING side (the listen-opener half,
|
||||
ADR-008 §3 as amended) per the task: `src/producer.rs` —
|
||||
ADR-008 §3 as amended) per the task: `src/producer.rs` gained
|
||||
`ForwardedHandler` (the injected assembly handler: parsed params +
|
||||
channel `BiStream`; the assembly owns the pump, correlating by the
|
||||
`listen` token against its waiting-listener bookkeeping);
|
||||
`make_forwarded_op_handler` (the built-in no-establisher wrapper:
|
||||
deny-unknown params parse — a malformed open fails loud → early
|
||||
deny-unknown params parse, so a malformed open fails loud → early
|
||||
return → teardown-at-birth reaps the channel, no phantom; `accept_bi`
|
||||
FIRST per R-02 telemetry; the injected handler's `JoinHandle` awaited
|
||||
inline); `register_tunnel_forwarded_openable` (`register_openable` —
|
||||
inline); `register_tunnel_forwarded_openable` (`register_openable`,
|
||||
no establisher, the data plane starts immediately; scope gate
|
||||
`tunnel:forwarded` via `tunnel_forwarded_spec`). `src/params.rs` —
|
||||
`listen_reply_fields` (the lenient, infallible reply projection:
|
||||
`tunnel:forwarded` via `tunnel_forwarded_spec`). `src/params.rs`
|
||||
gained `listen_reply_fields` (the lenient, infallible reply projection:
|
||||
unknown extra fields tolerated, a malformed shape logs loud and
|
||||
surfaces both-`None`; shared with `TunnelListener`), delegating to the
|
||||
landed `parse_listen_reply`. Re-exported through `lib.rs`.
|
||||
|
||||
Tests: 4 new unit tests in `params.rs` (full/bare/extra-field/malformed
|
||||
reply projection) + 4 integration tests in `tests/producer_forwarded.rs`
|
||||
(the serving-half e2e round-trip gate — reply projection → token
|
||||
(the serving-half e2e round-trip gate: reply projection → token
|
||||
correlation → forwarded open → byte round-trip through the assembly
|
||||
pump + the producer-side adopted pump; the FORBIDDEN scope gate —
|
||||
pump + the producer-side adopted pump; the FORBIDDEN scope gate:
|
||||
`tunnel:open`-only identity, no channel allocated, handler never ran;
|
||||
malformed params — both gates pinned loud, the serde-gated shape
|
||||
tears down at birth; the correlation-miss teardown — the assembly's
|
||||
malformed params: both gates pinned loud, the serde-gated shape
|
||||
tears down at birth; the correlation-miss teardown: the assembly's
|
||||
rejection, drop the stream, the channel reaps, the invocation
|
||||
witnessed). Verified: `cargo test` green (126 default; 146
|
||||
`--all-features`), clippy `--all-targets -D warnings` clean (native +
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
id: tunnels/local-bind-halves
|
||||
name: local feature — TCP bind halves + the bind-first assembly pieces
|
||||
name: local feature, TCP bind halves + the bind-first assembly pieces
|
||||
status: completed
|
||||
depends_on: [tunnels/bindfirst-producer]
|
||||
scope: narrow
|
||||
@@ -12,13 +12,13 @@ tags: [local, tcp, bind, listener, feature-gated]
|
||||
|
||||
## Description
|
||||
|
||||
Extend the `local` feature (non-wasm, `tokio/net`-gated —
|
||||
Extend the `local` feature (non-wasm, `tokio/net`-gated;
|
||||
`tunnels/local-socket-halves` established the pattern) with the
|
||||
bind-first shape's substrate half: a real TCP bind function
|
||||
(`BindFn`-compatible) and an accept loop producing
|
||||
`(peer, TargetHandle)` pairs, plus the direct-op convenience dial
|
||||
closure composition (the `DialFn` the direct op shares with the base
|
||||
op — likely already satisfied by `local_dial`; verify, don't
|
||||
op, likely already satisfied by `local_dial`; verify, don't
|
||||
duplicate).
|
||||
|
||||
### The bind half
|
||||
@@ -26,7 +26,7 @@ duplicate).
|
||||
```rust
|
||||
/// The real-socket BindFn source (the `local` feature's shape 3
|
||||
/// backing): bind `addr` (port 0 = OS-chosen), report the REAL
|
||||
/// bound address (the `bound` reply field's source — ADR-008 §2),
|
||||
/// bound address (the `bound` reply field's source, ADR-008 §2),
|
||||
/// expose the accept loop that feeds the dispatcher.
|
||||
pub async fn bind_tcp_first(addr: &str)
|
||||
-> Result<BoundListener, TunnelEstablishError>;
|
||||
@@ -35,28 +35,28 @@ pub fn local_bind_first() -> BindFn; // renders via bind_tcp_first
|
||||
```
|
||||
|
||||
- `BoundListener.listener` carries the `TcpListener` (type-erased in
|
||||
the plan — `tunnels/bindfirst-producer`'s shape note); the accept
|
||||
loop is assembly code composed ON it — the protocol crate stays
|
||||
the plan; `tunnels/bindfirst-producer`'s shape note); the accept
|
||||
loop is assembly code composed ON it: the protocol crate stays
|
||||
socket-blind, so the loop lives here (`local` = substrate code,
|
||||
ADR-004):
|
||||
- **`TcpListenerHalves` already has an accept loop** (`accept_loop`
|
||||
feeding an `AcceptQueue` — the accept-first shape). The bind-first
|
||||
feeding an `AcceptQueue`, the accept-first shape). The bind-first
|
||||
shape needs a DIFFERENT loop: per-accept it must produce
|
||||
`(peer_addr, TargetHandle)` for the accept dispatcher (which opens
|
||||
forwarded channels per accept), not feed a queue for the
|
||||
establisher to pop. Add a sibling method/struct (e.g.
|
||||
`TcpListenerHalves::accept_pairs` or a new
|
||||
`BindFirstAcceptLoop` adapter) — decide the honest shape, document
|
||||
`BindFirstAcceptLoop` adapter); decide the honest shape, document
|
||||
in Notes. The peer address rides from `accept()`'s return value
|
||||
(reply#2 fidelity's source — ADR-008 §3's `peer` carrier).
|
||||
(reply#2 fidelity's source, ADR-008 §3's `peer` carrier).
|
||||
- The direct-op `DialFn`: `local_dial()` already renders
|
||||
`(Substrate, backing)` → halves with the UDP framed adapter — the
|
||||
`(Substrate, backing)` → halves with the UDP framed adapter; the
|
||||
direct establisher calls the SAME closure with the rendered target
|
||||
string. Verify the UDP wrap happens INSIDE the closure (it does —
|
||||
string. Verify the UDP wrap happens INSIDE the closure (it does;
|
||||
review 001 U-1) so direct-udp gets the framed adapter free; add a
|
||||
test pinning direct-udp-through-`local_dial` framing if missing.
|
||||
|
||||
### Tests (`tests/`, real sockets — the `local` suite's posture)
|
||||
### Tests (`tests/`, real sockets; the `local` suite's posture)
|
||||
|
||||
- Bind port 0 → `bound` reports the REAL OS-chosen address (the
|
||||
reply#1 fidelity pin).
|
||||
@@ -64,17 +64,17 @@ pub fn local_bind_first() -> BindFn; // renders via bind_tcp_first
|
||||
channels (via the bind-first harness) → both pump independently
|
||||
(peer fidelity: each forwarded open carries the REAL accepted
|
||||
peer).
|
||||
- Unix-domain bind (OQ-TN-14 parity): `bind_unix_first`-equivalent —
|
||||
decide whether unix bind-first ships in this task or is deferred
|
||||
- Unix-domain bind (OQ-TN-14 parity): `bind_unix_first`-equivalent.
|
||||
Decide whether unix bind-first ships in this task or is deferred
|
||||
with a note (the wire enum already carries unix; the local dial
|
||||
path exists — the bind path is the same shape; prefer shipping
|
||||
path exists, and the bind path is the same shape; prefer shipping
|
||||
both, matching dial parity).
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] `bind_tcp_first` + the accept-pairs loop (peer + halves per
|
||||
accept) behind `local`
|
||||
- [x] The bound address is the REAL OS-chosen one (port 0 resolved —
|
||||
- [x] The bound address is the REAL OS-chosen one (port 0 resolved,
|
||||
pinned by test)
|
||||
- [x] Direct-op `DialFn` reuse verified (no duplicate dial code;
|
||||
UDP framed-adapter wrap composes)
|
||||
@@ -87,96 +87,96 @@ pub fn local_bind_first() -> BindFn; // renders via bind_tcp_first
|
||||
## References
|
||||
|
||||
- docs/architecture/decisions/008-listen-metadata-forwarded-op.md
|
||||
(§1 shape 3 — the bind-first establisher; §3 the peer carrier)
|
||||
(§1 shape 3, the bind-first establisher; §3 the peer carrier)
|
||||
- docs/architecture/producer.md §The Registration API (`BindFn` shape)
|
||||
- tasks/tunnels/local-socket-halves.md (the `local` pattern + the
|
||||
existing `bind_tcp`/`TcpListenerHalves`/`AcceptQueue` accept-first
|
||||
shapes)
|
||||
- tasks/tunnels/bindfirst-producer.md (the `BindFn`/
|
||||
`BindFirstAcceptLoop` contracts this implements)
|
||||
- OQ-TN-14 (unix ships with `local` v1; stdio out — alktty's)
|
||||
- OQ-TN-14 (unix ships with `local` v1; stdio is out, as in alktty's)
|
||||
|
||||
## Notes
|
||||
|
||||
- **The task sketch's `bind_tcp_first(addr: &str)` signature held** —
|
||||
- **The task sketch's `bind_tcp_first(addr: &str)` signature held**:
|
||||
the committed [`BindFn`] contract takes `(substrate, backing)`; the
|
||||
substrate dispatch lives inside [`local_bind_first`] (tcp →
|
||||
[`bind_tcp_first`], unix → [`bind_unix_first`], udp → fails loud),
|
||||
matching the type-erased closure precedent (the protocol crate stays
|
||||
socket-blind; the closure is the assembly side).
|
||||
- **The accept-pairs shape: a `*_pair` method, not a sibling loop
|
||||
struct** — [`TcpListenerHalves::accept_pair`] (one accept per call →
|
||||
struct**: [`TcpListenerHalves::accept_pair`] (one accept per call →
|
||||
`(peer, halves)`). The task floated `accept_pairs` or a
|
||||
`BindFirstAcceptLoop` adapter struct; one method is the honest shape:
|
||||
the DISPATCHER owns the loop pacing (the committed
|
||||
`BindFirstAcceptLoop` contract is one closure call = one accept), so
|
||||
the substrate half is just the single-accept step with the peer
|
||||
attached. `local_bindfirst_accept_loop()` is the adapter — it wraps
|
||||
attached. `local_bindfirst_accept_loop()` is the adapter: it wraps
|
||||
the `*_pair` methods with the downcast (the assembly side of the
|
||||
type-erased injection; a foreign listener type fails loud as
|
||||
`handler_error` — pinned by test). The accept-FIRST
|
||||
`handler_error`, pinned by test). The accept-FIRST
|
||||
[`TcpListenerHalves::accept_loop`] (queue-feeding) is untouched.
|
||||
- **UDP bind-first is NOT here — refused loud** (a decision the task
|
||||
- **UDP bind-first is NOT here: refused loud** (a decision the task
|
||||
left open): a UDP socket has no accept, so the bind-first shape is
|
||||
not DEFINED for udp; [`local_bind_first`] maps `Substrate::Udp` to a
|
||||
`handler_error` rather than faking a listener (pinned by
|
||||
`udp_bind_first_fails_loud`). The per-peer associate shape (an
|
||||
accept-analogue for UDP) would be a wire/protocol design decision —
|
||||
an ADR, if it ever ships. The direct/forward udp dial shapes cover
|
||||
accept-analogue for UDP) would be a wire/protocol design decision,
|
||||
an ADR if it ever ships. The direct/forward udp dial shapes cover
|
||||
the substrate.
|
||||
- **Unix bind-first SHIPPED** (the task's preferred posture — OQ-TN-14
|
||||
dial parity): `bind_unix` + `UnixListenerHalves` (a sibling of
|
||||
- **Unix bind-first SHIPPED** (the task's preferred posture,
|
||||
OQ-TN-14 dial parity): `bind_unix` + `UnixListenerHalves` (a sibling of
|
||||
[`TcpListenerHalves`]: `local_addr`, the queue-feeding
|
||||
`accept_loop`, and the bind-first `accept_pair`) +
|
||||
[`bind_unix_first`]. The bind-first `accept_pair`'s peer is the
|
||||
ACCEPTED client's bound path; an unnamed (autobind) client fails
|
||||
loud — the forwarded op's `peer` param is required on the wire, an
|
||||
loud: the forwarded op's `peer` param is required on the wire, an
|
||||
addressless peer cannot be represented (ADR-008 §3). The unix
|
||||
parity test binds the CLIENT to a named path first (socket2 — std
|
||||
parity test binds the CLIENT to a named path first (socket2; std
|
||||
has no `UnixStream::bind`), making the peer representable.
|
||||
- **`bind_unix` maps bind failure to `ResourceShortage`** (matching
|
||||
[`bind_tcp`]'s posture — the held-resource class), not
|
||||
[`bind_tcp`]'s posture, the held-resource class), not
|
||||
`DialFailed`: the bind IS the dial for the establisher's error table
|
||||
(shape-3: unbindable/held → `dial_failed`-class rides the
|
||||
establisher's passthrough), but the substrate function's own class
|
||||
follows the sibling bind functions.
|
||||
- **Direct-op `DialFn` reuse: verified, no duplication** —
|
||||
- **Direct-op `DialFn` reuse: verified, no duplication**:
|
||||
[`local_dial`] is unchanged and shared; the UDP wrap happens INSIDE
|
||||
the closure (review 001 U-1). The missing pin was added:
|
||||
`direct_udp_through_local_dial_round_trips_framed` (tests/
|
||||
local_halves.rs) drives `TunnelSession::open_direct` +
|
||||
`Substrate::Udp` through [`local_dial`] against a REAL udp echo
|
||||
socket — datagram boundary preserved through the direct op's
|
||||
socket, with the datagram boundary preserved through the direct op's
|
||||
establisher path.
|
||||
|
||||
## Summary
|
||||
|
||||
Implemented the `local` feature's bind-first substrate half (shape 3,
|
||||
ADR-008 as amended) per the task: `src/local/mod.rs` — `bind_tcp_first`
|
||||
ADR-008 as amended) per the task: `src/local/mod.rs` now has `bind_tcp_first`
|
||||
(the real-socket [`BindFn`] source: port 0 resolved via `local_addr`,
|
||||
the `bound` reply field's data), `TcpListenerHalves::accept_pair` (one
|
||||
accept → `(peer, halves)` — the peer rides `accept()`'s return, the
|
||||
accept → `(peer, halves)`, with the peer riding `accept()`'s return, the
|
||||
forwarded op's `peer` carrier), `bind_unix` + `UnixListenerHalves` +
|
||||
`bind_unix_first` (unix bind-first parity SHIPPED — OQ-TN-14; unnamed
|
||||
`bind_unix_first` (unix bind-first parity SHIPPED per OQ-TN-14; unnamed
|
||||
peer fails loud, the forwarded `peer` param requires an address),
|
||||
`local_bind_first()` (the [`BindFn`] convenience: tcp/unix dispatch,
|
||||
udp fails loud — no accept on a UDP socket, the shape is not defined
|
||||
udp fails loud: no accept on a UDP socket, the shape is not defined
|
||||
there, never a fake listener) and `local_bindfirst_accept_loop()` (the
|
||||
[`BindFirstAcceptLoop`] convenience: downcasts the plan's opaque
|
||||
listener to the module's wrappers, one pair per call; a foreign
|
||||
listener fails loud). The accept-FIRST shapes (`accept_loop` +
|
||||
`AcceptQueue`) are untouched.
|
||||
|
||||
Tests: `tests/local_bindfirst.rs` (4, `#![cfg(feature = "local")]`) —
|
||||
the tcp e2e over REAL sockets (bind port 0 → the reply's `bound` is
|
||||
the REAL OS-chosen address the test then DIALS — a fake bound would
|
||||
Tests: `tests/local_bindfirst.rs` (4, `#![cfg(feature = "local")]`),
|
||||
covering the tcp e2e over REAL sockets (bind port 0 → the reply's `bound` is
|
||||
the REAL OS-chosen address the test then DIALS; a fake bound would
|
||||
strand the dial; two clients → two accepts → two forwarded channels →
|
||||
independent interleaved pumps, each forwarded open carrying the REAL
|
||||
accepted peer, distinct ports), unix parity (real socket path in
|
||||
`bound`; a named-path client connects; the forwarded open carries the
|
||||
REAL peer path), the udp refusal, and the downcast-miss probe. Plus
|
||||
`direct_udp_through_local_dial_round_trips_framed` in tests/
|
||||
local_halves.rs — the direct-op `DialFn` reuse pin (the framed udp
|
||||
local_halves.rs, the direct-op `DialFn` reuse pin (the framed udp
|
||||
adapter composes through the direct op; review 001 U-1).
|
||||
|
||||
Verified: `cargo test` (default) and `cargo test --all-features` green
|
||||
@@ -184,4 +184,4 @@ Verified: `cargo test` (default) and `cargo test --all-features` green
|
||||
`--all-targets --all-features -D warnings` clean, fmt clean,
|
||||
`wasm32-unknown-unknown` check passes (the default crate stays
|
||||
wasm-clean; the bind-first half lives behind `local`, non-wasm by
|
||||
design).
|
||||
design).
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
id: tunnels/local-socket-halves
|
||||
name: "local feature — real socket halves functions (TCP dial, UDP connect, unix)"
|
||||
name: "local feature: real socket halves functions (TCP dial, UDP connect, unix)"
|
||||
status: completed
|
||||
depends_on: [tunnels/producer-open-op, tunnels/wire-codec]
|
||||
scope: moderate
|
||||
@@ -27,10 +27,10 @@ pub async fn dial_tcp(target: &str) -> Result<TargetHandle, TunnelEstablishError
|
||||
|
||||
pub async fn connect_udp(target: &str) -> Result<TargetHandle, TunnelEstablishError>;
|
||||
// UdpSocket::bind(("0.0.0.0", 0)) → connect(target) → the FRAMED adapter
|
||||
// (UdpHalf + codec — ADR-003; truncation fail-loud per OQ-TN-13)
|
||||
// (UdpHalf + codec per ADR-003; truncation fails loud per OQ-TN-13)
|
||||
|
||||
pub async fn dial_unix(path: &str) -> Result<TargetHandle, TunnelEstablishError>;
|
||||
// UnixStream::connect → into_split (OQ-TN-14: ships with local v1 — cheap,
|
||||
// UnixStream::connect → into_split (OQ-TN-14: ships with local v1; it is cheap,
|
||||
// same halves shape as TCP)
|
||||
|
||||
pub struct TcpListenerHalves { listener: tokio::net::TcpListener }
|
||||
@@ -45,10 +45,11 @@ pub struct UdpAssociateHalves { sock: Arc<UdpSocket> } // the POC's associate s
|
||||
- **UDP adapter (`UdpHalf`)**: port from the forward POC
|
||||
(`params.rs`'s `UdpHalfRead`/`UdpHalfWrite`) with the truncation fix:
|
||||
`poll_recv` into a too-small caller buffer must fail loud
|
||||
(OQ-TN-13's resolved posture — ADR-003). The codec wraps at this
|
||||
(OQ-TN-13's resolved posture, i.e. ADR-003). The codec wraps at this
|
||||
boundary; the pump never sees UDP specifics.
|
||||
- **Unix**: `dial_unix` ships (OQ-TN-14 resolved — same halves shape
|
||||
as TCP). **Stdio does NOT** — a spawned process's stdio is alktty's
|
||||
- **Unix**: `dial_unix` ships (OQ-TN-14 resolved, with the same
|
||||
halves shape as TCP). **Stdio does NOT**, because a spawned
|
||||
process's stdio is alktty's
|
||||
pipe mode (exit codes + signals are tty semantics, not tunnel
|
||||
semantics); remote command execution composes via alktty, not here.
|
||||
- **Error mapping** to `TunnelEstablishError` variants (the POC's
|
||||
@@ -67,11 +68,11 @@ local = ["tokio/net"]
|
||||
- `tokio/net` is the only dep addition (no new external crates; the
|
||||
POCs proved `rt+sync+io-util+macros+time+net` covers everything).
|
||||
- The default crate stays wasm-clean: `local`-gated code must not be
|
||||
importable from `producer.rs`/`consumer.rs` (convention 16 — backend
|
||||
importable from `producer.rs`/`consumer.rs` (convention 16: backend
|
||||
modules are never imported from the shared/producer/consumer
|
||||
modules).
|
||||
- Unix is IN v1 (OQ-TN-14's lean-yes posture; same halves shape as
|
||||
TCP); stdio bridging is NOT (different lifecycle — deferred).
|
||||
TCP); stdio bridging is NOT (a different lifecycle, deferred).
|
||||
|
||||
### Tests
|
||||
|
||||
@@ -106,22 +107,22 @@ POC's sizing test).
|
||||
|
||||
- **`bind_tcp` + `TcpListenerHalves::accept_loop(queue)`** complete the
|
||||
listen side (the task surface sketched `bind`/`accept_fn`): the
|
||||
accept loop is a spawned task feeding an [`AcceptQueue`] — the
|
||||
accept loop is a spawned task feeding an [`AcceptQueue`], the
|
||||
protocol-side queue contract from producer-listen. The assembly
|
||||
layer aborts the task on teardown; accept errors end the loop.
|
||||
- **`UdpHalf` truncation probe** (OQ-TN-13): `poll_read` recvs into a
|
||||
64KiB scratch buffer first, then size-checks against the caller's
|
||||
buffer — a >buffer datagram is `io::ErrorKind::InvalidData`, never
|
||||
buffer, so a >buffer datagram is `io::ErrorKind::InvalidData`, never
|
||||
a silent truncation. (`tokio`'s `poll_recv`-into-ReadBuf truncates
|
||||
silently, which is exactly the invariant F-2/OQ-TN-13 forbids.)
|
||||
WouldBlock polls `poll_recv_ready` for waker registration.
|
||||
- **The task sketch's `TcpListenerHalves::accept_fn(&self, queue)`
|
||||
became `accept_loop(&self, queue)`** (an owned async loop to spawn)
|
||||
— the `accept_fn` borrow-shape fought the spawn model; the loop
|
||||
; the `accept_fn` borrow-shape fought the spawn model, while the loop
|
||||
returning on listener failure is the assembly-abort contract.
|
||||
- **Test plumbing note** (tests): the listen-over-real-socket test
|
||||
drives the client socket via std `try_clone` BEFORE `from_std`
|
||||
(O_NONBLOCK is shared across dup — both ends go async); the session
|
||||
(O_NONBLOCK is shared across dup, so both ends go async); the session
|
||||
uses borrowed halves (the producer's wrapper-managed pump already
|
||||
pumps channel <-> accepted handle; `pump_against` there would loop
|
||||
the same socket back into itself).
|
||||
@@ -132,7 +133,7 @@ POC's sizing test).
|
||||
|
||||
Implemented the `local` feature (src/local/mod.rs, feature =
|
||||
`["tokio/net"]`): `dial_tcp` (5s timeout), `connect_udp` (ephemeral
|
||||
bind + connect + the FRAMED `UdpHalf` adapter — truncation fails
|
||||
bind + connect + the FRAMED `UdpHalf` adapter (truncation fails
|
||||
loud), `dial_unix` (OQ-TN-14 in v1), `bind_tcp`/`TcpListenerHalves::
|
||||
accept_loop` (the listen shape's assembly half), and `local_dial()`
|
||||
(the one `DialFn` covering all three substrates). No socket type
|
||||
@@ -141,8 +142,8 @@ imported from producer/consumer/shared; `TargetHandle` re-used from
|
||||
producer.rs). Stdio bridging is NOT here (alktty's pipe mode owns
|
||||
process stdio).
|
||||
|
||||
Tests: `tests/local_halves.rs` (7, `#![cfg(feature = "local")]`) —
|
||||
TCP dial round-trip through the real establisher path, UDP framed
|
||||
Tests: `tests/local_halves.rs` (7, `#![cfg(feature = "local")]`) with
|
||||
the TCP dial round-trip through the real establisher path, UDP framed
|
||||
round-trip, the 1400-byte MTU datagram, unix dial round-trip, the
|
||||
OQ-TN-13 truncation fail-loud probe (60k datagram vs 16KiB read →
|
||||
InvalidData), dial-refusal → `dial_failed`, and the listen producer
|
||||
|
||||
+12
-9
@@ -14,8 +14,8 @@ tags: [wire, params, open-op]
|
||||
|
||||
Implement `src/params.rs` per ADR-001 + wire.md §The Open Op: the
|
||||
`TunnelParams` wire type, the `Substrate` discriminator, and the open-op
|
||||
`OperationSpec` builder. This is the wire-stable surface (one-way door) —
|
||||
exact conformance to the ADR is the point.
|
||||
`OperationSpec` builder. This is the wire-stable surface (a one-way
|
||||
door), so exact conformance to the ADR is the point.
|
||||
|
||||
### Types
|
||||
|
||||
@@ -35,13 +35,14 @@ pub enum Substrate { Tcp, Udp, Unix }
|
||||
- `deny_unknown_fields` is the loud-rejection posture (ADR-001: unknown
|
||||
fields fail schema validation, never silently ignored).
|
||||
- `Substrate::Unix` is included in the type AND in the open-op schema's
|
||||
enum list v1 (`["tcp", "udp", "unix"]` — OQ-TN-14 resolved: unix has
|
||||
enum list v1 (`["tcp", "udp", "unix"]`; OQ-TN-14 resolved, so unix
|
||||
has
|
||||
an implementation path via the `local` feature). The schema list is
|
||||
the authoritative gate for newer-substrate negotiation: an older
|
||||
producer rejecting a newer substrate value is the SSH "unknown
|
||||
channel type" posture (loud, not silent).
|
||||
|
||||
### The open-op spec (in producer.rs or params.rs — put it where
|
||||
### The open-op spec (in producer.rs or params.rs; put it where
|
||||
`tunnels/producer-open-op` can consume; the spec builder lives in
|
||||
params.rs per the module map, producer.rs re-exports)
|
||||
|
||||
@@ -56,8 +57,8 @@ pub fn tunnel_open_spec() -> OperationSpec
|
||||
- `OperationType::Sub`; `Visibility::External`; channel-open marker
|
||||
`ChannelOpenSpec::new(TUNNEL_ALPN)`.
|
||||
- `input_schema`: `{resource: string (required), substrate: string enum
|
||||
["tcp","udp","unix"] (required)}` — JSON Schema Draft shape as the
|
||||
POCs used (unix in the list per OQ-TN-14's resolution).
|
||||
["tcp","udp","unix"] (required)}`, which is the JSON Schema Draft
|
||||
shape as the POCs used (unix in the list per OQ-TN-14's resolution).
|
||||
- `output_schema`: `{channel_id: integer > 0}`.
|
||||
- `AccessControl.required_scopes: [TUNNEL_OPEN_SCOPE]` (ADR-006).
|
||||
- `description`: a one-line human hint (round-trips through discovery;
|
||||
@@ -100,7 +101,8 @@ errors (filled incrementally by later tasks); plus the re-export of
|
||||
enum). `Eq` added to `TunnelParams` (test equality).
|
||||
- `tunnel_open_spec()` lives in params.rs per the module map;
|
||||
producer.rs re-exports. Output schema pins `channel_id` with
|
||||
`minimum: 1` + `required` (wire.md: integer > 0) — the POC's
|
||||
`minimum: 1` + `required` (wire.md says integer > 0), which corrects
|
||||
the POC's
|
||||
`minimum: 0` / no-required was loose.
|
||||
- `establishment_reason` delegates to alkcall 0.7.0's
|
||||
`ChannelOpenError::establishment_reason` method (upstream has it);
|
||||
@@ -108,7 +110,7 @@ errors (filled incrementally by later tasks); plus the re-export of
|
||||
re-exports through it).
|
||||
- Schema-validation tests use the `TunnelParams` deserializer as the
|
||||
enum validator (same deny-unknown/enum posture as the registry's
|
||||
JSON Schema check; no jsonschema dep added for v1 — the
|
||||
JSON Schema check; no jsonschema dep added for v1, and the
|
||||
input_schema string is asserted structurally in
|
||||
`open_spec_matches_wire_md`).
|
||||
- 6 unit tests: round-trip ×3 substrates, unknown-field rejection,
|
||||
@@ -123,4 +125,5 @@ External visibility, input/output schemas with the 3-value substrate
|
||||
enum, `tunnel:open` ACL, channel-open marker, description),
|
||||
constants, and the `establishment_reason` helper. Verified: `cargo
|
||||
test` (6 passed), clippy --all-targets -D warnings (native + wasm32),
|
||||
fmt --check, wasm32 check — all clean.
|
||||
fmt --check, and wasm32
|
||||
check are all clean.
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
id: tunnels/producer-listen
|
||||
name: Listen establisher — the producer-side listener variant
|
||||
name: Listen establisher, the producer-side listener variant
|
||||
status: completed
|
||||
depends_on: [tunnels/producer-open-op]
|
||||
scope: narrow
|
||||
@@ -15,7 +15,7 @@ tags: [producer, establisher, listen, reverse]
|
||||
Implement the listen establisher shape per producer.md §The Establisher
|
||||
(shape 2): a producer whose resource is a LISTENER (SSH `-R` far-side
|
||||
listener; a hub exposing its own port to a third party). Same open op,
|
||||
same params, same typed errors — the establisher pops the next accepted
|
||||
same params, same typed errors; the establisher pops the next accepted
|
||||
connection from an assembly-owned listener queue instead of dialing.
|
||||
No new wire surface (ADR-004).
|
||||
|
||||
@@ -36,8 +36,8 @@ pub fn listen_establisher(registry: ResourceRegistry, accept: AcceptFn) -> OpenE
|
||||
- Error mapping (producer.md's table): empty queue during
|
||||
establishment → `resource_shortage`; closed listener →
|
||||
`dial_failed`; registry miss → `unknown_resource` (same as dial).
|
||||
- The pump handler is UNCHANGED (the same `make_tunnel_pump_handler`) —
|
||||
the listen variant is plan-flow with a different halves source.
|
||||
- The pump handler is UNCHANGED (the same `make_tunnel_pump_handler`),
|
||||
since the listen variant is plan-flow with a different halves source.
|
||||
|
||||
### Assembly-side listener helper (the `local`-gated half lands in
|
||||
`tunnels/local-socket-halves`; here, define the protocol-side queue
|
||||
@@ -52,7 +52,7 @@ impl AcceptQueue {
|
||||
```
|
||||
|
||||
- `pop` during establishment: bounded (the establisher's own deadline
|
||||
applies — ADR-049 §2); `None` → `resource_shortage`.
|
||||
applies; ADR-049 §2). `None` → `resource_shortage`.
|
||||
|
||||
### Tests
|
||||
|
||||
@@ -67,7 +67,7 @@ queue → `resource_shortage`, closed listener → `dial_failed`.
|
||||
|
||||
- [ ] `listen_establisher` + `AcceptFn` + the queue contract compile
|
||||
and wire through `register_tunnel_openable`'s shape (a listen
|
||||
variant of the registration or a dial/accept enum — pick the
|
||||
variant of the registration or a dial/accept enum (pick the
|
||||
honest shape, document it)
|
||||
- [ ] The pump handler is untouched (same function registered for
|
||||
listen producers)
|
||||
@@ -81,7 +81,7 @@ queue → `resource_shortage`, closed listener → `dial_failed`.
|
||||
- docs/architecture/decisions/004-no-backend-trait.md (the listen
|
||||
variant rationale)
|
||||
- Reverse POC (the hub's accept loop stands in for the assembly
|
||||
listener — `docs/research/reverse-poc-summary.md` §The -R template)
|
||||
listener; see `docs/research/reverse-poc-summary.md` §The -R template)
|
||||
|
||||
## Notes
|
||||
|
||||
@@ -89,7 +89,7 @@ queue → `resource_shortage`, closed listener → `dial_failed`.
|
||||
|
||||
- **Registration shape (the "pick the honest shape" decision):** a
|
||||
separate `register_tunnel_listen_openable(core, registry, on_registry,
|
||||
auth, accept: AcceptFn)` — not a dial/accept enum on
|
||||
auth, accept: AcceptFn)`, not a dial/accept enum on
|
||||
`register_tunnel_openable`. Rationale: the establisher is the only
|
||||
difference; `register_openable_with_establisher` takes a pre-built
|
||||
`OpenEstablisher`, so both variants share the same spec + pump
|
||||
@@ -99,17 +99,17 @@ queue → `resource_shortage`, closed listener → `dial_failed`.
|
||||
not need.
|
||||
- **`AcceptQueue::push` is async** (lock-acquiring): the accept loop's
|
||||
step is async anyway; a sync `push` would need a sync mutex with
|
||||
cross-thread notify — not worth it for a queue that drains one
|
||||
handle per open.
|
||||
cross-thread notify, which is not worth it for a queue that drains
|
||||
one handle per open.
|
||||
- **Empty-queue posture is assembly-owned, not queue-owned:** an
|
||||
empty queue during establishment is NOT inherently an error (a late
|
||||
accept is legitimate — `accept_wait_resolves_when_push_arrives_late`
|
||||
accept is legitimate (`accept_wait_resolves_when_push_arrives_late`
|
||||
pins the wait-then-resolve shape). The mapping to
|
||||
`resource_shortage` belongs to the accept closure (the assembly
|
||||
knows its listener's bound: drained listener, accept budget); the
|
||||
wrapper's establishment timeout (10s) is the backstop. The task
|
||||
sketch's "empty queue → resource_shortage" is realized by the
|
||||
assembly mapping — the test pins the fail-fast closure posture.
|
||||
assembly mapping, and the test pins the fail-fast closure posture.
|
||||
- **`closed_listener → dial_failed`** is likewise the closure's
|
||||
mapping (the queue's `close()` + pop → `None` → the closure maps).
|
||||
The typed-error table holds: both reasons verified on the wire via
|
||||
@@ -127,12 +127,13 @@ Establisher: `AcceptFn` (the injected accepted-connection source),
|
||||
`AcceptQueue` (the protocol-side queue contract: async push/pop/
|
||||
close, FIFO with always-before-take ordering, close-while-waiting
|
||||
resolves None), `listen_establisher` (registry namespace gate →
|
||||
accept() → `Establishment::new(plan)` — the same plan flow, a
|
||||
accept() → `Establishment::new(plan)`; the same plan flow, but a
|
||||
different halves source; the pump handler is untouched), and
|
||||
`register_tunnel_listen_openable` (the same spec/pump registration,
|
||||
listen establisher).
|
||||
|
||||
Tests: `tests/producer_listen.rs` (6) — the listen flow end-to-end
|
||||
Tests: `tests/producer_listen.rs` (6), covering the listen flow
|
||||
end-to-end
|
||||
(pre-accepted handle round-trips through the pump), FIFO ordering
|
||||
across two opens (the R-01 plan-flow property, listen-flavored),
|
||||
empty queue → `resource_shortage` (fail-fast closure posture pinned),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
id: tunnels/producer-open-op
|
||||
name: Producer half — establisher (dial shape) + pump handler + register_tunnel_openable
|
||||
name: Producer half, establisher (dial shape) + pump handler + register_tunnel_openable
|
||||
status: completed
|
||||
depends_on: [tunnels/params, tunnels/wire-codec]
|
||||
scope: broad
|
||||
@@ -14,14 +14,14 @@ tags: [producer, establisher, open-op, pump]
|
||||
|
||||
Implement `src/producer.rs` per producer.md: the dial-shape establisher,
|
||||
the substrate-agnostic pump handler, and the registration surface. This
|
||||
is the crate's core — the open op that both POCs validated (the forward
|
||||
is the crate's core: the open op that both POCs validated (the forward
|
||||
POC's producer on the accept side; the reverse POC's worker on the
|
||||
connect side). Port from the REVERSE POC first
|
||||
(`/workspace/alktunnels-reverse-poc/src/producer.rs`) — it is the 0.6/0.7
|
||||
idiomatic shape (plan flow, no handoff) and its 16 tests are the
|
||||
reference suite.
|
||||
(`/workspace/alktunnels-reverse-poc/src/producer.rs`), which is the
|
||||
0.6/0.7 idiomatic shape (plan flow, no handoff) and whose 16 tests are
|
||||
the reference suite.
|
||||
|
||||
### The establisher (dial shape — the listen shape is a separate task)
|
||||
### The establisher (dial shape, with the listen shape as a separate task)
|
||||
|
||||
```rust
|
||||
pub type DialFn = Arc<
|
||||
@@ -29,21 +29,21 @@ pub type DialFn = Arc<
|
||||
+ Send + Sync,
|
||||
>;
|
||||
// TargetHandle: boxed halves (read: Box<dyn AsyncRead + Send + Sync + Unpin>, write: ...),
|
||||
// or the framed UDP adapter (the establisher wraps BEFORE returning the plan —
|
||||
// or the framed UDP adapter (the establisher wraps BEFORE returning the plan per
|
||||
// ADR-003's placement; the pump stays substrate-agnostic)
|
||||
```
|
||||
|
||||
- `tunnel_establisher(registry: ResourceRegistry, dial: DialFn,
|
||||
identity_witness: Option<...>) -> OpenEstablisher` — the assembly layer
|
||||
identity_witness: Option<...>) -> OpenEstablisher`; the assembly layer
|
||||
injects the substrate dial function (ADR-004); the establisher:
|
||||
1. Parses params (`TunnelParams` — schema-validated upstream, semantic
|
||||
1. Parses params (`TunnelParams`: schema-validated upstream, semantic
|
||||
parse here).
|
||||
2. Registry lookup `(resource, substrate)` → backing address —
|
||||
2. Registry lookup `(resource, substrate)` → backing address,
|
||||
`unknown_resource` on miss (typed reason mapping per ADR-001's
|
||||
table).
|
||||
3. `dial(substrate, backing)` → the handle (the closure owns real
|
||||
socket code — behind `local` or assembly-constructed).
|
||||
4. UDP: wrap in the framed adapter (ADR-003) — `UdpHalf` + codec,
|
||||
socket code, behind `local` or assembly-constructed).
|
||||
4. UDP: wrap in the framed adapter (ADR-003): `UdpHalf` + codec,
|
||||
truncation fail-loud (OQ-TN-13).
|
||||
5. Return `Ok(Establishment::new(Arc::new(handle)))` (R-01 plan flow).
|
||||
- `ResourceRegistry`: the assembly-owned
|
||||
@@ -56,14 +56,14 @@ pub type DialFn = Arc<
|
||||
pub fn make_tunnel_pump_handler() -> OpenHandler
|
||||
```
|
||||
|
||||
1. Downcast the plan to `TargetHandle` (`Arc::downcast` — error path:
|
||||
log + return; the birth-teardown telemetry catches it).
|
||||
1. Downcast the plan to `TargetHandle` (`Arc::downcast`; the error
|
||||
path logs and returns, and the birth-teardown telemetry catches it).
|
||||
2. `conn.accept_bi()` (yield-once).
|
||||
3. `pump_bidi(bidi, t_read, t_write)` — **awaited inline** (R-02: the
|
||||
3. `pump_bidi(bidi, t_read, t_write)`, **awaited inline** (R-02: the
|
||||
returned `JoinHandle` tracks the data-plane lifetime; early return =
|
||||
teardown-at-birth). The handler is substrate-agnostic by
|
||||
construction — it must not know what the halves came from
|
||||
(ADR-004).
|
||||
construction (it must not know what the halves came from, per
|
||||
ADR-004).
|
||||
|
||||
### Registration
|
||||
|
||||
@@ -89,7 +89,7 @@ Port the reverse POC's test topology (`harness.rs`: worker =
|
||||
`from_connection_with_serving` + `ChannelOperations::register_on` +
|
||||
post-hoc openable; hub = adapter + capturing install hook) into
|
||||
`tests/` with the `dial` closure standing in for real sockets
|
||||
(in-process duplex halves — no `local` feature needed). Cover:
|
||||
(in-process duplex halves, so no `local` feature is needed). Cover:
|
||||
establishment success + typed errors (unknown_resource / dial_failed /
|
||||
FORBIDDEN / timeout-adjacent), the plan flow under same-resource
|
||||
concurrency, per-call opener identity (CF-006 witness),
|
||||
@@ -105,7 +105,7 @@ late-registration visibility, pump round-trip through `pump_bidi`.
|
||||
- [x] Integration tests: forward AND reverse topology (the harness
|
||||
shapes from both POCs), ≥8 tests covering the above
|
||||
- [x] Clippy/fmt clean; wasm32 check passes (dial closures are
|
||||
runtime-injected — the crate compiles without `local`)
|
||||
runtime-injected, so the crate compiles without `local`)
|
||||
- [x] `cargo test` green
|
||||
|
||||
## References
|
||||
@@ -120,16 +120,16 @@ late-registration visibility, pump round-trip through `pump_bidi`.
|
||||
|
||||
- Ported from the reverse POC's producer.rs (the 0.7.0-idiomatic plan
|
||||
flow, R-01) with the crate generalizations:
|
||||
- The dial closure is INJECTED (`DialFn`) — the POC had real socket
|
||||
- The dial closure is INJECTED (`DialFn`): the POC had real socket
|
||||
code inline (tokio TCP/UDP); the crate's establisher is
|
||||
substrate-agnostic and the `local` feature (or the assembly layer)
|
||||
supplies the closure (ADR-004's inversion point, tested via
|
||||
in-process duplex echo + failing dials).
|
||||
- `register_tunnel_openable` takes `dial` + `identity_witness` (the
|
||||
CF-006 probe seam) — the POC's registration closure had no dial
|
||||
CF-006 probe seam); the POC's registration closure had no dial
|
||||
injection.
|
||||
- `ResourceRegistry` keys on `(String, Substrate)` (the enum, not a
|
||||
&'static str — `Hash` added to `Substrate`).
|
||||
`&'static str`; `Hash` added to `Substrate`).
|
||||
- `TargetHandle` carries `+ Sync` halves (the F-1 plan-payload
|
||||
bound).
|
||||
- Harness (tests/harness.rs): the reverse POC's topology (producer =
|
||||
@@ -139,16 +139,16 @@ late-registration visibility, pump round-trip through `pump_bidi`.
|
||||
`call_with_payload` on channel 0 and adopts the producer-allocated
|
||||
ID.
|
||||
- 11 integration tests: round-trip through `pump_bidi` (wrapper reaps
|
||||
the producer channel on pump completion — R-02), unknown_resource +
|
||||
the producer channel on pump completion, per R-02), unknown_resource +
|
||||
dial_failed typed errors (no phantom channel either side), FORBIDDEN
|
||||
identity-less (fails closed), transport-identity open (CF-005 (b)),
|
||||
token precedence (CF-005 (a) + the CF-006 witness), same-resource
|
||||
concurrency (no plan race), late registration (W2), unknown
|
||||
substrate schema rejection (INVALID_INPUT — the registry's
|
||||
substrate schema rejection (INVALID_INPUT; the registry's
|
||||
schema-validation runs before the establisher), outbound-calls-
|
||||
resolve-while-serving (ADR-022 §2), substrate-keyed registry lookups.
|
||||
- alkcall 0.7.0 note: the establisher's per-call `auth` carries the
|
||||
dispatch-resolved identity (CF-005 corollary) — the witness proves
|
||||
dispatch-resolved identity (CF-005 corollary), and the witness proves
|
||||
the overlay; `INVALID_INPUT` is the wire code for a schema failure
|
||||
(distinct from `channel:open_failed` establishment failures).
|
||||
- `futures` crate used for `BoxFuture` in `DialFn` (already a dep).
|
||||
@@ -161,6 +161,6 @@ pump handler (`pump_bidi` awaited inline, R-02), and
|
||||
`register_tunnel_openable` (spec + establisher + pump + dial
|
||||
injection + witness). 14 unit tests (params + codec) + 11 integration
|
||||
tests over the duplex harness pass. Verified: cargo test, clippy
|
||||
--all-targets -D warnings (native + wasm32), fmt --check, wasm32
|
||||
check — all clean. The listen establisher variant lands with
|
||||
--all-targets -D warnings (native + wasm32), fmt --check, and the
|
||||
wasm32 check are all clean. The listen establisher variant lands with
|
||||
`tunnels/producer-listen`.
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
id: tunnels/review-002-coverage-gaps
|
||||
name: "G-4 + G-5 — close the remaining cheap coverage gaps (unix accept-loop test, unnamed unix fail-loud guards)"
|
||||
name: "G-4 + G-5: close the remaining cheap coverage gaps (unix accept-loop test, unnamed unix fail-loud guards)"
|
||||
status: completed
|
||||
depends_on: []
|
||||
scope: narrow
|
||||
@@ -14,40 +14,40 @@ tags: [tests, local, unix, coverage, review-002, review-remediation]
|
||||
|
||||
The two remaining cheap-to-close coverage gaps from review 002's
|
||||
coverage appendix (`docs/reviews/
|
||||
002-graduation-implementation-review.md`), batched — both are
|
||||
002-graduation-implementation-review.md`), batched: both are
|
||||
`local`-feature test additions (no protocol-layer changes).
|
||||
|
||||
### G-4 — `UnixListenerHalves::accept_loop` never driven
|
||||
### G-4: `UnixListenerHalves::accept_loop` never driven
|
||||
|
||||
`src/local/mod.rs:591-609` has zero coverage: no test constructs it
|
||||
(the unix dial test hand-rolls its own echo listener; the bind-first
|
||||
unix test uses `accept_pair`). It is the unix parity twin of the
|
||||
covered `TcpListenerHalves::accept_loop` (hit at ~107-125) — 15 lines
|
||||
covered `TcpListenerHalves::accept_loop` (hit at ~107-125), 15 lines
|
||||
of the same shape, and the largest single uncovered block in the
|
||||
crate.
|
||||
|
||||
Test: mirror `tests/producer_listen.rs`'s
|
||||
`accept_loop_stops_feeding_after_close_and_maps_the_drop` with
|
||||
`bind_unix` + a temp path — closes the entire 593-607 range and
|
||||
`bind_unix` + a temp path, which closes the entire 593-607 range and
|
||||
exercises `UnixListenerHalves` beyond the bind-first path. Low value
|
||||
(copy-shaped) but the largest single uncovered block.
|
||||
|
||||
### G-5 — the unnamed-peer/unnamed-bind fail-loud arms (unix)
|
||||
### G-5: the unnamed-peer/unnamed-bind fail-loud arms (unix)
|
||||
|
||||
Two fail-loud guards the ADR pins (ADR-008 §3 — "a producer that
|
||||
Two fail-loud guards the ADR pins (ADR-008 §3, where "a producer that
|
||||
cannot determine the peer does not serve a bind-first listener over
|
||||
that substrate") never fire:
|
||||
|
||||
- `src/local/mod.rs:626-633` — the `accept_pair` unnamed-peer
|
||||
- `src/local/mod.rs:626-633`: the `accept_pair` unnamed-peer
|
||||
`handler_error` (an autobind client)
|
||||
- `src/local/mod.rs:578-583` — the `local_addr` unnamed-bind error
|
||||
- `src/local/mod.rs:578-583`, the `local_addr` unnamed-bind error
|
||||
|
||||
The existing unix bind-first test binds a named client socket
|
||||
deliberately (the parity case); no test binds an abstract/autobind
|
||||
unix listener.
|
||||
|
||||
Test: bind an abstract unix listener (Linux: a leading-`\0` path via
|
||||
`socket2` — already a `local` dependency, or `UnixListener::bind` on
|
||||
`socket2`, already a `local` dependency, or `UnixListener::bind` on
|
||||
an unnamed autobind socket), call `local_addr()` expecting the
|
||||
"no filesystem path" `handler_error`, and drive one autobind-client
|
||||
accept through `accept_pair` expecting the unnamed-peer error. Cheap,
|
||||
@@ -56,18 +56,18 @@ promise.
|
||||
|
||||
### Explicitly NOT in this task (review guidance)
|
||||
|
||||
- **G-7** — kernel-dependent readiness arms; leave uncovered.
|
||||
- **G-6** — the UDP empty-resolve arm; only if the `ResolveFn` seam
|
||||
refactor is ever wanted (optional, separate decision — do not do it
|
||||
here).
|
||||
- **G-1/G-3** — unreachable-by-construction serde/JoinError arms;
|
||||
- **G-7**, kernel-dependent readiness arms; leave uncovered.
|
||||
- **G-6** (the UDP empty-resolve arm:; only if the `ResolveFn` seam
|
||||
refactor is ever wanted; optional, separate decision, so do not do
|
||||
it here).
|
||||
- **G-1/G-3**, unreachable-by-construction serde/JoinError arms;
|
||||
leave uncovered.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] The unix `accept_loop` test added (G-4 — the 591-609 range
|
||||
- [ ] The unix `accept_loop` test added (G-4, the 591-609 range
|
||||
covered)
|
||||
- [ ] The abstract-listener test added (G-5 — both fail-loud arms
|
||||
- [ ] The abstract-listener test added (G-5, both fail-loud arms
|
||||
covered: `local_addr` unnamed-bind + `accept_pair`
|
||||
unnamed-peer)
|
||||
- [ ] `cargo test --features local` green; clippy `--all-targets
|
||||
@@ -91,7 +91,7 @@ promise.
|
||||
|
||||
### Implementation (2026-09-20)
|
||||
|
||||
**G-4** — `unix_accept_loop_stops_feeding_after_close_and_maps_the_drop`
|
||||
**G-4**: `unix_accept_loop_stops_feeding_after_close_and_maps_the_drop`
|
||||
in `tests/producer_listen.rs` (mirroring the TCP twin above it):
|
||||
`bind_unix` + a temp path under the test's own pid-scoped dir, the
|
||||
listener's `accept_loop` spawned against a pre-closed `AcceptQueue`,
|
||||
@@ -99,19 +99,19 @@ one unix client connect, and the loop's `handler_error` exit asserted
|
||||
within a 5s bound. Covers the full 591-609 range (the unix
|
||||
accept-first loop was previously never driven).
|
||||
|
||||
**G-5** — `unix_unnamed_bind_and_unnamed_peer_fail_loud` in
|
||||
`tests/local_bindfirst.rs` (`#[cfg(target_os = "linux")]` — abstract
|
||||
**G-5**: `unix_unnamed_bind_and_unnamed_peer_fail_loud` in
|
||||
`tests/local_bindfirst.rs` (`#[cfg(target_os = "linux")]`; abstract
|
||||
addresses + autobind are Linux-shaped). Both fail-loud arms pinned via
|
||||
the crate's own public API (no private-constructor seam needed):
|
||||
|
||||
- **Unnamed bind**: `bind_unix("")` — tokio passes the empty path
|
||||
- **Unnamed bind**: `bind_unix("")`, so tokio passes the empty path
|
||||
through to `bind(2)`, which the kernel autobinds (addlen ==
|
||||
`offsetof(sun_path)`, an abstract name, no filesystem path) —
|
||||
`local_addr` maps it to the "no filesystem path" handler error
|
||||
`offsetof(sun_path)`, an abstract name, no filesystem path),
|
||||
so `local_addr` maps it to the "no filesystem path" handler error
|
||||
(the 578-583 arm). Probed on this host: the bound addr reports
|
||||
`as_pathname() == None`.
|
||||
- **Unnamed peer**: a socket2 client bound to the empty abstract
|
||||
sockaddr (the autobind shape — verified `Ok` on this kernel)
|
||||
sockaddr (the autobind shape, verified `Ok` on this kernel)
|
||||
connects to a NAMED `bind_unix` listener; `accept_pair` refuses
|
||||
with the unnamed-peer handler error (the 626-633 arm). A plain
|
||||
unbound client behaves identically: mio's UDS accept maps ANY
|
||||
@@ -120,7 +120,7 @@ the crate's own public API (no private-constructor seam needed):
|
||||
catches both client shapes through tokio's accept.
|
||||
|
||||
`bind_unix_first` itself stays untested for the unnamed-bind arm at
|
||||
the establisher level (it composes `bind_unix` + `local_addr` — the
|
||||
the establisher level (it composes `bind_unix` + `local_addr`, and the
|
||||
arm is the same code path the test drives directly); the local_addr
|
||||
assertion covers the composition's error leg.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
id: tunnels/review-002-doc-sync
|
||||
name: "D-1 + D-2 — sync wire.md's schema bounds + ADR-007's oneOf→if/then + the establisher-time→registry-gate sentence"
|
||||
name: "D-1 + D-2. Sync wire.md's schema bounds + ADR-007's oneOf→if/then + the establisher-time→registry-gate sentence"
|
||||
status: completed
|
||||
depends_on: []
|
||||
scope: narrow
|
||||
@@ -15,23 +15,23 @@ tags: [docs, wire-format, adr-007, review-002, review-remediation]
|
||||
Two doc-only review 002 findings
|
||||
(`docs/reviews/002-graduation-implementation-review.md` §D-1, §D-2)
|
||||
batched: the shipped code is the wire-stable side and is correct; the
|
||||
*docs* lag it. Wire-contract-adjacent — do this before the first
|
||||
*docs* lag it. This is wire-contract-adjacent, so do it before the first
|
||||
consumer (alksocks) ships.
|
||||
|
||||
### D-1 — wire.md omits the `port` bounds the shipped schema enforces
|
||||
### D-1: wire.md omits the `port` bounds the shipped schema enforces
|
||||
|
||||
The shipped direct-op and forwarded-op schemas pin
|
||||
`"port": {"minimum": 0, "maximum": 65535}` plus
|
||||
`"additionalProperties": false` on the target/peer objects
|
||||
(`src/params.rs:208-222, 453-467`), and `listen` with
|
||||
`"minimum": 1` (`src/params.rs:442`). wire.md (the normative WHAT)
|
||||
says only `port: <u16>` and "integer > 0" in prose — it does not
|
||||
says only `port: <u16>` and "integer > 0" in prose; it does not
|
||||
record the bounds as schema properties, nor the
|
||||
`additionalProperties: false` gate that makes cross-shape targets
|
||||
fail the registry's `INVALID_INPUT` class BEFORE the establisher (the
|
||||
behavior `malformed_target_is_schema_class_and_leaves_no_channel`
|
||||
pins on the wire). The BAST doc (`bast.md`) covers only the
|
||||
data-plane codec, not the params schemas — no second source records
|
||||
data-plane codec, not the params schemas; no second source records
|
||||
these bounds.
|
||||
|
||||
Fix: record the exact schema constraints in wire.md's §The Direct Op
|
||||
@@ -41,15 +41,15 @@ sentence). The risk addressed: a future consumer implementing from
|
||||
wire.md alone accepting a shape the reference producer rejects (or
|
||||
vice versa).
|
||||
|
||||
### D-2 — ADR-007's `oneOf` wording is stale; the "validated at establisher time" sentence contradicts the shipped design
|
||||
### D-2: ADR-007's `oneOf` wording is stale; the "validated at establisher time" sentence contradicts the shipped design
|
||||
|
||||
1. ADR-007 §2 (line ~82) says "the target sub-schema is per-substrate
|
||||
(`oneOf` on the sibling `substrate` value, validated at establisher
|
||||
time — see Consequences)". The shipped schema (and wire.md §The
|
||||
time; see Consequences)". The shipped schema (and wire.md §The
|
||||
Forwarded Op, and the unit tests pinning it) use **`allOf` of two
|
||||
`if`/`then` branches** instead. `oneOf` of full-target-schemas
|
||||
would actually be WRONG here (a `unix`-substrate open would need to
|
||||
fail the `tcp` branch — plain `oneOf` on sibling-conditioned
|
||||
fail the `tcp` branch, so plain `oneOf` on sibling-conditioned
|
||||
subschemas is awkward; `if`/`then` composes correctly with the base
|
||||
`properties`/`required`). The code is right; the ADR's one-word
|
||||
schema-structure reference is stale.
|
||||
@@ -59,11 +59,11 @@ vice versa).
|
||||
the REGISTRY gate fires first (the establisher's dial never runs).
|
||||
The ADR/wire.md sentence should read "validated registry-side (the
|
||||
schema gate), before the establisher; re-checked by serde at
|
||||
establisher time as the backstop" — which is exactly what
|
||||
establisher time as the backstop", which is exactly what
|
||||
`src/params.rs`'s doc comments already say.
|
||||
|
||||
Fix: a one-line ADR-007 erratum (or an amendment note — house style
|
||||
has precedent: ADR-008 carries numbered amendments) replacing
|
||||
Fix: a one-line ADR-007 erratum (or an amendment note; the house
|
||||
style has precedent: ADR-008 carries numbered amendments) replacing
|
||||
`oneOf` with the shipped `allOf`-of-`if`/`then` structure, plus the
|
||||
corrected gate-ordering sentence; mirror the same
|
||||
gate-ordering correction in wire.md §The Direct Op's
|
||||
@@ -80,8 +80,8 @@ gate-ordering correction in wire.md §The Direct Op's
|
||||
- [ ] The "validated at establisher time" sentence corrected to
|
||||
"registry-side schema gate first; serde backstop at
|
||||
establisher time" in ADR-007 (and wire.md if it repeats it)
|
||||
- [ ] `cargo doc --no-deps` clean (docs changed — the verification
|
||||
battery's doc gate)
|
||||
- [ ] `cargo doc --no-deps` clean (docs changed, which trips the
|
||||
verification battery's doc gate)
|
||||
|
||||
## References
|
||||
|
||||
@@ -98,11 +98,11 @@ gate-ordering correction in wire.md §The Direct Op's
|
||||
## Implementation Notes (2026-09-20)
|
||||
|
||||
- **D-1 (wire.md §The Direct Op + §The Forwarded Op)**: both sections
|
||||
now record the exact shipped schema constraints — the per-branch
|
||||
now record the exact shipped schema constraints: the per-branch
|
||||
target/peer objects (`type: object`, `additionalProperties: false`,
|
||||
`port` bounds `minimum: 0`/`maximum: 65535`, the `unix` `path`
|
||||
branch) and the forwarded `listen` `minimum: 1` + its serde-pin
|
||||
backstop — with the gate-ordering sentence (registry-side schema
|
||||
backstop, plus the gate-ordering sentence (registry-side schema
|
||||
gate before the establisher, serde backstop) and the
|
||||
cross-shape-INVALID_INPUT behavior the integration test pins. No
|
||||
second source existed for these bounds (bast.md covers only the
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
id: tunnels/review-002-g2-graceful-teardown-observation
|
||||
name: "G-2 — observe close()'s EOF sentinel at the far accept dispatcher (+ N-2's write-happy-peer teardown pin)"
|
||||
name: "G-2. Observe close()'s EOF sentinel at the far accept dispatcher (+ N-2's write-happy-peer teardown pin)"
|
||||
status: completed
|
||||
depends_on: []
|
||||
scope: narrow
|
||||
@@ -13,19 +13,19 @@ tags: [tests, consumer, bind-first, teardown, review-002, review-remediation]
|
||||
## Description
|
||||
|
||||
Review 002 coverage gaps G-2 and the N-2 branch note, batched (the
|
||||
review itself suggests the same harness shape covers both — see its
|
||||
review itself suggests the same harness shape covers both; see its
|
||||
§Branch-coverage notes, last bullet).
|
||||
|
||||
### G-2 — `TunnelListener::close`'s `shutdown` arm never observed at the far end
|
||||
### G-2: `TunnelListener::close`'s `shutdown` arm never observed at the far end
|
||||
|
||||
`src/consumer.rs:529-531`: `close()` runs `write.shutdown().await`
|
||||
(the explicit EOF sentinel) then reaps — but no test currently
|
||||
(the explicit EOF sentinel) then reaps, but no test currently
|
||||
observes the FAR accept dispatcher's idle-read firing from a `close()`
|
||||
(the graceful path). The Drop path (implicit EOF → far teardown) IS
|
||||
observed end-to-end
|
||||
(`bindfirst_open_surfaces_reply_fields_and_teardown_cascades`), and
|
||||
the close path is pinned only at the reap level
|
||||
(`close_and_drop_reap_the_listen_channel`) — the sentinel's far-side
|
||||
(`close_and_drop_reap_the_listen_channel`), so the sentinel's far-side
|
||||
effect for `close()` specifically is asserted nowhere: the "the pushed
|
||||
accept was never consumed" assertion runs only in the Drop-shaped
|
||||
tests.
|
||||
@@ -36,15 +36,15 @@ observation.
|
||||
|
||||
Test: mirror `bindfirst_full_flow…`'s harness (the
|
||||
`tests/consumer_listener.rs` bind-first topology) with a `close()`
|
||||
instead of a drop — open against a bind-first producer, push an
|
||||
instead of a drop: open against a bind-first producer, push an
|
||||
accept, `listener.close().await`, then assert the far accept loop is
|
||||
gone (the same queue-len / never-consumed assertion the Drop tests
|
||||
use) and the serving-side listen channel reaped.
|
||||
|
||||
### N-2's testable residue — the write-happy-peer teardown posture
|
||||
### N-2's testable residue: the write-happy-peer teardown posture
|
||||
|
||||
The idle-stream EOF watch (`src/producer.rs:781-794`) triggers
|
||||
teardown on `Ok(0)`, `Err(_)`, AND `Ok(1..)` — a misbehaving opener
|
||||
teardown on `Ok(0)`, `Err(_)`, AND `Ok(1..)`; a misbehaving opener
|
||||
that writes to the idle listen channel also tears the listener down
|
||||
(fail-closed; the channel is not a data plane; the doc comment owns
|
||||
this). N-2 records the shape as correct, but the review's
|
||||
@@ -52,7 +52,7 @@ branch-coverage notes flag that the `Ok(_)` (non-empty read) arm is
|
||||
untested: a test would write a byte to the idle channel and assert
|
||||
far teardown, pinning the "any non-empty read tears down" posture.
|
||||
|
||||
Test: same harness — open the listen channel, write a byte into the
|
||||
Test: same harness. Open the listen channel, write a byte into the
|
||||
idle stream from the opener side, assert the accept loop cancels and
|
||||
the listener tears down (the fail-closed posture).
|
||||
|
||||
@@ -81,7 +81,7 @@ the listener tears down (the fail-closed posture).
|
||||
|
||||
Filled (2026-09-20):
|
||||
|
||||
- Both tests landed in `tests/consumer_listener.rs` — no `src/`
|
||||
- Both tests landed in `tests/consumer_listener.rs` with no `src/`
|
||||
changes needed (pure coverage additions):
|
||||
- `graceful_close_eof_sentinel_observed_at_far_accept_dispatcher`
|
||||
(G-2): mirrors the bind-first full-flow harness with a `close()`
|
||||
@@ -89,7 +89,7 @@ Filled (2026-09-20):
|
||||
accept), then asserts after `listener.close().await` that the
|
||||
serving-side listen channel reaped AND a follow-on pushed accept
|
||||
is never consumed (the same queue-len assertion the Drop-shaped
|
||||
tests use) — the sentinel's far-side effect for the graceful path.
|
||||
tests use), which is the sentinel's far-side effect for the graceful path.
|
||||
- `write_happy_peer_on_idle_listen_channel_tears_down_far_listener`
|
||||
(N-2 residue): opens the listen channel via the raw client
|
||||
surface (`open_channel_with_reply`, both halves held alive), proves
|
||||
@@ -97,9 +97,9 @@ Filled (2026-09-20):
|
||||
asserts the fail-closed teardown (channel reaped + the follow-on
|
||||
accept never consumed). The typed `TunnelListener` never exposes
|
||||
its idle write half by contract, so the misbehaving-opener shape
|
||||
requires the raw client — that shape difference is the point.
|
||||
requires the raw client; that shape difference is the point.
|
||||
- Harness notes: the write-happy test installs NO forwarded-op
|
||||
serving stub — the pre-write consumed accept fails its forwarded
|
||||
serving stub. The pre-write consumed accept fails its forwarded
|
||||
open (per-accept isolation), which is fine: the assertion only
|
||||
needs the queue emptied (loop alive), and the isolation path is
|
||||
pinned by `open_only_opener_still_opens_listen_but_forwarded_fails`.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
id: tunnels/review-002-m1-substrate-fallback
|
||||
name: "M-1 remediation — the accept dispatcher's silent substrate fallback (stamp the plan, fail loud)"
|
||||
name: "M-1 remediation: the accept dispatcher's silent substrate fallback (stamp the plan, fail loud)"
|
||||
status: completed
|
||||
depends_on: []
|
||||
scope: narrow
|
||||
@@ -28,7 +28,7 @@ let substrate = serde_json::from_value::<TunnelParams>(input.clone())
|
||||
In the wired path the fallback is unreachable (the registry schema gate
|
||||
and the establisher's `parse_params` both validate first), but the
|
||||
shape itself is wrong: a future drift would silently stamp `Tcp` into
|
||||
every forwarded open — for a UDP listen tunnel that is a
|
||||
every forwarded open; on a UDP listen tunnel that is a
|
||||
raw-vs-framed data-plane mismatch on the opener side (the exact
|
||||
invariant class review 001's U-1 was about), degrading silently
|
||||
instead of loudly.
|
||||
@@ -36,18 +36,18 @@ instead of loudly.
|
||||
### The fix (decide deliberately, then implement)
|
||||
|
||||
The review offers two shapes; **the second is recommended** (the
|
||||
review calls it strictly better, and the pre-consumer window is open —
|
||||
review calls it strictly better, and the pre-consumer window is open,
|
||||
`BindFirstPlan` has no external consumer yet):
|
||||
|
||||
1. Log-loud-and-return on parse failure (teardown-at-birth — the
|
||||
1. Log-loud-and-return on parse failure (teardown-at-birth, the
|
||||
same posture as every other early return in the file). Smaller,
|
||||
but keeps the second parse and the fallback branch.
|
||||
2. **Stamp the substrate into `BindFirstPlan` at establisher time and
|
||||
drop the second parse entirely** — one parse, one source of truth,
|
||||
drop the second parse entirely**: one parse, one source of truth,
|
||||
no fallback branch at all. The establisher already parsed the
|
||||
params successfully; the substrate is per-open-static; the plan is
|
||||
already the per-open carrier. `BindFirstPlan` gains a
|
||||
`substrate: Substrate` field (a plan-payload change — cheap now,
|
||||
`substrate: Substrate` field (a plan-payload change, cheap now,
|
||||
pre-consumer); the dispatcher reads it from the plan it already
|
||||
downcast.
|
||||
|
||||
@@ -61,10 +61,10 @@ plan (the dispatcher must not re-parse wire input).
|
||||
### Tests
|
||||
|
||||
- Keep the existing suite green (the two-open token-discipline test
|
||||
pins per-dispatcher stamping from its own plan — it now also pins
|
||||
pins per-dispatcher stamping from its own plan; it now also pins
|
||||
the substrate's origin implicitly).
|
||||
- A failure-path test: with shape 2 there is no failure branch left
|
||||
in the dispatcher (assert the removed fallback is gone — e.g. the
|
||||
in the dispatcher (assert the removed fallback is gone, that is, the
|
||||
forwarded opens for a UDP bind-first listen carry `substrate: udp`
|
||||
from the plan, not a re-parse). With shape 1: a malformed-input
|
||||
accept teardown test (loud return, channel reaps, no phantom
|
||||
@@ -85,7 +85,7 @@ plan (the dispatcher must not re-parse wire input).
|
||||
|
||||
- docs/reviews/002-graduation-implementation-review.md §M-1 (the
|
||||
finding, both fix shapes, and why not fixed inline)
|
||||
- src/producer.rs — `BindFirstPlan` (~line 97), the establisher, and
|
||||
- src/producer.rs: `BindFirstPlan` (~line 97), the establisher, and
|
||||
`make_bindfirst_accept_handler` (~line 755)
|
||||
- tasks/tunnels/bindfirst-producer.md (the original implementation
|
||||
task + Notes on the plan's shape)
|
||||
@@ -94,20 +94,20 @@ plan (the dispatcher must not re-parse wire input).
|
||||
|
||||
> Agent fills during implementation.
|
||||
|
||||
**Chosen shape: 2 — stamp the plan** (the review's recommendation).
|
||||
Rationale: one parse (the establisher's `parse_params` — which
|
||||
**Chosen shape: 2, stamp the plan** (the review's recommendation).
|
||||
Rationale: one parse (the establisher's `parse_params`, which
|
||||
rejects malformed input with `handler_error` before any channel
|
||||
exists), one source of truth; the substrate is per-open-static and
|
||||
the plan is already the per-open carrier; the fallback branch is not
|
||||
just guarded against, it is REMOVED — a future drift cannot degrade
|
||||
just guarded against, it is REMOVED: a future drift cannot degrade
|
||||
silently because there is no fallback to degrade into. Pre-consumer
|
||||
window used (`BindFirstPlan` has no external consumer yet — a
|
||||
window used (`BindFirstPlan` has no external consumer yet, so a
|
||||
plan-payload change is cheap).
|
||||
|
||||
Implemented:
|
||||
|
||||
- `BindFirstPlan` gains `substrate: Substrate` (doc comment states
|
||||
why it rides the plan — the dispatcher must not re-parse wire
|
||||
why it rides the plan; the dispatcher must not re-parse wire
|
||||
input; the removed fallback's data-plane mismatch risk is cited).
|
||||
The `Debug` impl stamps it (the opaque listener field stays).
|
||||
- `bindfirst_establisher` stamps the parsed `params.substrate` into
|
||||
@@ -120,7 +120,7 @@ Implemented:
|
||||
|
||||
Tests:
|
||||
|
||||
- Existing suite kept green — the two-open token-discipline test
|
||||
- Existing suite kept green: the two-open token-discipline test
|
||||
(`token_discipline_two_opens_distinct_tokens`) still pins
|
||||
per-dispatcher stamping from its own plan.
|
||||
- `bindfirst_plan_downcasts_through_channel_plan` (unit): the plan's
|
||||
@@ -130,18 +130,18 @@ Tests:
|
||||
stamps the parsed substrate into the plan (`Tcp` input →
|
||||
`plan.substrate == Tcp`).
|
||||
- **`forwarded_opens_carry_plan_stamped_substrate` (integration,
|
||||
the M-1 pin):** a UDP bind-first listen over the wire — the
|
||||
the M-1 pin):** a UDP bind-first listen over the wire; the
|
||||
forwarded open the dispatcher makes carries `substrate: udp` (the
|
||||
plan's stamp), observed through a substrate-recording
|
||||
forwarded-op stub on the opener side. The removed fallback would
|
||||
have stamped `Tcp`; the assert is `vec![Substrate::Udp]`.
|
||||
|
||||
Docs synced: `docs/architecture/producer.md` (§3 — the plan carries
|
||||
`{listener, token, substrate}`; §The Forwarded Op item 1 — the
|
||||
Docs synced: `docs/architecture/producer.md` (§3, the plan carries
|
||||
`{listener, token, substrate}`; §The Forwarded Op item 1, the
|
||||
substrate also comes from the plan, never re-parsed),
|
||||
`docs/architecture/decisions/008-listen-metadata-forwarded-op.md`
|
||||
(§1 — the plan's substrate stamp + the removed fallback, review 002
|
||||
M-1 referenced).
|
||||
(§1, the plan's substrate stamp + the removed fallback; review
|
||||
002's M-1 referenced).
|
||||
|
||||
Verification (all green): `cargo test` (145 tests across the lib +
|
||||
10 integration binaries), `cargo test --all-features` (153),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
id: tunnels/review-002-recorder
|
||||
name: "N-1..N-7 — record the review's no-action notes in the code/ADR trail (N-4's ADR line + N-3's telemetry note)"
|
||||
name: "N-1..N-7: record the review's no-action notes in the code/ADR trail (N-4's ADR line + N-3's telemetry note)"
|
||||
status: completed
|
||||
depends_on: []
|
||||
scope: narrow
|
||||
@@ -14,70 +14,71 @@ tags: [docs, notes, review-002, review-remediation]
|
||||
|
||||
Review 002's [note] findings
|
||||
(`docs/reviews/002-graduation-implementation-review.md` §N-1..N-7)
|
||||
are correct-as-is with **no code action** — but two of them ask for a
|
||||
are correct-as-is with **no code action**; but two of them ask for a
|
||||
durable trail beyond the review doc itself, and the rest are recorded
|
||||
here as the tracker (the review's own remediation list only orders
|
||||
M-1/D-*/the coverage gaps).
|
||||
|
||||
### The two notes wanting a durable trail
|
||||
|
||||
- **N-3** — `make_forwarded_op_handler` parses params BEFORE
|
||||
- **N-3**: `make_forwarded_op_handler` parses params BEFORE
|
||||
`accept_bi` (`src/producer.rs:993-1006`), so the R-02 accepted-flag
|
||||
telemetry reads `false` for a malformed-params open — the
|
||||
telemetry reads `false` for a malformed-params open; the
|
||||
birth-teardown hint fires for a handled-rejection case. The order
|
||||
is deliberately the better one (accepting first would hold a stream
|
||||
open for a params set the producer already got wrong). Record one
|
||||
sentence in the code's doc comment ("the accepted-flag telemetry
|
||||
reads false here by design — a malformed-params open IS a
|
||||
reads false here by design; a malformed-params open IS a
|
||||
birth-teardown shape") so a future alkcall reviewer doesn't flag
|
||||
the flag.
|
||||
- **N-4** — `SubstrateAddr`'s untagged leniency is schema-gated (the
|
||||
- **N-4**: `SubstrateAddr`'s untagged leniency is schema-gated (the
|
||||
schema's `additionalProperties: false` per branch is the real
|
||||
gate). Two latent sharp edges to keep in view, in the ADR trail:
|
||||
1. The establisher-level serde backstop keys `render_target` off
|
||||
the sibling `substrate` — a cross-shape target that sneaked past
|
||||
the sibling `substrate`, so a cross-shape target that sneaked past
|
||||
a looser registry would render *something* rather than fail.
|
||||
Worth an ADR line if a future consumer runs a schema-less
|
||||
registry variant. Add the line to ADR-007 (Consequences or the
|
||||
wire-stable section) while the doc-sync task's context is fresh
|
||||
— one sentence: "the serde backstop renders the sibling-keyed
|
||||
wire-stable section) while the doc-sync task's context is fresh.
|
||||
One sentence: "the serde backstop
|
||||
renders the sibling-keyed
|
||||
target; cross-shape garbage is the schema's absence, not a
|
||||
serde failure."
|
||||
2. If `SubstrateAddr` grows a variant whose shapes overlap (e.g. a
|
||||
future `{host, port, flowlabel}`), untagged disambiguation
|
||||
starts ordering — switch to an internally-tagged form at that
|
||||
starts ordering; switch to an internally-tagged form at that
|
||||
point. This is additive-only, pre-consumer; the ADR line should
|
||||
say "don't let it happen by accident."
|
||||
|
||||
### The no-action notes (recorded here as the tracker)
|
||||
|
||||
- **N-1** — `parse_listen_reply`'s positive-`listen` requirement
|
||||
- **N-1**: `parse_listen_reply`'s positive-`listen` requirement
|
||||
matches the forwarded params' wire contract (`minimum: 1`);
|
||||
JSON-null-as-absent is the standard lenient reading; the struct
|
||||
`deny_unknown_fields` warning in the doc comment is doing real
|
||||
work — keep both. No action.
|
||||
- **N-2** — the idle-stream EOF watch treats ANY byte as teardown
|
||||
work, so keep both. No action.
|
||||
- **N-2**: the idle-stream EOF watch treats ANY byte as teardown
|
||||
(fail-closed). Correct; the ADR sentence is thinner than the
|
||||
implementation — the write-happy-peer test pin is tracked in
|
||||
implementation; the write-happy-peer test pin is tracked in
|
||||
`tunnels/review-002-g2-graceful-teardown-observation`. Optionally
|
||||
widen ADR-008 §1's "its EOF cancels the loop" sentence to name the
|
||||
any-byte posture while editing (coordinate with
|
||||
`tunnels/review-002-doc-sync` if both touch ADR-008).
|
||||
- **N-5** — the token-mint discipline is sound; the 256-channel
|
||||
- **N-5**: the token-mint discipline is sound; the 256-channel
|
||||
per-connection cap makes the wrap-collision math even more
|
||||
favorable than the doc comment states. No action (optionally
|
||||
tighten the doc comment's math note).
|
||||
- **N-6** — the sleep-then-assert test pattern
|
||||
- **N-6**: the sleep-then-assert test pattern
|
||||
(`tests/consumer_listener.rs:276` + siblings) is the established
|
||||
house pattern; if CI ever flakes, the fix is a bounded poll loop on
|
||||
`channel_ids()` extended to the arrivals. No action now.
|
||||
- **N-7** — `test_bind()`'s bare `Arc<u32>` listener is fine (the
|
||||
- **N-7**: `test_bind()`'s bare `Arc<u32>` listener is fine (the
|
||||
downcast-miss probe covers the failure mode). No action.
|
||||
|
||||
### G-1/G-3 — the unreachable-by-construction arms (document-only)
|
||||
### G-1/G-3: the unreachable-by-construction arms (document-only)
|
||||
|
||||
Per the coverage appendix's recommendations: leave both uncovered,
|
||||
noted here as the review's "unreachable error arm" record —
|
||||
noted here as the review's "unreachable error arm" record:
|
||||
`TunnelSession::open`/`open_direct`/`TunnelListener::open` (+
|
||||
`open_reverse_channel`'s twin) params-serialize closures (serde_json
|
||||
cannot fail on these types) and `TunnelSession::join`'s pump-`Err`
|
||||
@@ -113,7 +114,7 @@ G-1's four duplicated closures into one `params_to_input` helper
|
||||
|
||||
> Agent fills during implementation.
|
||||
|
||||
## Implementation Notes (2026-09-20) — the review's no-action dispositions, recorded
|
||||
## Implementation Notes (2026-09-20): the review's no-action dispositions, recorded
|
||||
|
||||
The two durable-trail items landed as specified; the no-action notes'
|
||||
dispositions follow (this section is the tracker the task asked for).
|
||||
@@ -122,24 +123,24 @@ dispositions follow (this section is the tracker the task asked for).
|
||||
|
||||
- **N-3**: the parse-before-accept telemetry sentence added to
|
||||
`make_forwarded_op_handler`'s doc comment
|
||||
(`src/producer.rs`) — "the accepted-flag telemetry reads false here
|
||||
(`src/producer.rs`): "the accepted-flag telemetry reads false here
|
||||
by design; a malformed-params open IS a birth-teardown shape; do
|
||||
not flag the flag (review 002 N-3)".
|
||||
- **N-4**: both ADR-007 Consequences lines added — (1) the
|
||||
- **N-4**: both ADR-007 Consequences lines added: (1) the
|
||||
`SubstrateAddr` untagged-leniency-is-schema-gated paragraph (the
|
||||
sibling-keyed `render_target` backstop; "cross-shape garbage is the
|
||||
schema's absence, not a serde failure"; the schema-less-registry
|
||||
ADR-line-if-ever requirement) and (2) the overlapping-variants
|
||||
caution (untagged disambiguation starts ordering → switch to an
|
||||
internally-tagged form, deliberately, never by accident).
|
||||
- **N-2 (ADR-008 §1 widening — APPLIED, not declined)**: §1's
|
||||
- **N-2 (ADR-008 §1 widening: APPLIED, not declined)**: §1's
|
||||
accept-dispatcher bullet now states the any-byte idle-read posture
|
||||
(`Ok(0)` / error / any non-empty read all tear down; fail closed;
|
||||
the write-happy-peer test pins it) and names the `Ok(0)`-only
|
||||
hang-on-write-happy-peer trap the narrower reading would produce.
|
||||
Note: the doc-sync task and this task both touched ADR files but
|
||||
never ADR-008, so no coordination conflict materialized.
|
||||
- **N-5 (optionally tighten — done)**: `mint_token`'s doc comment now
|
||||
- **N-5 (optionally tighten: done)**: `mint_token`'s doc comment now
|
||||
records the review's cap analysis (the token is
|
||||
per-producer-connection; the 256-channels-per-connection cap makes
|
||||
the live-collision window unreachable by a wide margin).
|
||||
@@ -149,21 +150,21 @@ dispositions follow (this section is the tracker the task asked for).
|
||||
- **N-1**: no action. `parse_listen_reply`'s positive-`listen`
|
||||
requirement matches the forwarded params' `minimum: 1`; JSON-null-
|
||||
as-absent is the standard lenient reading; the `deny_unknown_fields`
|
||||
warning in `TunnelListenReply`'s doc comment is doing real work —
|
||||
kept as-is.
|
||||
warning in `TunnelListenReply`'s doc comment is doing real work and
|
||||
is kept as-is.
|
||||
- **N-2's code**: no action (fail-closed any-byte watch is correct as
|
||||
shipped; only the ADR sentence was widened, above).
|
||||
- **N-6**: no action. Sleep-then-assert is the established house
|
||||
pattern (3x-repeat stable historically); if CI ever flakes on the
|
||||
forwarded-arrival tests, the fix is a bounded poll loop on
|
||||
`channel_ids()` extended to the arrivals — recorded here, not
|
||||
`channel_ids()` extended to the arrivals; recorded here, not
|
||||
implemented.
|
||||
- **N-7**: no action. `test_bind()`'s bare `Arc<u32>` listener is
|
||||
fine; the downcast-miss probe
|
||||
(`accept_pair_loop_downcast_miss_fails_loud`) covers the failure
|
||||
mode.
|
||||
- **G-1/G-3 (the unreachable-by-construction arms)**: left uncovered,
|
||||
per the review's recommendations — `open`/`open_direct`/
|
||||
per the review's recommendations: `open`/`open_direct`/
|
||||
`TunnelListener::open` (+ `open_reverse_channel`'s twin)
|
||||
params-serialize closures (serde_json cannot fail on these types)
|
||||
and `join`'s pump-`Err` arm (unreachable through the public API; the
|
||||
@@ -176,4 +177,4 @@ dispositions follow (this section is the tracker the task asked for).
|
||||
|
||||
- `cargo fmt --check`; `cargo clippy --all-targets -D warnings`
|
||||
(native + wasm32); `cargo test` (all suites + doc-tests);
|
||||
`cargo doc --no-deps` — all green.
|
||||
`cargo doc --no-deps` are all green.
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
id: tunnels/review-002-trivial-batch
|
||||
name: "Trivial batch — D-3 (rename the overpromising schema test helper) + D-4 (trim the stale CHANGELOG preamble)"
|
||||
name: "Trivial batch: D-3 (rename the overpromising schema test helper) + D-4 (trim the stale CHANGELOG preamble)"
|
||||
status: completed
|
||||
depends_on: []
|
||||
scope: narrow
|
||||
@@ -13,13 +13,13 @@ tags: [tests, changelog, review-002, review-remediation]
|
||||
## Description
|
||||
|
||||
Two trivial review 002 findings
|
||||
(`docs/reviews/002-graduation-implementation-review.md`) batched —
|
||||
each a one-line-scale change; no behavior impact.
|
||||
(`docs/reviews/002-graduation-implementation-review.md`) batched:
|
||||
each is a one-line-scale change with no behavior impact.
|
||||
|
||||
### D-3 — the test helper's name overpromises
|
||||
### D-3: the test helper's name overpromises
|
||||
|
||||
`src/params.rs:628-637`: `jsonschema_valid` parses the value with
|
||||
serde and pattern-matches the substrate string — it validates the
|
||||
serde and pattern-matches the substrate string, so it validates the
|
||||
serde layer, NOT the JSON Schema (the registry compiles the real
|
||||
validators; adding a `jsonschema` dev-dependency purely for this unit
|
||||
test is not warranted). The call-site
|
||||
@@ -38,18 +38,18 @@ end-to-end by the integration probes
|
||||
`malformed_target_is_schema_class…`, the forwarded `listen: 0`
|
||||
gate)"). Do NOT add a `jsonschema` dev-dependency.
|
||||
|
||||
### D-4 — the CHANGELOG "Unreleased" preamble is stale
|
||||
### D-4: the CHANGELOG "Unreleased" preamble is stale
|
||||
|
||||
`CHANGELOG.md`'s Unreleased section still opens with "implementation
|
||||
is decomposed in `tasks/tunnels/` and lands in the next release…
|
||||
the only behavior-affecting change so far (no wire or API change at
|
||||
this level yet)" — written before the six graduation implementation
|
||||
this level yet)", written before the six graduation implementation
|
||||
commits. The `### Added` section below it correctly lists all six
|
||||
features; the preamble contradicts the section it introduces.
|
||||
|
||||
Fix: trim the preamble to a one-liner (e.g. "The graduation
|
||||
implementation (ADRs 007/008) landed — see Added below." — final
|
||||
wording agent's choice) or drop it.
|
||||
implementation (ADRs 007/008) landed; see Added below." (the final
|
||||
wording is the agent's choice) or drop it.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
@@ -73,14 +73,14 @@ wording agent's choice) or drop it.
|
||||
|
||||
- **D-3**: inlined the honest serde check directly into the renamed
|
||||
test `serde_rejects_unknown_substrate_value` (the `jsonschema_valid`
|
||||
helper deleted — it had exactly one call-site, and its
|
||||
helper deleted, since it had exactly one call-site, and its
|
||||
`matches!`-with-guard body was convoluted beyond its job). The doc
|
||||
comment names the integration probes that pin the real registry-side
|
||||
gate end-to-end. No `jsonschema` dev-dependency added (the
|
||||
structural-assertion sibling `direct_target_schema_gates_shape_per_
|
||||
substrate` is untouched and stays honest).
|
||||
- **D-4**: trimmed the preamble to the one-liner ("The graduation
|
||||
implementation … landed — see Added below."); the stale
|
||||
implementation … landed; see Added below."); the stale
|
||||
`tasks/tunnels/` sentence and the "only behavior-affecting change"
|
||||
claim are gone.
|
||||
- Verification: `cargo fmt --check`, `cargo clippy --all-targets -D
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
id: tunnels/review-core-crates
|
||||
name: Mid-phase review — producer + consumer halves before the local feature
|
||||
name: Mid-phase review, producer + consumer halves before the local feature
|
||||
status: completed
|
||||
depends_on: [tunnels/consumer-session]
|
||||
scope: moderate
|
||||
@@ -12,22 +12,22 @@ tags: [review, injection-point]
|
||||
|
||||
## Description
|
||||
|
||||
Review injection point (SDD §Review Injection — "high-risk tasks: review
|
||||
Review injection point (SDD §Review Injection: "high-risk tasks: review
|
||||
before proceeding"): `tunnels/producer-open-op` and
|
||||
`tunnels/consumer-session` are the two `risk: high, impact: phase` tasks
|
||||
and the crate's wire/API surface. Review them BEFORE
|
||||
`tunnels/local-socket-halves` and `tunnels/end-to-end-suite` build
|
||||
against the shapes — a wire or API mistake found here costs one task's
|
||||
rework; found later it costs three.
|
||||
against the shapes; a wire or API mistake found here costs one task's
|
||||
rework, while found later it costs three.
|
||||
|
||||
### Checklist (focused — the full gate is `tunnels/review-impl`)
|
||||
### Checklist (focused; the full gate is `tunnels/review-impl`)
|
||||
|
||||
1. Wire conformance of what landed (params/codec/open-op vs wire.md +
|
||||
ADR-001/003) — the one-way-door check.
|
||||
ADR-001/003), the one-way-door check.
|
||||
2. The pump handler shape: `pump_bidi` inline, JoinHandle tracks the
|
||||
data plane (R-02) — the POC's hang-bug class must be structurally
|
||||
data plane (R-02), so the POC's hang-bug class must be structurally
|
||||
absent.
|
||||
3. Teardown matrix of `TunnelSession` — close/join/Drop soundness, no
|
||||
3. Teardown matrix of `TunnelSession`: close/join/Drop soundness, no
|
||||
leaks (the W3 class).
|
||||
4. No substrate types outside `src/local/`-to-be; no hand-rolled
|
||||
two-pump loops; no side-channel handoff.
|
||||
@@ -60,38 +60,38 @@ items were run against `producer.rs`/`consumer.rs`/`params.rs`/
|
||||
alkcall 0.7.0's wrapper/establisher/pump source. Because both
|
||||
downstream tasks built on the shapes immediately after, the full
|
||||
re-verification (with the complete suite) landed in
|
||||
`tunnels/review-impl` — consolidated findings live in
|
||||
`tunnels/review-impl`; consolidated findings live in
|
||||
`docs/reviews/001-implementation-review.md` (the review-impl
|
||||
deliverable). Verdicts, summarized:
|
||||
|
||||
1. **Wire conformance — PASS.** Params shape exact
|
||||
1. **Wire conformance: PASS.** Params shape exact
|
||||
(`{resource, substrate}`, `deny_unknown_fields`, enum
|
||||
`["tcp","udp","unix"]` — schema + serde both pinned by tests); op
|
||||
`["tcp","udp","unix"]`, schema + serde both pinned by tests); op
|
||||
id/ALPN/scope constants asserted against wire.md; codec
|
||||
(`[len: u16 BE]`, `len=0` legal, `Oversize` at frame time, no
|
||||
sentinel) correct.
|
||||
2. **Pump handler shape — PASS.** `pump_bidi` awaited inline inside
|
||||
2. **Pump handler shape: PASS.** `pump_bidi` awaited inline inside
|
||||
the spawned handler task; `accept_bi` → plan downcast →
|
||||
`Arc::try_unwrap` → inline await; no spawn-and-forget, no
|
||||
hand-rolled two-pump loop (grep-verified); R-02 pinned by an
|
||||
end-to-end test (`pump_handle_tracks_the_data_plane_r02`).
|
||||
3. **Teardown matrix — PASS.** close (abort + reap), join (await +
|
||||
3. **Teardown matrix: PASS.** close (abort + reap), join (await +
|
||||
reap + counts, panic arm logged), Drop (abort + sync reap),
|
||||
pump-less join `(0, 0, reaped)`, failed-adopt no-leak — all pinned
|
||||
pump-less join `(0, 0, reaped)`, failed-adopt no-leak; all pinned
|
||||
with `channel_ids()` leak asserts on both sides. No `Clone`
|
||||
(compile-fail doctest).
|
||||
4. **No substrate types / no hand-rolled pumps / no side-channel
|
||||
handoff — PASS.** The only plan flow is `Establishment::new(plan)`
|
||||
handoff: PASS.** The only plan flow is `Establishment::new(plan)`
|
||||
→ wrapper → handler `plan` param (R-01); the only `Mutex<HashMap>`
|
||||
is the `ResourceRegistry` lookup table (a registry, not a plan
|
||||
handoff); `tokio::net` appears only under the `local` gate.
|
||||
5. **Tests green + 3× repeat-run stable — PASS** (both configs).
|
||||
5. **Tests green + 3× repeat-run stable: PASS** (both configs).
|
||||
|
||||
### Majors logged (carried to `tunnels/review-impl`, re-verified there)
|
||||
|
||||
- **C-1** (`AcceptQueue::pop` lost wakeup — found in the full sweep,
|
||||
- **C-1** (`AcceptQueue::pop` lost wakeup, found in the full sweep and
|
||||
fixed in-review): see review 001 §C-1.
|
||||
- **U-1** (the `local` UDP adapter is not the framed adapter — found
|
||||
- **U-1** (the `local` UDP adapter is not the framed adapter, found
|
||||
by the review-impl probes over real sockets): see review 001 §U-1;
|
||||
remediation task `tunnels/fix-udp-framed-adapter`.
|
||||
|
||||
|
||||
@@ -30,9 +30,9 @@ surface (ADR-001/002/003) is a one-way door, and the API surface
|
||||
- typed errors: the five establishment reasons + `FORBIDDEN` +
|
||||
`channel:too_many_channels` surfaces
|
||||
2. **Producer conformance** (producer.md):
|
||||
- establisher = the awaited bounded phase; plan flow (R-01) — no
|
||||
- establisher = the awaited bounded phase; plan flow (R-01), meaning no
|
||||
side-channel handoff anywhere (grep for `Mutex<HashMap>` +
|
||||
poll-loop take patterns — the POC's dead shape)
|
||||
poll-loop take patterns, the POC's dead shape)
|
||||
- pump handler: `pump_bidi` inline, no substrate types, JoinHandle
|
||||
tracks the data plane (R-02)
|
||||
- registration: post-hoc supported (W2); `ChannelOperations::
|
||||
@@ -81,21 +81,21 @@ tasks; minors get a follow-up batch.
|
||||
|
||||
> Agent fills during implementation.
|
||||
|
||||
### Review executed 2026-09-08 (tree `2efac9b`) — deliverable: `docs/reviews/001-implementation-review.md`
|
||||
### Review executed 2026-09-08 (tree `2efac9b`); deliverable: `docs/reviews/001-implementation-review.md`
|
||||
|
||||
Method: full source read (`src/` + `tests/`) against the complete spec
|
||||
set; alkcall 0.7.0 API surfaces verified in source (wrapper, establisher
|
||||
types, client errors, manager adopt/teardown, `pump_bidi`); executable
|
||||
probes over real sockets for the `local` UDP path (temporary test file,
|
||||
deleted after the run — probe evidence quoted in the review); the full
|
||||
deleted after the run, with the probe evidence quoted in the review); the full
|
||||
verification battery (see below). The mid-phase `tunnels/
|
||||
review-core-crates` checklist is subsumed (consolidated in the review
|
||||
doc; that task's Notes carry its verdicts).
|
||||
|
||||
### The five checklist verdicts
|
||||
|
||||
1. **Wire conformance — PASS.** Params shape exact (`deny_unknown_
|
||||
fields`, required both, enum `["tcp","udp","unix"]` — the OQ-TN-14
|
||||
1. **Wire conformance: PASS.** Params shape exact (`deny_unknown_
|
||||
fields`, required both, enum `["tcp","udp","unix"]`; the OQ-TN-14
|
||||
note in this checklist predates the OQ's resolution; `unix` SHIPS
|
||||
per OQ-TN-14's resolution, and the enum carries it correctly); op
|
||||
id/ALPN/scope pinned; codec correct including `len=0` and frame-time
|
||||
@@ -104,58 +104,58 @@ doc; that task's Notes carry its verdicts).
|
||||
`resource_shortage`; `handler_error` through the same `From` arm;
|
||||
`timeout` by the hanging-establisher probe) + `FORBIDDEN` +
|
||||
`channel:too_many_channels`.
|
||||
2. **Producer conformance — PASS.** Awaited bounded establisher (10s
|
||||
2. **Producer conformance: PASS.** Awaited bounded establisher (10s
|
||||
default + per-registration override proven); pure R-01 plan flow
|
||||
(grep-verified — no side-channel handoff, no poll-loop take); pump
|
||||
(grep-verified: no side-channel handoff, no poll-loop take); pump
|
||||
handler structurally R-02 (`pump_bidi` inline; JoinHandle tracks
|
||||
the data plane, pinned by test); post-hoc registration (W2)
|
||||
supported; listen variant rides the same op with the typed-error
|
||||
table proven.
|
||||
3. **Consumer conformance — PASS.** Full session surface per ADR-005;
|
||||
teardown matrix sound (close/join/Drop/pump-less/failed-adopt — all
|
||||
3. **Consumer conformance: PASS.** Full session surface per ADR-005;
|
||||
teardown matrix sound (close/join/Drop/pump-less/failed-adopt, all
|
||||
leak-asserted on both managers); no `Clone` (compile-fail doctest).
|
||||
One documented divergence: `adopt` takes the substrate (the task
|
||||
sketch omitted it — a datagram session cannot exist without it).
|
||||
4. **Conventions sweep — PASS.** No unwrap/expect/panic outside
|
||||
sketch omitted it, since a datagram session cannot exist without it).
|
||||
4. **Conventions sweep: PASS.** No unwrap/expect/panic outside
|
||||
`#[cfg(test)]` (grep-verified); thiserror everywhere; all locks are
|
||||
`tokio::sync::Mutex` (unpoisoned — the `into_inner` rule has no
|
||||
`tokio::sync::Mutex` (unpoisoned: the `into_inner` rule has no
|
||||
application point, N/A); substrate types confined to `src/local/`
|
||||
+ feature gate; wasm clean (`check` + `clippy --target
|
||||
wasm32-unknown-unknown`, `-D warnings` both); `pump_bidi` consumed,
|
||||
never hand-rolled. Inline `//` comments are the
|
||||
safety-constraint class convention 1 allows.
|
||||
5. **Docs ↔ implementation sync — PASS with drift fixed in-review.**
|
||||
5. **Docs ↔ implementation sync: PASS with drift fixed in-review.**
|
||||
Module map matches; ADRs all Accepted (implementation confirms); N-5
|
||||
(stale doc forward-reference), N-7 (broken doc link), N-8 (missing
|
||||
root re-exports) fixed in-review.
|
||||
|
||||
### Findings (full detail in the review doc)
|
||||
|
||||
- **U-1 [major]** — the `local` UDP adapter is NOT the framed adapter
|
||||
- **U-1 [major]**, the `local` UDP adapter is NOT the framed adapter
|
||||
ADR-003 mandates: codec-framed bytes reach real targets; empty
|
||||
datagrams are EOF-shaped at the adapter (tunnel teardown);
|
||||
>8 KiB datagrams split; unframed target-initiated datagrams are
|
||||
dropped. Executable-pinned by probes. **NOT fixed — remediation
|
||||
dropped. Executable-pinned by probes. **NOT fixed; remediation
|
||||
task `tunnels/fix-udp-framed-adapter`** (blocks UDP-over-`local`
|
||||
consumers; wire surface unaffected).
|
||||
- **C-1 [major]** — `AcceptQueue::pop` lost wakeup in the
|
||||
- **C-1 [major]**: `AcceptQueue::pop` lost wakeup in the
|
||||
check→register window (open op hangs to the establishment bound on
|
||||
a lost push). **Fixed in-review** (create-`Notified`-before-check
|
||||
per iteration, the tokio `notify_waiters` contract; regression test
|
||||
added).
|
||||
- **U-2/U-3 [minor]** — `connect_udp` v4-only bind posture; 65535-byte
|
||||
- **U-2/U-3 [minor]**: `connect_udp` v4-only bind posture; 65535-byte
|
||||
per-tunnel scratch. Riders on the U-1 task.
|
||||
- **N-4/N-5/N-7/N-8/N-9/N-10/N-11 [minor]** — fixed in-review (payload
|
||||
- **N-4/N-5/N-7/N-8/N-9/N-10/N-11 [minor]**, fixed in-review (payload
|
||||
serialization, doc staleness, doc link, root re-exports, README +
|
||||
publish dry-run, harness comment, `pump_against` UDP framing doc
|
||||
note).
|
||||
- **N-2** — retracted during the review by executable falsification
|
||||
- **N-2**, retracted during the review by executable falsification
|
||||
(the codec's buffering is bounded by construction; the u16 cap IS
|
||||
the bound).
|
||||
- **N-6** — non-finding with rationale (`TunnelEstablishError` has no
|
||||
- **N-6**, a non-finding with rationale (`TunnelEstablishError` has no
|
||||
`Timeout` variant, correctly).
|
||||
|
||||
No criticals — the phase-gate passes.
|
||||
There are no criticals; the phase-gate passes.
|
||||
|
||||
### Verification battery (all green)
|
||||
|
||||
@@ -173,7 +173,7 @@ No criticals — the phase-gate passes.
|
||||
> Agent fills this on completion.
|
||||
|
||||
Phase-gate review complete: the wire surface (the one-way door) is
|
||||
conformant; the API surface matches ADR-005/006; two majors found —
|
||||
conformant; the API surface matches ADR-005/006; two majors found:
|
||||
U-1 (local UDP framing, remediation task created) and C-1 (queue lost
|
||||
wakeup, fixed with the review commit). The trivial batch + README +
|
||||
publish dry-run also landed with the review. Findings with
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
id: tunnels/tunnel-listener
|
||||
name: TunnelListener — the listen-flow consumer session (ADR-008 §4)
|
||||
name: TunnelListener, the listen-flow consumer session (ADR-008 §4)
|
||||
status: completed
|
||||
depends_on: [tunnels/bindfirst-params, tunnels/forwarded-receipt]
|
||||
scope: moderate
|
||||
@@ -12,14 +12,14 @@ tags: [consumer, listener, listen-flow, teardown, tunnel-listener]
|
||||
|
||||
## Description
|
||||
|
||||
Implement `TunnelListener` (consumer.md §The Listen Flow — ADR-008
|
||||
Implement `TunnelListener` (consumer.md §The Listen Flow, ADR-008
|
||||
§4): the listen-flow sibling of `TunnelSession`. It OPENS a listen
|
||||
tunnel (`channels/tunnel/sub` — the establisher shape is the
|
||||
tunnel (`channels/tunnel/sub`; the establisher shape is the
|
||||
producer's choice, invisible here), reads the bind-first reply fields
|
||||
(`bound`/`listen`), presents them, and owns the listen channel's
|
||||
teardown (ADR-005's ownership posture extended to the listen flow).
|
||||
Forwarded connections arrive as ordinary adopted-side `TunnelSession`s
|
||||
on the opener's manager — `adopt` gains nothing.
|
||||
on the opener's manager; `adopt` gains nothing.
|
||||
|
||||
### The type
|
||||
|
||||
@@ -39,45 +39,45 @@ impl TunnelListener {
|
||||
pub fn bound(&self) -> Option<&SubstrateAddr>;
|
||||
pub fn listen(&self) -> Option<u32>;
|
||||
pub async fn close(self) -> bool; // tear the listen channel down
|
||||
// Drop: sync-reap, best-effort — ADR-005's Drop posture
|
||||
// Drop: sync-reap, best-effort (ADR-005's Drop posture)
|
||||
}
|
||||
```
|
||||
|
||||
- **`open`** — `open_channel_with_reply(OP_TUNNEL_OPEN, params,
|
||||
- **`open`**: `open_channel_with_reply(OP_TUNNEL_OPEN, params,
|
||||
TUNNEL_ALPN)` (the U-2 surface, landed alkcall 0.8.0): the full
|
||||
reply arrives, `listen_reply_fields` extracts
|
||||
`bound`/`listen`, `adopt`-free construction (the client already
|
||||
adopted the channel — the streams come back from
|
||||
adopted the channel, since the streams come back from
|
||||
`open_channel_with_reply`), and the listener holds the channel
|
||||
entry. The listen channel's own `BiStream` is held but IDLE — the
|
||||
entry. The listen channel's own `BiStream` is held but IDLE: the
|
||||
listener session's drop/EOF is the teardown signal the far accept
|
||||
dispatcher watches (ADR-008 §1's contract); `TunnelListener` never
|
||||
pumps its own stream.
|
||||
- **The held BiStream (the one new thing):** unlike
|
||||
`TunnelSession` (which presents a data plane), a listen session's
|
||||
stream has NO data-plane role — its only job is to EOF when the
|
||||
stream has NO data-plane role; its only job is to EOF when the
|
||||
session drops. Keep the stream (or its write half) alive in the
|
||||
struct so the drop → EOF ordering is structural (the mux sender
|
||||
drops → EOF sentinel). Do NOT expose the stream publicly —
|
||||
drops → EOF sentinel). Do NOT expose the stream publicly;
|
||||
pumping on a listen channel is meaningless (the far side's
|
||||
dispatcher ignores it too).
|
||||
- **`close(self)`/`Drop`** — abort nothing (no pump), reap the
|
||||
- **`close(self)`/`Drop`**: abort nothing (no pump), reap the
|
||||
adopted channel entry (`teardown_channel`). The far-side teardown
|
||||
cascades (EOF → accept-loop cancel → listener drop). Forwarded
|
||||
sessions are NOT owned by the listener (they arrive as separate
|
||||
adopted sessions the assembly correlates + owns) — document the
|
||||
adopted sessions the assembly correlates + owns). Document the
|
||||
split (listener teardown does not cascade to already-adopted
|
||||
forwarded sessions; the far listener dying stops NEW accepts).
|
||||
- **No `Clone`** — same posture as `TunnelSession` (aliasing would
|
||||
- **No `Clone`**, same posture as `TunnelSession` (aliasing would
|
||||
alias teardown). Compile-assert with a doc-test like the session's.
|
||||
- **`forwarded()`**: NOT a method on `TunnelListener` — forwarded
|
||||
- **`forwarded()`**: NOT a method on `TunnelListener`. Forwarded
|
||||
sessions arrive on the opener's manager as adopted channels the
|
||||
assembly correlates via the `listen` token (consumer.md's posture;
|
||||
the correlation registry is assembly-side). The type hands the
|
||||
assembly the token (`listen()`); it does not own the receipt
|
||||
queue. (The task sketch in consumer.md showed `listener.forwarded()`
|
||||
— the honest shape is the assembly's map; document if a v1
|
||||
convenience accessor is still wanted — decide, don't silently
|
||||
queue. (The task sketch in consumer.md showed `listener.forwarded()`,
|
||||
but the honest shape is the assembly's map; document if a v1
|
||||
convenience accessor is still wanted: decide, don't silently
|
||||
drop.)
|
||||
|
||||
### The direct path (ADR-007's consumer ctor lives in `tunnels/direct-op`; this task adds nothing to `TunnelSession`)
|
||||
@@ -85,7 +85,7 @@ impl TunnelListener {
|
||||
Per ADR-008 §4: "`TunnelSession::adopt` gains nothing." Verify the
|
||||
claim while implementing: a forwarded channel adopted by the opener's
|
||||
assembly (`manager.adopt_channel` or `TunnelSession::adopt`) presents
|
||||
the same session/data-plane/teardown semantics — pin it with a test
|
||||
the same session/data-plane/teardown semantics; pin it with a test
|
||||
(adopt a forwarded channel, pump, teardown).
|
||||
|
||||
### Tests
|
||||
@@ -101,7 +101,7 @@ the same session/data-plane/teardown semantics — pin it with a test
|
||||
- Adopt-path pin: the forwarded session adopt + pump + teardown.
|
||||
- Scope interaction: an opener identity with `tunnel:open` but NOT
|
||||
`tunnel:forwarded` still opens the listen channel (that gate is on
|
||||
the producer side) — forwarded opens to it then fail FORBIDDEN
|
||||
the producer side), and forwarded opens to it then fail FORBIDDEN
|
||||
(the per-accept isolation path from `tunnels/bindfirst-producer`).
|
||||
|
||||
## Acceptance Criteria
|
||||
@@ -112,7 +112,7 @@ the same session/data-plane/teardown semantics — pin it with a test
|
||||
BiStream's EOF is the structural teardown trigger; no
|
||||
`Clone`
|
||||
- [x] Forwarded sessions are assembly-adopted ordinary
|
||||
`TunnelSession`s (adopt gains nothing — pinned by test)
|
||||
`TunnelSession`s (adopt gains nothing; pinned by test)
|
||||
- [x] Integration tests: bind-first e2e with the listener type,
|
||||
teardown cascade, Option posture, adopt-path pin
|
||||
- [x] Clippy/fmt clean; wasm32 check passes
|
||||
@@ -121,13 +121,13 @@ the same session/data-plane/teardown semantics — pin it with a test
|
||||
## References
|
||||
|
||||
- docs/architecture/decisions/008-listen-metadata-forwarded-op.md
|
||||
(§4 the consumer API; Amendment 1 — the token the `listen()` view
|
||||
(§4 the consumer API; Amendment 1, the token the `listen()` view
|
||||
reads)
|
||||
- docs/architecture/decisions/005-consumer-session-owns-teardown.md
|
||||
(the ownership posture extended to the listen flow)
|
||||
- docs/architecture/consumer.md §The Listen Flow
|
||||
- tasks/tunnels/consumer-session.md (the TunnelSession shape this
|
||||
siblings — close/join/Drop posture, the no-Clone assert)
|
||||
siblings: close/join/Drop posture, the no-Clone assert)
|
||||
- tasks/tunnels/bindfirst-params.md (the reply reader);
|
||||
tasks/tunnels/forwarded-receipt.md (the serving half the tests
|
||||
compose against)
|
||||
@@ -138,15 +138,15 @@ the same session/data-plane/teardown semantics — pin it with a test
|
||||
`BiStream`.** The task sketch said "hold the stream (or its write
|
||||
half) alive"; the honest shape is the write half alone: the mux
|
||||
sender's drop is the structural EOF trigger (the mux pump writes
|
||||
the implicit-EOF chunk for the channel — REQ-CH-01's implicit path —
|
||||
the implicit-EOF chunk for the channel, REQ-CH-01's implicit path,
|
||||
which the far accept dispatcher's idle-stream watch observes as the
|
||||
zero-byte sentinel). Holding the read half too would add nothing
|
||||
(nothing ever reads it) and would keep the demux receiver alive.
|
||||
`close()` additionally calls `shutdown()` first (the explicit
|
||||
sentinel — the graceful path) before the reap; `Drop` relies on the
|
||||
sentinel, the graceful path) before the reap; `Drop` relies on the
|
||||
implicit path.
|
||||
- **`forwarded()` decided: not provided.** Per the task's "decide,
|
||||
don't silently drop": the honest shape is the assembly's map — the
|
||||
don't silently drop": the honest shape is the assembly's map. The
|
||||
listener hands the assembly the token via `listen()`; it does not
|
||||
own the receipt queue. A v1 convenience accessor would either own a
|
||||
queue (duplicating assembly state) or wrap one (an accessor over
|
||||
@@ -154,9 +154,10 @@ the same session/data-plane/teardown semantics — pin it with a test
|
||||
.forwarded()` sketch line was already marked as the task-sketch
|
||||
shape; the docs (type docs + consumer.md) state the split.
|
||||
- **`open` is the ONLY construction path.** No `adopt` (the listener
|
||||
is an opener-flow type — the open reply carries the correlation
|
||||
is an opener-flow type; the open reply carries the correlation
|
||||
material) and no `join` (there is no pump to await; the task's
|
||||
sketch has no `join` either — teardown is `close`/`Drop` only).
|
||||
sketch has no `join` either, since teardown is `close`/`Drop`
|
||||
only).
|
||||
- **Teardown ordering nuance (test-discovered, documented):** the
|
||||
far-side wrapper's teardown waits on its handler's R-02 await,
|
||||
which tracks every per-accept pump it spawned. The listener's EOF
|
||||
@@ -165,15 +166,15 @@ the same session/data-plane/teardown semantics — pin it with a test
|
||||
winds down. The e2e drops the local BIND client's end before
|
||||
asserting the far reap (the forwarded pump's own EOF path).
|
||||
- **`close()` on a dial-producer listener (both fields `None`)** is
|
||||
ordinary: the Option posture does not affect teardown — the entry
|
||||
ordinary: the Option posture does not affect teardown; the entry
|
||||
reaps regardless of what the establisher contributed.
|
||||
- **Harness addition (`wire_listener_bindfirst`, ListenerTopology):**
|
||||
the listen-flow topology — the OPENER dials the transport (connect
|
||||
the listen-flow topology: the OPENER dials the transport (connect
|
||||
side, `from_connection_with_serving`: it can both open the listen
|
||||
tunnel and serve the forwarded op post-hoc via its captured
|
||||
registry); the producer accepts (serving the bind-first openable;
|
||||
its forwarded-open caller rides the serving session's
|
||||
`CallConnection` + the producer-side manager — the design pin from
|
||||
`CallConnection` + the producer-side manager, the design pin from
|
||||
`tunnels/bindfirst-producer`). The existing topologies could not
|
||||
serve this shape: `wire_bindfirst`'s consumer is a hook-captured
|
||||
`CallConnection` (no `ChannelClient`), and `wire_forward`'s
|
||||
@@ -181,47 +182,47 @@ the same session/data-plane/teardown semantics — pin it with a test
|
||||
- **The adopt-path pin lives in its own test** (`forwarded_channel
|
||||
_adopt_presents_ordinary_session_semantics`, the forwarded-serving
|
||||
standalone topology): the gate e2e's forwarded channel on the
|
||||
opener side is wrapper-managed (the serving wrapper allocated it —
|
||||
a second `adopt_channel` fails `ChannelExists`, the same property
|
||||
opener side is wrapper-managed (the serving wrapper allocated it,
|
||||
so a second `adopt_channel` fails `ChannelExists`, the same property
|
||||
producer_bindfirst.rs pinned), so the pin drives the PRODUCER-side
|
||||
adoption — `TunnelSession::adopt` + pump + teardown on the
|
||||
opener-allocated ID — proving the session type gains nothing.
|
||||
adoption (`TunnelSession::adopt` + pump + teardown on the
|
||||
opener-allocated ID), proving the session type gains nothing.
|
||||
|
||||
## Summary
|
||||
|
||||
Implemented `TunnelListener` (src/consumer.rs, ~130 lines) per
|
||||
consumer.md §The Listen Flow + ADR-008 §4 as amended: `open` (rides
|
||||
`ChannelClient::open_channel_with_reply` — the full reply arrives and
|
||||
`ChannelClient::open_channel_with_reply`: the full reply arrives and
|
||||
`listen_reply_fields` projects `bound`/`listen`; adopt-free
|
||||
construction), `bound()`/`listen()` (the Option posture), `close`
|
||||
(the graceful path: `shutdown` on the held write half — the explicit
|
||||
EOF sentinel — then the sync reap), and `Drop` (the held write half
|
||||
drops → the mux pump's implicit-EOF chunk — the far accept
|
||||
dispatcher's teardown signal — then the sync reap; never leaks).
|
||||
(the graceful path: `shutdown` on the held write half, the explicit
|
||||
EOF sentinel, then the sync reap), and `Drop` (the held write half
|
||||
drops → the mux pump's implicit-EOF chunk, the far accept
|
||||
dispatcher's teardown signal, then the sync reap; never leaks).
|
||||
The held stream is the WRITE HALF (`MpscSendStream`) only, IDLE and
|
||||
private — a listen channel has no data-plane role. No `Clone`
|
||||
private, since a listen channel has no data-plane role. No `Clone`
|
||||
(compile_fail doc-test, the session's precedent). `forwarded()`
|
||||
decided: NOT provided — the receipt queue is assembly-side
|
||||
decided: NOT provided. The receipt queue is assembly-side
|
||||
bookkeeping; the type hands the assembly the token (`listen()`) and
|
||||
the docs state the split (listener teardown does not cascade to
|
||||
already-adopted forwarded sessions; the far listener dying stops NEW
|
||||
accepts). `TunnelSession` gained nothing (the direct path lives in
|
||||
`tunnels/direct-op`, untouched).
|
||||
|
||||
Tests: `tests/consumer_listener.rs` — 5 integration tests: the
|
||||
Tests: `tests/consumer_listener.rs` has 5 integration tests: the
|
||||
bind-first gate (both reply fields surface; the forwarded flow
|
||||
round-trips through the REAL `register_tunnel_forwarded_openable`
|
||||
+ correlation map; the teardown cascade — drop listener → far
|
||||
listener gone → a follow-on accept is never consumed), the Option
|
||||
+ correlation map; the teardown cascade (drop listener → far
|
||||
listener gone → a follow-on accept is never consumed)), the Option
|
||||
posture (a dial producer's open: both fields `None`; close reaps),
|
||||
close/Drop reaping (the ADR-005 leak-free guarantee,
|
||||
listen-flavored), the adopt-path pin (a forwarded channel adopted
|
||||
via `TunnelSession::adopt` presents the ordinary session semantics —
|
||||
via `TunnelSession::adopt` presents the ordinary session semantics;
|
||||
adopt gains nothing), and the scope interaction (`tunnel:open`-only
|
||||
opener still opens the listen channel; forwarded opens to it fail
|
||||
FORBIDDEN before the assembly handler runs). Harness addition:
|
||||
`wire_listener_bindfirst` + `ListenerTopology` (the opener-dials
|
||||
shape — client + serving on the opener, bind-first serving +
|
||||
shape: client + serving on the opener, bind-first serving +
|
||||
call-plane forwarded-open caller on the producer). One doc-fix drive-
|
||||
by in producer.rs (two broken intra-doc links, pre-existing).
|
||||
|
||||
@@ -229,6 +230,6 @@ Verified: `cargo test` green (132 default; 157 `--all-features`), doc
|
||||
tests pass (both `compile_fail` asserts), clippy `--all-targets -D
|
||||
warnings` clean (native + wasm32), fmt clean,
|
||||
`wasm32-unknown-unknown` check passes (the default crate stays
|
||||
wasm-clean), `cargo doc --no-deps` clean (0 warnings — the 2
|
||||
wasm-clean), `cargo doc --no-deps` clean (0 warnings, the 2
|
||||
pre-existing broken links fixed). CHANGELOG updated (Unreleased →
|
||||
Added).
|
||||
+18
-16
@@ -23,7 +23,7 @@ pin.
|
||||
```rust
|
||||
pub const MAX_DATAGRAM_LEN: usize = u16::MAX as usize; // 65535
|
||||
|
||||
pub struct DatagramCodecError { .. } // thiserror: Oversize(usize, usize) (planned shape; InvalidLength removed post-review — see Notes)
|
||||
pub struct DatagramCodecError { .. } // thiserror: Oversize(usize, usize) (planned shape; InvalidLength removed post-review, see Notes)
|
||||
|
||||
pub fn frame_datagram(payload: &[u8]) -> Result<Bytes, DatagramCodecError>
|
||||
// [len: u16 BE][payload]; len=0 is a legal empty datagram; >65535 = Oversize at frame time
|
||||
@@ -41,7 +41,7 @@ impl DatagramReader {
|
||||
spec's executable form)
|
||||
|
||||
1. Single datagram round-trip (frame → feed → exact bytes out).
|
||||
2. Empty datagram (`len=0`): survives as a real datagram — NEVER
|
||||
2. Empty datagram (`len=0`): survives as a real datagram and is NEVER
|
||||
confused with EOF (the F-2 invariant; the codec layer never emits a
|
||||
zero-length read).
|
||||
3. Split across chunks (feed a frame in awkward 7-byte chunks; exact
|
||||
@@ -51,15 +51,16 @@ spec's executable form)
|
||||
6. Mid-datagram state observable (incremental decode correctness).
|
||||
7. Oversize rejected at frame time (never a wire overflow).
|
||||
8. Truncation fail-loud (OQ-TN-13): the adapter-level receive that
|
||||
would truncate surfaces as an error — test at the codec boundary by
|
||||
would truncate surfaces as an error. Test at the codec boundary by
|
||||
asserting `frame_datagram` + reader round-trip with a buffer
|
||||
smaller than a full datagram is not silently accepted (the concrete
|
||||
API shape lands with `tunnels/local-socket-halves`; here, pin the
|
||||
codec-side invariant: a truncated stream yields an error, not a
|
||||
partial datagram).
|
||||
|
||||
No `stream_type` byte, no 5-byte header — a tunnel has one data stream
|
||||
per direction (ADR-003). No EOF sentinel in the codec — EOF is the
|
||||
No `stream_type` byte and no 5-byte header: a tunnel has one data
|
||||
stream per direction (ADR-003). No EOF sentinel in the codec, since
|
||||
EOF is the
|
||||
channels-level `length=0` chunk, a different layer (the two coexist;
|
||||
the POC proved no collision).
|
||||
|
||||
@@ -71,8 +72,8 @@ the POC proved no collision).
|
||||
boundaries
|
||||
- [x] `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check`
|
||||
clean
|
||||
- [x] wasm32 check passes (the codec is pure byte work — it must be
|
||||
wasm-clean)
|
||||
- [x] wasm32 check passes (the codec is pure byte work, so it must
|
||||
be wasm-clean)
|
||||
|
||||
## References
|
||||
|
||||
@@ -86,17 +87,17 @@ the POC proved no collision).
|
||||
|
||||
- Direct port of the POC's wire.rs (17 tests rode it) with the crate
|
||||
conventions: thiserror doc comments, module doc per ADR-003 +
|
||||
wire.md + bast.md layering (sentinel layering called out — the F-2
|
||||
invariant is load-bearing, so the module doc states why raw
|
||||
wire.md + bast.md layering (sentinel layering is called out; the
|
||||
F-2 invariant is load-bearing, so the module doc states why raw
|
||||
pass-through is structurally broken for UDP).
|
||||
- Error naming per the task's API: `Oversize(usize, usize)` (was
|
||||
POC's `TooLarge`). (2026-09-09: the `InvalidLength` variant was
|
||||
REMOVED post-review — `feed` never returned it; the truncated-stream
|
||||
REMOVED post-review (`feed` never returned it); the truncated-stream
|
||||
contract is `is_mid_datagram` + the session's `TruncatedDatagram`
|
||||
instead, per the coverage-review commit. A completed task doc
|
||||
records its planned shape, not corrections made after.)
|
||||
- `feed` returns `Vec<Bytes>` per the task API (the POC's
|
||||
`Vec<Datagram>` wrapper dropped — `Bytes` is the payload; the mux
|
||||
`Vec<Datagram>` wrapper dropped, since `Bytes` is the payload; the mux
|
||||
handoff is zero-copy via `BytesMut::freeze`).
|
||||
- `MAX_DATAGRAM_LEN`/`DATAGRAM_LEN_FIELD` constants; `DATAGRAM_LEN_FIELD`
|
||||
is private (implementation detail), `MAX_DATAGRAM_LEN` public (the
|
||||
@@ -104,8 +105,8 @@ the POC proved no collision).
|
||||
- 8 test families: single round-trip, empty-datagram-not-EOF,
|
||||
7-byte-chunk split reassembly, two-datagram batch, partial header,
|
||||
mid-datagram observability, oversize at frame time, truncated
|
||||
stream (feeds an empty chunk + a one-byte-short tail — never yields
|
||||
a partial datagram; the codec-side half of OQ-TN-13; the
|
||||
stream (feeds an empty chunk + a one-byte-short tail; it never
|
||||
yields a partial datagram, the codec-side half of OQ-TN-13; the
|
||||
adapter-level receive shape lands with local-socket-halves).
|
||||
- POC's `Datagram` struct dropped: `Bytes` suffices (the task's
|
||||
`feed -> Vec<Bytes>` signature); `is_empty()` moves to callers
|
||||
@@ -116,7 +117,8 @@ the POC proved no collision).
|
||||
`src/wire.rs` complete: `frame_datagram` (Oversize at frame time,
|
||||
`len=0` legal), `DatagramReader` (incremental decoder, `is_mid_datagram`
|
||||
teardown diagnostic), `DatagramCodecError {Oversize}` (originally
|
||||
sketched `{Oversize, InvalidLength}`; see the 2026-09-09 note above) —
|
||||
the ADR-003/BAST contract in executable form. 8 codec tests + 6 params
|
||||
sketched `{Oversize, InvalidLength}`; see the 2026-09-09 note above).
|
||||
This is the ADR-003/BAST contract in executable form. 8 codec tests + 6 params
|
||||
tests = 14 passing. Verified: cargo test, clippy --all-targets -D
|
||||
warnings (native + wasm32), fmt --check, wasm32 check — all clean.
|
||||
warnings (native + wasm32), fmt --check, and the wasm32 check are all
|
||||
clean.
|
||||
+21
-21
@@ -1,5 +1,5 @@
|
||||
//! Integration tests for `TunnelListener` (the listen-flow consumer
|
||||
//! session — ADR-008 §4, the `tunnels/tunnel-listener` shape): the
|
||||
//! session (ADR-008 §4, the `tunnels/tunnel-listener` shape): the
|
||||
//! listen-flow sibling of `TunnelSession`. `open` rides
|
||||
//! `open_channel_with_reply` (the U-2 surface), the bind-first reply
|
||||
//! fields surface via `bound()`/`listen()` (the Option posture), the
|
||||
@@ -17,12 +17,12 @@
|
||||
//! - `close` reaps the listen channel; `Drop` without close reaps.
|
||||
//! - Adopt-path pin: a forwarded channel adopted via
|
||||
//! `TunnelSession::adopt` presents the ordinary session semantics
|
||||
//! (adopt gains nothing — pump + teardown on the adopted entry).
|
||||
//! (adopt gains nothing: pump + teardown on the adopted entry).
|
||||
//! - Scope interaction: an opener identity with `tunnel:open` but NOT
|
||||
//! `tunnel:forwarded` still opens the listen channel; forwarded
|
||||
//! opens to it then fail FORBIDDEN (per-accept isolation).
|
||||
//! - Graceful `close` (review 002 G-2): the explicit shutdown
|
||||
//! sentinel is observed at the FAR accept dispatcher — the idle-read
|
||||
//! sentinel is observed at the FAR accept dispatcher; the idle-read
|
||||
//! fires, the loop cancels, the serving side reaps.
|
||||
//! - Write-happy peer (review 002 N-2's testable residue): a
|
||||
//! non-empty read on the idle listen stream tears the listener down
|
||||
@@ -70,7 +70,7 @@ fn test_bind() -> BindFn {
|
||||
}
|
||||
|
||||
/// The test accept loop: pops from the test's VecDeque + Notify pair
|
||||
/// (one accepted pair per closure call — the dispatcher invokes the
|
||||
/// (one accepted pair per closure call; the dispatcher invokes the
|
||||
/// closure per accept).
|
||||
fn queue_loop(
|
||||
queue: Arc<tokio::sync::Mutex<std::collections::VecDeque<(SubstrateAddr, TargetHandle)>>>,
|
||||
@@ -222,10 +222,10 @@ fn opener_only_channel_0(topo: &ListenerTopology) -> bool {
|
||||
.all(|&id| id == 0)
|
||||
}
|
||||
|
||||
/// THE gate: `open` against the bind-first producer — both reply
|
||||
/// THE gate: `open` against the bind-first producer, both reply
|
||||
/// fields surface via `bound()`/`listen()`, the forwarded flow runs
|
||||
/// through a REAL `TunnelSession::adopt` of the producer-allocated
|
||||
/// forwarded channel (adopt gains nothing — the session type is
|
||||
/// forwarded channel (adopt gains nothing; the session type is
|
||||
/// ordinary), and dropping the listener tears the far listener down
|
||||
/// (subsequent forwarded opens fail).
|
||||
#[tokio::test]
|
||||
@@ -276,7 +276,7 @@ async fn bindfirst_open_surfaces_reply_fields_and_teardown_cascades() {
|
||||
|
||||
// The forwarded channel arrives as a PRODUCER-INITIATED open the
|
||||
// opener serves: the opener-allocated ID appears on the opener's
|
||||
// manager (wrapper-managed — the serving side allocates, ADR-047
|
||||
// manager (wrapper-managed: the serving side allocates, ADR-047
|
||||
// §5; the PRODUCER adopts it inside the harness's forwarded-open
|
||||
// caller and pumps the accepted halves against it).
|
||||
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
||||
@@ -293,7 +293,7 @@ async fn bindfirst_open_surfaces_reply_fields_and_teardown_cascades() {
|
||||
// writes on the local BIND client's end cross the assembly pump →
|
||||
// the forwarded channel → the producer-side adopted pump → the
|
||||
// accepted handle's echo → and back. The forwarded session is an
|
||||
// ordinary channel — `adopt` gains nothing (the standalone adopt
|
||||
// ordinary channel; `adopt` gains nothing (the standalone adopt
|
||||
// pin below drives the same session type directly).
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
b_write.write_all(b"bind-ping").await.expect("write");
|
||||
@@ -311,7 +311,7 @@ async fn bindfirst_open_surfaces_reply_fields_and_teardown_cascades() {
|
||||
// Teardown cascade: drop the listener → the held write half EOFs
|
||||
// the listen channel → the far accept loop cancels → the far
|
||||
// listener is gone (a follow-on accept is never consumed). The
|
||||
// client end drops first (the forwarded session's own teardown —
|
||||
// client end drops first (the forwarded session's own teardown:
|
||||
// the pump winds down; the handler's R-02 await unblocks only
|
||||
// when every per-accept pump finished).
|
||||
drop(b_read);
|
||||
@@ -337,11 +337,11 @@ async fn bindfirst_open_surfaces_reply_fields_and_teardown_cascades() {
|
||||
}
|
||||
|
||||
/// The adopt-path pin (ADR-008 §4: "`TunnelSession::adopt` gains
|
||||
/// nothing — forwarded channels are ordinary adopted channels"): in
|
||||
/// nothing; forwarded channels are ordinary adopted channels"): in
|
||||
/// the forwarded-serving standalone shape (the test plays the
|
||||
/// producer), a forwarded channel adopted via `TunnelSession::adopt`
|
||||
/// presents the ordinary session semantics — substrate-shaped data
|
||||
/// plane, pump, teardown — identical to any other adopted session.
|
||||
/// presents the ordinary session semantics (substrate-shaped data
|
||||
/// plane, pump, teardown) identical to any other adopted session.
|
||||
#[tokio::test]
|
||||
async fn forwarded_channel_adopt_presents_ordinary_session_semantics() {
|
||||
// The forwarded-serving topology: the producer leg (connect side,
|
||||
@@ -366,7 +366,7 @@ async fn forwarded_channel_adopt_presents_ordinary_session_semantics() {
|
||||
correlations.lock().await.insert(TOKEN, local);
|
||||
|
||||
// The producer calls the forwarded op (valid params + scope) and
|
||||
// adopts the opener-allocated ID — via `TunnelSession::adopt` (the
|
||||
// adopts the opener-allocated ID via `TunnelSession::adopt` (the
|
||||
// session type's adopt path; `adopt gains nothing`).
|
||||
let out = topo
|
||||
.producer
|
||||
@@ -412,7 +412,7 @@ async fn forwarded_channel_adopt_presents_ordinary_session_semantics() {
|
||||
assert_eq!(&buf, b"adopt-ping");
|
||||
|
||||
// Teardown: the adopted session reaps its OWN entry (per-session,
|
||||
// ADR-005) — independent of any listener. The far end (the local
|
||||
// ADR-005), independent of any listener. The far end (the local
|
||||
// BIND client end) drops first: the assembly pump's `pump_bidi`
|
||||
// (R-02, the wrapper's handler task) unwinds when both pumps
|
||||
// finish, and only THEN does the wrapper's handler-exit teardown
|
||||
@@ -433,10 +433,10 @@ async fn forwarded_channel_adopt_presents_ordinary_session_semantics() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The Option posture: `open` against a plain DIAL producer — both
|
||||
/// The Option posture: `open` against a plain DIAL producer, both
|
||||
/// reply fields `None`, the session is ordinary. (`wire_forward`'s
|
||||
/// producer (accept side) serves the base openable with a DIAL
|
||||
/// establisher — a dial establisher never binds, so the reply carries
|
||||
/// establisher; a dial establisher never binds, so the reply carries
|
||||
/// neither field; the listener session is otherwise ordinary.)
|
||||
#[tokio::test]
|
||||
async fn dial_producer_open_both_fields_none() {
|
||||
@@ -499,7 +499,7 @@ async fn close_and_drop_reap_the_listen_channel() {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
assert!(!topo.producer_manager.has_channel(id1));
|
||||
|
||||
// Drop(): the best-effort path — no leak.
|
||||
// Drop(): the best-effort path (no leak).
|
||||
let registry = ResourceRegistry::new();
|
||||
registry
|
||||
.register("bind", Substrate::Tcp, "assembly-owned")
|
||||
@@ -589,7 +589,7 @@ async fn open_only_opener_still_opens_listen_but_forwarded_fails() {
|
||||
|
||||
/// The graceful-teardown half of ADR-008 §1's contract (review 002
|
||||
/// G-2): `TunnelListener::close`'s explicit `shutdown` (the EOF
|
||||
/// sentinel) is observed at the FAR accept dispatcher — the idle-read
|
||||
/// sentinel) is observed at the FAR accept dispatcher; the idle-read
|
||||
/// fires, the accept loop cancels, the pushed accept is never
|
||||
/// consumed, and the serving-side listen channel reaps. The Drop path
|
||||
/// (`bindfirst_open_surfaces_reply_fields_and_teardown_cascades`) and
|
||||
@@ -665,7 +665,7 @@ async fn graceful_close_eof_sentinel_observed_at_far_accept_dispatcher() {
|
||||
|
||||
/// The write-happy-peer teardown posture (review 002 N-2's testable
|
||||
/// residue): a non-empty read on the idle listen stream tears the
|
||||
/// listener down — the fail-closed arm (`Ok(_)` in the idle-stream EOF
|
||||
/// listener down; the fail-closed arm (`Ok(_)` in the idle-stream EOF
|
||||
/// watch, src/producer.rs). The channel is not a data plane; ANY
|
||||
/// non-empty read is a teardown trigger, not just the zero-byte
|
||||
/// sentinels. The typed `TunnelListener` never writes, so the test
|
||||
@@ -687,7 +687,7 @@ async fn write_happy_peer_on_idle_listen_channel_tears_down_far_listener() {
|
||||
.await;
|
||||
|
||||
// The misbehaving opener: the raw client surface (the typed
|
||||
// `TunnelListener` never exposes the write half — it holds it
|
||||
// `TunnelListener` never exposes the write half; it holds it
|
||||
// idle by contract). Hold the write half ALIVE: the idle read
|
||||
// must fire from the written byte, not from a dropped session.
|
||||
let input = serde_json::to_value(listen_params("bind")).expect("params serialize");
|
||||
@@ -703,7 +703,7 @@ async fn write_happy_peer_on_idle_listen_channel_tears_down_far_listener() {
|
||||
assert!(topo.producer_manager.has_channel(channel_id));
|
||||
|
||||
// Prove the accept loop is alive FIRST (a consumed accept), then
|
||||
// write a byte into the idle channel — the dispatcher's idle read
|
||||
// write a byte into the idle channel, and the dispatcher's idle read
|
||||
// observes the non-empty read and tears the listener down
|
||||
// (fail-closed).
|
||||
push_accept(&queue, ¬ify, (198, 55236)).await;
|
||||
|
||||
+20
-20
@@ -1,4 +1,4 @@
|
||||
//! Integration tests for the consumer half — `TunnelSession` per
|
||||
//! Integration tests for the consumer half (`TunnelSession` per
|
||||
//! consumer.md + ADR-005, over the duplex harness. Ported from the
|
||||
//! reverse POC's `ReverseTunnel` suite (the W3/W4 probes) plus the
|
||||
//! forward POC's session shapes, generalized to the spec's session
|
||||
@@ -7,9 +7,9 @@
|
||||
//! Covered: forward open (session halves drive a duplex round-trip),
|
||||
//! reverse open+adopt+pump_against (round-trip, W4 half-close, join
|
||||
//! copy counts, out-of-band close + self-reaping, pump-less join),
|
||||
//! datagram variant (round-trip incl. the empty datagram — the F-2
|
||||
//! datagram variant (round-trip incl. the empty datagram, the F-2
|
||||
//! layering), wrong-substrate errors, and the teardown matrix
|
||||
//! (close/join/Drop — no leaked channel entries via `channel_ids()`).
|
||||
//! (close/join/Drop; no leaked channel entries via `channel_ids()`).
|
||||
|
||||
mod harness;
|
||||
|
||||
@@ -105,7 +105,7 @@ async fn forward_open_session_halves_round_trip() {
|
||||
#[tokio::test]
|
||||
async fn forward_open_take_halves_then_pumpless_join() {
|
||||
// consumer.md's pinned semantics: after take_halves the session is
|
||||
// pump-less — join completes immediately, reaps only, (0, 0, reaped).
|
||||
// pump-less: join completes immediately, reaps only, (0, 0, reaped).
|
||||
let registry = ResourceRegistry::new();
|
||||
registry
|
||||
.register("echo", Substrate::Tcp, "in-process")
|
||||
@@ -145,7 +145,7 @@ async fn forward_open_take_halves_then_pumpless_join() {
|
||||
#[tokio::test]
|
||||
async fn datagram_round_trip_including_empty() {
|
||||
// The datagram variant over the codec (ADR-003): round-trip incl.
|
||||
// the F-2 empty datagram — `len=0` is a legal datagram, never EOF.
|
||||
// the F-2 empty datagram (`len=0` is a legal datagram, never EOF).
|
||||
let registry = ResourceRegistry::new();
|
||||
registry.register("dns", Substrate::Udp, "in-process").await;
|
||||
let topo = wire_forward(registry, framed_udp_echo_dial()).await;
|
||||
@@ -177,13 +177,13 @@ async fn datagram_round_trip_including_empty() {
|
||||
.await
|
||||
.expect("recv timed out")
|
||||
.expect("no io error")
|
||||
.expect("second datagram (empty round-trips — F-2)");
|
||||
.expect("second datagram (empty round-trips, F-2)");
|
||||
assert!(dg2.is_empty(), "the empty datagram round-trips (F-2)");
|
||||
|
||||
let reaped = session.close().await;
|
||||
assert!(reaped);
|
||||
// The producer-side wrapper reap is async (the pump task exits,
|
||||
// the wrapper reaps) — settle before asserting both managers.
|
||||
// the wrapper reaps); settle before asserting both managers.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||
assert!(only_channel_0(&topo));
|
||||
}
|
||||
@@ -191,7 +191,7 @@ async fn datagram_round_trip_including_empty() {
|
||||
#[tokio::test]
|
||||
async fn datagram_backlog_tracks_pending_and_drains_fifo() {
|
||||
// The §1.3/§3.3 session-shape diagnostic: `datagram_backlog()` is
|
||||
// the `DatagramHalves.pending` count — None for stream sessions,
|
||||
// the `DatagramHalves.pending` count: None for stream sessions,
|
||||
// 0 at rest, batched arrivals queue FIFO, and a drained queue
|
||||
// reads 0 (the wave-2 harness asserts the bound against it).
|
||||
let registry = ResourceRegistry::new();
|
||||
@@ -224,7 +224,7 @@ async fn datagram_backlog_tracks_pending_and_drains_fifo() {
|
||||
|
||||
// Backlog drain posture: two echoed datagrams land back-to-back;
|
||||
// whether the echo batches them into one chunk is timing-shaped,
|
||||
// so the suite pins only the rest state (empty after drains) —
|
||||
// so the suite pins only the rest state (empty after drains);
|
||||
// the batched-arrival bound is the wave-2 harness's (§3.3).
|
||||
session.send_datagram(b"first").await.expect("send");
|
||||
session.send_datagram(b"second").await.expect("send");
|
||||
@@ -409,7 +409,7 @@ async fn reverse_half_close_semantics_w4() {
|
||||
async fn out_of_band_close_then_session_self_reap() {
|
||||
// The POC's reverse_channel_close_out_of_band shape: a peer-initiated
|
||||
// `channel/close` tears the SERVING side down; the consumer's session
|
||||
// still owns its adopted entry — close/join reaps it (W3 split).
|
||||
// still owns its adopted entry; close/join reaps it (W3 split).
|
||||
let registry = ResourceRegistry::new();
|
||||
registry
|
||||
.register("echo", Substrate::Tcp, "in-process")
|
||||
@@ -473,7 +473,7 @@ async fn drop_never_leaks_the_channel_entry() {
|
||||
);
|
||||
|
||||
// Drop with a session-owned pump: the pump aborts too (the reverse
|
||||
// topology — pump_against is the reverse-flow use).
|
||||
// topology, where pump_against is the reverse-flow use).
|
||||
let topo = wire(registry, echo_dial()).await;
|
||||
let channel_id =
|
||||
open_reverse_channel(&topo.consumer_call, ¶ms("echo", Substrate::Tcp), None)
|
||||
@@ -522,7 +522,7 @@ async fn oversize_datagram_rejected_at_frame_time() {
|
||||
async fn datagram_clean_eof_is_none_not_error() {
|
||||
// consumer.md's EOF contract for pump-less datagram sessions: a
|
||||
// clean stream EOF (the channels-level sentinel, mid-frame
|
||||
// state clear) resolves `Ok(None)` — the caller's shutdown
|
||||
// state clear) resolves `Ok(None)`, the caller's shutdown
|
||||
// signal, distinct from both an empty datagram (`Some(b"")`,
|
||||
// F-2) and a truncated stream (`TruncatedDatagram`).
|
||||
let registry = ResourceRegistry::new();
|
||||
@@ -571,7 +571,7 @@ async fn datagram_mid_frame_eof_fails_loud_truncated() {
|
||||
|
||||
// The echo's far end forwards the frame but drops the connection
|
||||
// BEFORE the payload completes (the 2-byte length prefix rides;
|
||||
// the payload is cut — mid-datagram EOF).
|
||||
// the payload is cut (mid-datagram EOF).
|
||||
let err = tokio::time::timeout(std::time::Duration::from_secs(5), session.recv_datagram())
|
||||
.await
|
||||
.expect("recv timed out")
|
||||
@@ -590,7 +590,7 @@ async fn datagram_mid_frame_eof_fails_loud_truncated() {
|
||||
async fn datagram_data_plane_after_take_halves_is_channel_taken() {
|
||||
// After the halves were taken, the data plane is gone: datagram
|
||||
// accessors fail with `ChannelTaken` (the pinned `ChannelTaken`
|
||||
// surface — the session is still the channel-entry owner).
|
||||
// surface; the session is still the channel-entry owner).
|
||||
let registry = ResourceRegistry::new();
|
||||
registry.register("dns", Substrate::Udp, "in-process").await;
|
||||
let topo = wire_forward(registry, framed_udp_echo_dial()).await;
|
||||
@@ -617,7 +617,7 @@ async fn datagram_data_plane_after_take_halves_is_channel_taken() {
|
||||
assert!(matches!(err, alktunnels::TunnelIoError::ChannelTaken));
|
||||
|
||||
// The taken halves carry the WIRE framing (ADR-005's N-3 pin):
|
||||
// the raw bytes are `[len: u16 BE][payload]` — framing duty
|
||||
// the raw bytes are `[len: u16 BE][payload]`; framing duty
|
||||
// transferred to the caller, visible on the wire.
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
write
|
||||
@@ -646,7 +646,7 @@ async fn datagram_data_plane_after_take_halves_is_channel_taken() {
|
||||
async fn reverse_udp_pump_against_framed_adapter_round_trips() {
|
||||
// The reverse UDP shape: the session's codec data plane is
|
||||
// consumed by `pump_against` and the accepted local half speaks
|
||||
// the FRAMED wire (the N-11 contract — the pump copies raw bytes,
|
||||
// the FRAMED wire (the N-11 contract: the pump copies raw bytes,
|
||||
// so the `[len: u16 BE]` framing lives at the boundary; the test's
|
||||
// local end stands in for the framed adapter, e.g. `local::UdpHalf`).
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
@@ -672,7 +672,7 @@ async fn reverse_udp_pump_against_framed_adapter_round_trips() {
|
||||
let mut local_end = local_end;
|
||||
|
||||
// The local end speaks FRAMED bytes end-to-end: a payload frame
|
||||
// crosses the pump untouched and echoes back framed — decode
|
||||
// crosses the pump untouched and echoes back framed; decode
|
||||
// locally with the codec (boundary preserved, F-2 shape intact).
|
||||
let sent = alktunnels::wire::frame_datagram(b"rev-udp").expect("frame");
|
||||
local_end.write_all(&sent).await.expect("write frame");
|
||||
@@ -686,7 +686,7 @@ async fn reverse_udp_pump_against_framed_adapter_round_trips() {
|
||||
.expect("read");
|
||||
assert_eq!(framed_back, &sent[..], "framed bytes pass through");
|
||||
|
||||
// An EMPTY datagram (`len=0`) also round-trips — never collapsed
|
||||
// An EMPTY datagram (`len=0`) also round-trips, never collapsed
|
||||
// to EOF across the pump (the F-2 invariant, reverse direction).
|
||||
local_end
|
||||
.write_all(&[0, 0])
|
||||
@@ -752,8 +752,8 @@ async fn failed_open_never_yields_a_session() {
|
||||
async fn adopt_failure_is_typed_not_establishment() {
|
||||
// An adopt failure is `AdoptFailed`-class (post-reply local
|
||||
// failure: ID collision), not an establishment error. A bogus-id
|
||||
// adopt SUCCEEDS by design — the manager parks early arrivals for
|
||||
// any not-yet-seen ID (the adoption race cover, ADR-047 §5) — so
|
||||
// adopt SUCCEEDS by design, since the manager parks early arrivals
|
||||
// for any not-yet-seen ID (the adoption race cover, ADR-047 §5);
|
||||
// the collision path is the honest probe: adopt the same ID twice.
|
||||
let registry = ResourceRegistry::new();
|
||||
registry
|
||||
|
||||
+30
-30
@@ -1,28 +1,28 @@
|
||||
//! The end-to-end suite — every validated POC behavior, re-expressed
|
||||
//! The end-to-end suite: every validated POC behavior, re-expressed
|
||||
//! against the crate's public API (tests/common/harness.rs, the two
|
||||
//! POC harnesses' topologies unified). The spec's executable form: the
|
||||
//! quality gate before the implementation review.
|
||||
//!
|
||||
//! Suites:
|
||||
//! 1. **Forward (`-L`)** — open → establisher dials → pump → round-
|
||||
//! 1. **Forward (`-L`)**: open → establisher dials → pump → round-
|
||||
//! trip; 1 MiB backpressure; typed errors; per-call opener identity
|
||||
//! (CF-006); establishment deadline → `timeout` (ADR-049 §2).
|
||||
//! 2. **Reverse (`-R`)** — `open_reverse_channel` + `adopt` +
|
||||
//! 2. **Reverse (`-R`)**: `open_reverse_channel` + `adopt` +
|
||||
//! `pump_against`; odd-ID allocation (ADR-047 §5); the identity
|
||||
//! precedence chain (CF-005); half-close (W4); out-of-band close +
|
||||
//! self-reaping (W3); same-resource concurrency (R-01).
|
||||
//! 3. **Listen producer** — the accept-queue flow + typed errors.
|
||||
//! 4. **Datagram (`udp`)** — forward + reverse datagram sessions via
|
||||
//! 3. **Listen producer**: the accept-queue flow + typed errors.
|
||||
//! 4. **Datagram (`udp`)**: forward + reverse datagram sessions via
|
||||
//! the codec; round-trip, empty datagram (F-2), chunk-split
|
||||
//! survival, TCP+UDP concurrent on one connection.
|
||||
//! 5. **Teardown matrix** (ADR-005) — close/join/Drop; `channel_ids()`
|
||||
//! 5. **Teardown matrix** (ADR-005): close/join/Drop; `channel_ids()`
|
||||
//! asserts no leaks on either side.
|
||||
//! 6. Spec-conformance assertions — no phantom channel on a failed
|
||||
//! 6. Spec-conformance assertions: no phantom channel on a failed
|
||||
//! open, no leaked session on a failed adopt, R-02's
|
||||
//! JoinHandle-tracks-the-data-plane shape.
|
||||
//!
|
||||
//! (`local`-feature real-socket suites live in tests/local_halves.rs
|
||||
//! — the duplex suites here need no sockets.)
|
||||
//! (`local`-feature real-socket suites live in tests/local_halves.rs;
|
||||
//! the duplex suites here need no sockets.)
|
||||
|
||||
mod harness;
|
||||
|
||||
@@ -45,7 +45,7 @@ fn params(resource: &str, substrate: Substrate) -> TunnelParams {
|
||||
}
|
||||
}
|
||||
|
||||
/// Only channel 0 (the pre-negotiated call channel) may remain —
|
||||
/// Only channel 0 (the pre-negotiated call channel) may remain;
|
||||
/// asserted on both sides of both topologies after every teardown.
|
||||
trait LeakCheck {
|
||||
fn no_data_channels(&self) -> bool;
|
||||
@@ -104,7 +104,7 @@ fn call_error_reason(err: &alktunnels::ReverseOpenError) -> Option<String> {
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Suite 1 — Forward (`-L`): open → establisher → pump → round-trip
|
||||
// Suite 1, Forward (`-L`): open → establisher → pump → round-trip
|
||||
// =====================================================================
|
||||
|
||||
/// A 1 MiB patterned payload round-trips through the two-pump shape
|
||||
@@ -175,7 +175,7 @@ async fn forward_establishment_deadline_maps_to_timeout_typed() {
|
||||
let producer_op_registry = Arc::new(alkcall::registry::registration::OperationRegistry::new());
|
||||
|
||||
// The timeout topology: the default wire() shape with the hanging
|
||||
// establisher replacing the dial establisher — wired by hand here
|
||||
// establisher replacing the dial establisher, wired by hand here
|
||||
// via wire_with's Timeout mode on the reverse topology, then the
|
||||
// forward direction asserted through the session API on a fresh
|
||||
// forward wiring below.
|
||||
@@ -251,7 +251,7 @@ async fn forward_establishment_deadline_maps_to_timeout_typed() {
|
||||
/// both the transport-identity path and the token path.
|
||||
#[tokio::test]
|
||||
async fn forward_opener_identity_witnessed_on_both_paths() {
|
||||
// Transport identity path (the mTLS posture — no token).
|
||||
// Transport identity path (the mTLS posture; no token).
|
||||
let registry = ResourceRegistry::new();
|
||||
registry
|
||||
.register("echo", Substrate::Tcp, "in-process")
|
||||
@@ -299,7 +299,7 @@ async fn forward_opener_identity_witnessed_on_both_paths() {
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Suite 2 — Reverse (`-R`): open_reverse_channel + adopt + pump_against
|
||||
// Suite 2 (Reverse, `-R`): open_reverse_channel + adopt + pump_against
|
||||
// =====================================================================
|
||||
|
||||
/// Odd-ID allocation under ADR-047 §5: the serving side (producer,
|
||||
@@ -328,7 +328,7 @@ async fn reverse_channel_ids_are_odd_and_distinct() {
|
||||
for id in ids {
|
||||
// The reverse-open'd channel was never adopted: the manager
|
||||
// parked its early arrivals. `adopt_channel` drains the park
|
||||
// and installs the entry — adopt, then close (the session's
|
||||
// and installs the entry; adopt, then close (the session's
|
||||
// teardown; teardown_channel alone is UnknownChannel for a
|
||||
// parked-but-never-adopted id).
|
||||
let session = TunnelSession::adopt(
|
||||
@@ -382,8 +382,8 @@ async fn reverse_identity_precedence_chain() {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
|
||||
// (b) The token wins over the transport identity: the provider
|
||||
// resolves the scoped token to a DISTINCT id — the witness proves
|
||||
// which one the establisher saw.
|
||||
// resolves the scoped token to a DISTINCT id, and the witness
|
||||
// proves which one the establisher saw.
|
||||
struct DistinctTokenProvider;
|
||||
impl alkcall::core::auth::IdentityProvider for DistinctTokenProvider {
|
||||
fn resolve_from_fingerprint(&self, _: &str) -> Option<alkcall::core::auth::Identity> {
|
||||
@@ -522,7 +522,7 @@ async fn reverse_concurrent_same_resource_no_handoff_race() {
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Suite 3 — Listen producer (shape 2, the accept-queue flow)
|
||||
// Suite 3, Listen producer (shape 2, the accept-queue flow)
|
||||
// =====================================================================
|
||||
|
||||
/// The listen-queue flow: FIFO always-before-take across two opens;
|
||||
@@ -584,7 +584,7 @@ async fn listen_fifo_ordering_two_opens_two_handles() {
|
||||
|
||||
/// The listen-shape typed-error table (producer.md's mapping):
|
||||
/// unknown resource, empty-queue → resource_shortage, closed listener
|
||||
/// → dial_failed — each leaves no channel (the no-phantom property).
|
||||
/// → dial_failed; each leaves no channel (the no-phantom property).
|
||||
#[tokio::test]
|
||||
async fn listen_typed_error_table_leaves_no_channel() {
|
||||
// Empty queue (the assembly's fail-fast posture: a bounded accept
|
||||
@@ -647,11 +647,11 @@ async fn listen_typed_error_table_leaves_no_channel() {
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Suite 4 — Datagram (`udp`): forward + reverse via the codec
|
||||
// Suite 4 (Datagram, `udp`): forward + reverse via the codec
|
||||
// =====================================================================
|
||||
|
||||
/// Many datagrams in each direction, boundaries preserved, batched
|
||||
/// frames survive (one chunk may carry several datagrams — the
|
||||
/// frames survive (one chunk may carry several datagrams; the
|
||||
/// pending-queue cover).
|
||||
#[tokio::test]
|
||||
async fn datagram_boundaries_preserved_both_directions() {
|
||||
@@ -663,7 +663,7 @@ async fn datagram_boundaries_preserved_both_directions() {
|
||||
.await
|
||||
.expect("udp open");
|
||||
|
||||
// Burst 40 datagrams (varied sizes) — they may batch into shared
|
||||
// Burst 40 datagrams (varied sizes); they may batch into shared
|
||||
// chunks; each must come back as exactly one datagram.
|
||||
let mut payloads = Vec::new();
|
||||
for i in 0..40u16 {
|
||||
@@ -683,7 +683,7 @@ async fn datagram_boundaries_preserved_both_directions() {
|
||||
}
|
||||
assert_eq!(received, payloads, "boundaries preserved under batching");
|
||||
|
||||
// The reverse direction: the target echo only repeats — drive the
|
||||
// The reverse direction: the target echo only repeats, so drive the
|
||||
// other way by sending again after draining; order holds.
|
||||
session.send_datagram(b"final").await.expect("send");
|
||||
let dg = tokio::time::timeout(std::time::Duration::from_secs(5), session.recv_datagram())
|
||||
@@ -748,7 +748,7 @@ async fn reverse_udp_datagram_session_round_trips() {
|
||||
.await
|
||||
.expect("adopt");
|
||||
// No pump_against: the session's codec data plane is driven
|
||||
// directly (the pump-less datagram shape — pump_bidi copies raw
|
||||
// directly (the pump-less datagram shape; pump_bidi copies raw
|
||||
// bytes, so the pump is the consumer's job here only when it
|
||||
// holds local halves; the direct drive is the session API's).
|
||||
session.send_datagram(b"query").await.expect("send");
|
||||
@@ -764,7 +764,7 @@ async fn reverse_udp_datagram_session_round_trips() {
|
||||
assert!(only_channel_0(&topo));
|
||||
}
|
||||
|
||||
/// TCP + UDP sessions concurrently on ONE connection — the mux is
|
||||
/// TCP + UDP sessions concurrently on ONE connection; the mux is
|
||||
/// transparent across substrates (both POCs' concurrent shape).
|
||||
#[tokio::test]
|
||||
async fn tcp_and_udp_concurrent_on_one_connection() {
|
||||
@@ -820,7 +820,7 @@ async fn tcp_and_udp_concurrent_on_one_connection() {
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Suite 5 — Teardown matrix (ADR-005): close / join / Drop
|
||||
// Suite 5, Teardown matrix (ADR-005): close / join / Drop
|
||||
// =====================================================================
|
||||
|
||||
/// close on a session WITH a spawned pump: aborts the pump, reaps the
|
||||
@@ -906,7 +906,7 @@ async fn teardown_join_reports_copy_counts_and_reaps() {
|
||||
assert!(only_channel_0(&topo));
|
||||
}
|
||||
|
||||
/// Drop after pump_against: the pump aborts AND the entry reaps —
|
||||
/// Drop after pump_against: the pump aborts AND the entry reaps,
|
||||
/// the full teardown, not just the pump (the drop matrix's
|
||||
/// pump-carrying arm).
|
||||
#[tokio::test]
|
||||
@@ -940,7 +940,7 @@ async fn teardown_drop_with_pump_reaps_entry() {
|
||||
}
|
||||
|
||||
/// A failed adopt leaks nothing: the manager's parked entry is
|
||||
/// released (the failed-adopt arm of the matrix) — a bogus-ID adopt
|
||||
/// released (the failed-adopt arm of the matrix); a bogus-ID adopt
|
||||
/// SUCCEEDS by design (the manager parks any unseen id), so the
|
||||
/// collision path is the honest probe; after the probe the queue is
|
||||
/// clean on both sides.
|
||||
@@ -985,7 +985,7 @@ async fn teardown_failed_adopt_leaves_no_leak() {
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Suite 6 — Spec-conformance assertions (kept visible)
|
||||
// Suite 6, Spec-conformance assertions (kept visible)
|
||||
// =====================================================================
|
||||
|
||||
/// A failed open NEVER returns a channel id (the no-phantom-channel
|
||||
@@ -1099,7 +1099,7 @@ struct Taken {
|
||||
}
|
||||
|
||||
/// An in-process accepted handle: a duplex pair whose far end echoes
|
||||
/// (the same shape the dial harness echoes with — a listen producer's
|
||||
/// (the same shape the dial harness echoes with, a listen producer's
|
||||
/// "accepted connection").
|
||||
fn echo_handle(_label: &str) -> TargetHandle {
|
||||
let (consumer_side, target_side) = tokio::io::duplex(64 * 1024);
|
||||
|
||||
+58
-57
@@ -1,13 +1,13 @@
|
||||
//! Test harness — the producer↔consumer wiring over a
|
||||
//! Test harness: the producer↔consumer wiring over a
|
||||
//! `tokio::io::duplex` carrying the channels 8-byte chunk format.
|
||||
#![allow(dead_code)]
|
||||
//!
|
||||
//! Topology (the reverse POC's shape, mirrored for both directions):
|
||||
//! - **producer** (connect side of the channels connection — it runs
|
||||
//! - **producer** (connect side of the channels connection: it runs
|
||||
//! `ChannelClient::from_connection_with_serving`): dials the
|
||||
//! duplex, serves the tunnel open op + generic channel ops on its
|
||||
//! serving registry. Its manager allocates the tunnel channel IDs
|
||||
//! (`ChannelSide::Connect` — ADR-047 §5: the serving side
|
||||
//! (`ChannelSide::Connect`; ADR-047 §5: the serving side
|
||||
//! allocates; the initiator adopts).
|
||||
//! - **consumer** (accept side): the `ChannelsAdapter` + capturing
|
||||
//! install hook. The hook captures the consumer's channel-0
|
||||
@@ -43,7 +43,7 @@ use alktunnels::producer::{
|
||||
};
|
||||
|
||||
/// The consumer identity the transport carries (the mTLS/QUIC
|
||||
/// analogue — key-based identity resolved out-of-band, attached to
|
||||
/// analogue: key-based identity resolved out-of-band, attached to
|
||||
/// the dialing connection before `from_connection_with_serving`).
|
||||
pub fn consumer_identity() -> Identity {
|
||||
Identity {
|
||||
@@ -54,7 +54,7 @@ pub fn consumer_identity() -> Identity {
|
||||
}
|
||||
|
||||
/// The direct-egress identity: `tunnel:direct` ONLY (no
|
||||
/// `tunnel:open`) — the scope-separation probe's identity.
|
||||
/// `tunnel:open`); this is the scope-separation probe's identity.
|
||||
pub fn direct_identity() -> Identity {
|
||||
Identity {
|
||||
id: "consumer-direct".to_string(),
|
||||
@@ -64,7 +64,7 @@ pub fn direct_identity() -> Identity {
|
||||
}
|
||||
|
||||
/// Both scopes: `tunnel:open` AND `tunnel:direct` (a deployment
|
||||
/// granting both grants both explicitly — ADR-006 Amendment 1).
|
||||
/// granting both grants both explicitly; ADR-006 Amendment 1).
|
||||
pub fn both_scopes_identity() -> Identity {
|
||||
Identity {
|
||||
id: "consumer-both".to_string(),
|
||||
@@ -76,7 +76,7 @@ pub fn both_scopes_identity() -> Identity {
|
||||
}
|
||||
}
|
||||
|
||||
/// The scoped token (the hub-forwarding path) — resolves to
|
||||
/// The scoped token (the hub-forwarding path) resolves to
|
||||
/// [`consumer_identity`] via [`TestIdProvider`].
|
||||
pub const TEST_AUTH_TOKEN: &str = "test-token-with-tunnel-open-scope";
|
||||
|
||||
@@ -105,23 +105,23 @@ pub struct Topology {
|
||||
/// The consumer's manager (accept side). `adopt` installs the
|
||||
/// producer-allocated IDs here; reaping calls `teardown_channel`.
|
||||
pub consumer_manager: ChannelManager,
|
||||
/// The consumer's channel-0 call connection — its surface for
|
||||
/// The consumer's channel-0 call connection, the surface for
|
||||
/// calling the producer's open op (and channel/close).
|
||||
pub consumer_call: Arc<CallConnection>,
|
||||
/// The producer's serving registry (probes).
|
||||
pub producer_registry: Arc<OperationRegistry>,
|
||||
/// The consumer's serving registry (the opener-side op stubs —
|
||||
/// e.g. the forwarded-op serving stub — pre-register here).
|
||||
/// The consumer's serving registry (the opener-side op stubs,
|
||||
/// e.g. the forwarded-op serving stub, pre-register here).
|
||||
pub consumer_registry: Arc<OperationRegistry>,
|
||||
/// What the establisher's per-call auth carried as the opener
|
||||
/// identity id (the CF-006 probe — `None` until an open runs).
|
||||
/// identity id (the CF-006 probe; `None` until an open runs).
|
||||
pub identity_witness: Arc<tokio::sync::Mutex<Option<String>>>,
|
||||
}
|
||||
|
||||
/// Wire the topology. `transport_identity` is the key-based identity
|
||||
/// attached to the dialing connection (CF-005 remediation (b));
|
||||
/// `serving_identity` is the explicit `ServingConfig.identity`
|
||||
/// override (CF-005 remediation (a)) — `None` in the primary path.
|
||||
/// override (CF-005 remediation (a)), `None` in the primary path.
|
||||
/// `provider` resolves payload tokens (the hub-forwarding path).
|
||||
/// The open-op registration mode: a dial establisher (shape 1) or a
|
||||
/// listen establisher over an assembly-fed [`AcceptQueue`] (shape 2).
|
||||
@@ -130,10 +130,10 @@ pub enum RegistrationMode {
|
||||
Listen(alktunnels::producer::AcceptFn),
|
||||
/// A hanging establisher registered with an explicit
|
||||
/// per-registration establishment timeout (the deadline-expiry
|
||||
/// probe — ADR-049 §2's bound override; the open fails with reason
|
||||
/// probe: ADR-049 §2's bound override; the open fails with reason
|
||||
/// `timeout` and no channel survives).
|
||||
Timeout(std::time::Duration),
|
||||
/// The no-witness dial establisher (`tunnel_establisher` — the
|
||||
/// The no-witness dial establisher (`tunnel_establisher`, the
|
||||
/// public default shape, no CF-006 probe seam).
|
||||
DialNoWitness(alktunnels::producer::DialFn),
|
||||
/// The direct op (`channels/tunnel/direct`, ADR-007): the direct
|
||||
@@ -146,7 +146,7 @@ pub enum RegistrationMode {
|
||||
/// establisher + the accept dispatcher over the injected bind +
|
||||
/// accept loop + forwarded-open caller. The forwarded-op caller
|
||||
/// rides the producer-side call plane (`producer_client`'s call
|
||||
/// connection — captured in the topology) and adopts on the
|
||||
/// connection, captured in the topology) and adopts on the
|
||||
/// producer-side manager.
|
||||
BindFirst(
|
||||
alktunnels::producer::BindFn,
|
||||
@@ -168,7 +168,7 @@ pub async fn wire_with(
|
||||
tokio::sync::mpsc::channel::<ChannelManager>(1);
|
||||
|
||||
// The consumer's serving registry: empty by default; a test that
|
||||
// installs opener-side ops (the forwarded-op serving stub — the
|
||||
// installs opener-side ops (the forwarded-op serving stub, the
|
||||
// ADR-008 §3 ingress shape) pre-registers them here so the
|
||||
// dispatch loop serves them from the first inbound call.
|
||||
let consumer_serving_registry = Arc::new(OperationRegistry::new());
|
||||
@@ -202,8 +202,8 @@ pub async fn wire_with(
|
||||
let _ = call_tx.send(Arc::clone(&call_connection)).await;
|
||||
let _ = manager_tx.send(manager).await;
|
||||
// The consumer serves the ops the test pre-registered on
|
||||
// its serving registry (empty in the default topology —
|
||||
// the dispatcher keeps the call loop alive either way; its
|
||||
// its serving registry (empty in the default topology;
|
||||
// the dispatcher keeps the call loop alive either way, and its
|
||||
// read loop resolves the producer's responses to the
|
||||
// consumer's pending entries).
|
||||
let dp = Dispatcher::new(
|
||||
@@ -252,7 +252,7 @@ pub async fn wire_with(
|
||||
.expect("producer channel client init");
|
||||
|
||||
// The generic channel ops ride the producer's serving registry
|
||||
// (channel/close tears a channel down out-of-band — the review
|
||||
// (channel/close tears a channel down out-of-band: the review
|
||||
// 007 recipe).
|
||||
let ops = ChannelOperations::with_default_policy(producer_client.manager().clone());
|
||||
ops.register_on(&producer_op_registry)
|
||||
@@ -304,12 +304,12 @@ pub async fn wire_with(
|
||||
// closes over the producer-side call plane + manager (the
|
||||
// design pin: the per-channel `Connection` the handler
|
||||
// receives is stream-less and carries no call surface, so
|
||||
// the registration takes the call-plane handle — here the
|
||||
// the registration takes the call-plane handle, here the
|
||||
// serving session's `CallConnection`, which the client
|
||||
// holds; in production the assembly layer has the same
|
||||
// handle because it constructed the serving session). The
|
||||
// harness reaches it via `producer_client.call_open_op` +
|
||||
// `adopt_channel` — the reverse POC's
|
||||
// `adopt_channel`, the reverse POC's
|
||||
// `open_reverse_channel` shape, inline (the reply's extra
|
||||
// fields are not needed for the forwarded op; the channel
|
||||
// ID extraction + adopt is the whole dance).
|
||||
@@ -369,7 +369,7 @@ pub async fn wire_with(
|
||||
.expect("register tunnel bindfirst openable");
|
||||
}
|
||||
RegistrationMode::DialNoWitness(dial) => {
|
||||
// The public no-witness wrapper (tunnel_establisher) —
|
||||
// The public no-witness wrapper (tunnel_establisher):
|
||||
// registration + establisher body identical to the
|
||||
// witnessed variant minus the CF-006 seam.
|
||||
let core = alkcall::channels::operations::ChannelCore::new(
|
||||
@@ -396,7 +396,7 @@ pub async fn wire_with(
|
||||
// above on the SAME registry are unaffected (per-op
|
||||
// timeout, ADR-049 §2).
|
||||
// A fresh ChannelCore over the SAME manager + policy (both
|
||||
// cores are cheap facades over the same pair — the second
|
||||
// cores are cheap facades over the same pair; the second
|
||||
// registration rides the same ledger/cap state; this arm
|
||||
// is local to the probe so the other arms' `core` binding
|
||||
// stays untouched).
|
||||
@@ -442,7 +442,7 @@ pub async fn wire_with(
|
||||
|
||||
/// The default topology: the consumer's transport identity is the
|
||||
/// primary caller-identity path (no token). The provider is
|
||||
/// noop-shaped — only the identity chain authorizes.
|
||||
/// noop-shaped; only the identity chain authorizes.
|
||||
pub async fn wire(registry: ResourceRegistry, dial: alktunnels::producer::DialFn) -> Topology {
|
||||
wire_with(
|
||||
registry,
|
||||
@@ -456,7 +456,7 @@ pub async fn wire(registry: ResourceRegistry, dial: alktunnels::producer::DialFn
|
||||
|
||||
/// The direct-op topology (ADR-007): the direct op registered with
|
||||
/// the CF-006 witness; the consumer's transport identity is
|
||||
/// [`both_scopes_identity`] (both grants — the opens authorize).
|
||||
/// [`both_scopes_identity`] (both grants; the opens authorize).
|
||||
pub async fn wire_direct(dial: alktunnels::producer::DialFn) -> Topology {
|
||||
wire_with(
|
||||
ResourceRegistry::new(),
|
||||
@@ -482,7 +482,7 @@ pub async fn wire_direct_with_identity(identity: Identity) -> Topology {
|
||||
}
|
||||
|
||||
/// A topology with the consumer's transport identity REPLACED by the
|
||||
/// serving-side override (`ServingConfig.identity` — CF-005 (a) probe
|
||||
/// serving-side override (`ServingConfig.identity`, CF-005 (a) probe
|
||||
/// shape) or stripped entirely (the fail-closed probe). The transport
|
||||
/// identity still rides the dialing connection; the override is what
|
||||
/// the serving dispatch resolves (the witness proves which one won).
|
||||
@@ -522,11 +522,11 @@ pub async fn wire_listen(
|
||||
/// establisher binds via the injected [`BindFn`]; the accept
|
||||
/// dispatcher runs the injected accept loop and opens forwarded
|
||||
/// channels back toward the opener (whose serving stub the test
|
||||
/// installs on the consumer side — see
|
||||
/// installs on the consumer side; see
|
||||
/// [`serve_forwarded_on_consumer`]). Same identity posture as
|
||||
/// [`wire`], plus the forwarded scope (the opener-side ingress grant
|
||||
/// — the consumer identity must hold `tunnel:forwarded` to receive
|
||||
/// producer-initiated forwarded opens).
|
||||
/// [`wire`], plus the forwarded scope (the opener-side ingress
|
||||
/// grant: the consumer identity must hold `tunnel:forwarded` to
|
||||
/// receive producer-initiated forwarded opens).
|
||||
pub async fn wire_bindfirst(
|
||||
registry: ResourceRegistry,
|
||||
bind: alktunnels::producer::BindFn,
|
||||
@@ -544,7 +544,7 @@ pub async fn wire_bindfirst(
|
||||
|
||||
/// The forwarded-op opener identity: `tunnel:forwarded` AND
|
||||
/// `tunnel:open` (the opener receives producer-initiated forwarded
|
||||
/// opens — the ingress grant — and opens the listen tunnel itself —
|
||||
/// opens, the ingress grant, and opens the listen tunnel itself,
|
||||
/// the egress grant; ADR-008 §3 / ADR-006 Amendment 1).
|
||||
pub fn forwarded_opener_identity() -> Identity {
|
||||
Identity {
|
||||
@@ -558,7 +558,7 @@ pub fn forwarded_opener_identity() -> Identity {
|
||||
}
|
||||
|
||||
/// [`forwarded_opener_identity`] minus the forwarded grant (the
|
||||
/// per-accept isolation probe's identity — the forwarded open fails
|
||||
/// per-accept isolation probe's identity; the forwarded open fails
|
||||
/// FORBIDDEN).
|
||||
pub fn forwarded_scopeless_identity() -> Identity {
|
||||
Identity {
|
||||
@@ -569,18 +569,18 @@ pub fn forwarded_scopeless_identity() -> Identity {
|
||||
}
|
||||
|
||||
/// Serve the forwarded op (`channels/tunnel/forwarded`, ADR-008 §3) on
|
||||
/// the CONSUMER side (the listen opener's serving stub — the test
|
||||
/// the CONSUMER side (the listen opener's serving stub, the test
|
||||
/// stand-in for the opener-side registration that lands with
|
||||
/// `tunnels/forwarded-receipt`): a `register_openable` (no
|
||||
/// establisher — the data plane starts immediately) whose handler
|
||||
/// establisher; the data plane starts immediately) whose handler
|
||||
/// correlates the open by the `listen` token against the test's
|
||||
/// correlation map, accepts the `BiStream`, and pumps against the
|
||||
/// "local BIND client" halves the test registered for that token.
|
||||
///
|
||||
/// `correlations` maps `listen` token → the pump side to pump the
|
||||
/// forwarded channel against (the assembly-side bookkeeping posture —
|
||||
/// forwarded channel against (the assembly-side bookkeeping posture:
|
||||
/// the protocol provides the op + params, the test provides the map).
|
||||
/// The registration is post-hoc on the consumer's captured registry —
|
||||
/// The registration is post-hoc on the consumer's captured registry,
|
||||
/// but the CONSUMER serves no ops in the default harness (its
|
||||
/// dispatch registry is empty and the dispatcher runs on it), so this
|
||||
/// helper must be called BEFORE the topology is wired; hence it is a
|
||||
@@ -611,7 +611,7 @@ pub type CorrelationMap =
|
||||
/// The forwarded-op handler (the opener-side serving stub's handler):
|
||||
/// correlate by `listen` → accept the `BiStream` → pump against the
|
||||
/// correlated halves (`pump_bidi`, convention 8). A correlation miss
|
||||
/// is a loud `handler_error`-class open failure (the typed posture —
|
||||
/// is a loud `handler_error`-class open failure (the typed posture:
|
||||
/// the accept dies at birth; the loop keeps serving).
|
||||
pub fn make_forwarded_stub_handler(
|
||||
correlations: CorrelationMap,
|
||||
@@ -650,7 +650,7 @@ pub fn make_forwarded_stub_handler(
|
||||
}
|
||||
|
||||
/// The forward topology (the `-L` direction): the consumer dials the
|
||||
/// transport (connect side — `ChannelClient::from_connection`, a pure
|
||||
/// transport (connect side, `ChannelClient::from_connection`, a pure
|
||||
/// consumer serving nothing) and the producer accepts (the adapter +
|
||||
/// an install hook that registers the channel ops + the tunnel
|
||||
/// openable on the producer's serving registry). This is the topology
|
||||
@@ -667,7 +667,7 @@ pub struct ForwardTopology {
|
||||
/// manager adopted the producer-allocated channel IDs; the
|
||||
/// session reaps via this manager.
|
||||
pub consumer: Arc<ChannelClient>,
|
||||
/// The producer's manager (accept side, serving) — for asserting
|
||||
/// The producer's manager (accept side, serving), for asserting
|
||||
/// wrapper-managed reaping.
|
||||
pub producer_manager: ChannelManager,
|
||||
/// What the establisher's per-call auth carried (the CF-006 probe).
|
||||
@@ -740,7 +740,7 @@ pub async fn wire_forward(
|
||||
let consumer_conn = CoreConnection::from_bidi(consumer_end, b"alk/channels".to_vec(), None);
|
||||
let producer_conn = CoreConnection::from_bidi(producer_end, b"alk/channels".to_vec(), None);
|
||||
|
||||
// The dialer's transport identity (the mTLS/QUIC posture) — set
|
||||
// The dialer's transport identity (the mTLS/QUIC posture), set
|
||||
// before from_connection. The accept side resolves it out-of-band
|
||||
// (modeled in the hook above).
|
||||
consumer_conn
|
||||
@@ -777,8 +777,9 @@ pub async fn wire_forward(
|
||||
}
|
||||
|
||||
/// The DIRECT-op forward topology (ADR-007): the producer (accept
|
||||
/// side, serving) registers the direct op — [`register_tunnel_direct_openable`]
|
||||
/// over the injected dial, CF-006 witness attached — and the consumer
|
||||
/// side, serving) registers the direct op
|
||||
/// ([`register_tunnel_direct_openable`] over the injected dial, CF-006
|
||||
/// witness attached) and the consumer
|
||||
/// (connect side, pure client) holds the identity the session tests
|
||||
/// authorize with. This is the topology `TunnelSession::open_direct`
|
||||
/// is normative for.
|
||||
@@ -812,7 +813,7 @@ pub async fn wire_direct_forward_with_identity(
|
||||
let (writer, reader) = split_single_stream(channel0_bidi);
|
||||
// The accept-side transport-identity posture: the transport
|
||||
// authenticated the dialer; attach the RESOLVED identity
|
||||
// (scopes included — resolved out-of-band) to channel 0.
|
||||
// (scopes included, resolved out-of-band) to channel 0.
|
||||
let _ = channel0_conn.set_identity(caller);
|
||||
let call_connection = Arc::new(CallConnection::new_single_stream(
|
||||
channel0_conn,
|
||||
@@ -877,14 +878,14 @@ pub async fn wire_direct_forward_with_identity(
|
||||
}
|
||||
}
|
||||
|
||||
/// The listen-flow topology (ADR-008 §4 — the `TunnelListener` shape):
|
||||
/// the OPENER dials the transport (connect side —
|
||||
/// The listen-flow topology (ADR-008 §4, the `TunnelListener` shape):
|
||||
/// the OPENER dials the transport (connect side,
|
||||
/// `ChannelClient::from_connection_with_serving`, so it can both open
|
||||
/// the listen tunnel and serve the forwarded op), and the producer
|
||||
/// accepts (the adapter + an install hook registering the bind-first
|
||||
/// openable over the injected bind + accept loop). The producer-side
|
||||
/// forwarded-open caller rides the accept-side call plane (the
|
||||
/// serving session's `CallConnection` — the same surface the serving
|
||||
/// serving session's `CallConnection`, the same surface the serving
|
||||
/// dispatch loop runs on; both-sides sanity, ADR-022 §2) and adopts on
|
||||
/// the producer-side manager. The forwarded op registers post-hoc (W2)
|
||||
/// on the opener's captured `opener_registry`.
|
||||
@@ -897,10 +898,10 @@ pub struct ListenerTopology {
|
||||
/// The opener's serving registry (the forwarded op registers here
|
||||
/// post-hoc).
|
||||
pub opener_registry: Arc<OperationRegistry>,
|
||||
/// The producer's manager (accept side, serving) — for asserting
|
||||
/// The producer's manager (accept side, serving), for asserting
|
||||
/// wrapper-managed reaping + for the test's adopt dance.
|
||||
pub producer_manager: ChannelManager,
|
||||
/// The producer-side call plane (`Arc<CallConnection>`) — the
|
||||
/// The producer-side call plane (`Arc<CallConnection>`), the
|
||||
/// forwarded-op caller's surface (and the test's direct-call
|
||||
/// surface for the adopt-pin dance).
|
||||
pub producer_call: Arc<CallConnection>,
|
||||
@@ -967,9 +968,9 @@ pub async fn wire_listener_bindfirst(
|
||||
.expect("register channel ops on producer");
|
||||
|
||||
// The forwarded-open caller (the call plane): call + adopt
|
||||
// (the reverse POC's `open_reverse_channel` shape — the
|
||||
// serving session's `CallConnection` is the per-accept call
|
||||
// plane; the producer-side manager adopts the
|
||||
// (the reverse POC's `open_reverse_channel` shape: the
|
||||
// serving session's `CallConnection` is the per-accept
|
||||
// call plane, and the producer-side manager adopts the
|
||||
// opener-allocated ID).
|
||||
let call_for_opener = Arc::clone(&call_connection);
|
||||
let manager_for_opener = manager.clone();
|
||||
@@ -1040,7 +1041,7 @@ pub async fn wire_listener_bindfirst(
|
||||
let (opener_end, producer_end) = tokio::io::duplex(64 * 1024);
|
||||
let opener_conn = CoreConnection::from_bidi(opener_end, b"alk/channels".to_vec(), None);
|
||||
let producer_conn = CoreConnection::from_bidi(producer_end, b"alk/channels".to_vec(), None);
|
||||
// The dialer's transport identity (the mTLS/QUIC posture) — set
|
||||
// The dialer's transport identity (the mTLS/QUIC posture), set
|
||||
// before from_connection_with_serving. The accept side resolves it
|
||||
// out-of-band (modeled in the hook above).
|
||||
opener_conn
|
||||
@@ -1090,7 +1091,7 @@ pub async fn wire_listener_bindfirst(
|
||||
}
|
||||
|
||||
/// An echo dial closure (the ADR-004 injection point under test): an
|
||||
/// in-process pipe per dial — the "target" echoes bytes until EOF.
|
||||
/// in-process pipe per dial; the "target" echoes bytes until EOF.
|
||||
/// The consumer side gets split `DuplexStream` halves; a spawned task
|
||||
/// echoes the other end. No `local` feature, no sockets.
|
||||
pub fn echo_dial() -> alktunnels::producer::DialFn {
|
||||
@@ -1142,7 +1143,7 @@ pub fn all_substrate_dial() -> alktunnels::producer::DialFn {
|
||||
}
|
||||
|
||||
/// A framed UDP echo dial whose target end CLOSES after the first
|
||||
/// echoed datagram — the EOF-path harness. `payload_completes`
|
||||
/// echoed datagram, the EOF-path harness. `payload_completes`
|
||||
/// controls the truncation shape: `true` forwards the whole frame
|
||||
/// (echo + payload intact) then drops → the clean-EOF path;
|
||||
/// `false` forwards only the 2-byte length prefix then drops → the
|
||||
@@ -1164,7 +1165,7 @@ pub fn closing_udp_echo_dial(payload_completes: bool) -> alktunnels::producer::D
|
||||
// echo-closes-after-first shape): forward it
|
||||
// whole (clean-EOF shape) or forward only its
|
||||
// length prefix (truncated shape), then drop
|
||||
// the far end — the channel read half EOFs.
|
||||
// the far end; the channel read half EOFs.
|
||||
let n = match t_read.read(&mut buf).await {
|
||||
Ok(0) | Err(_) => return,
|
||||
Ok(n) => n,
|
||||
@@ -1182,7 +1183,7 @@ pub fn closing_udp_echo_dial(payload_completes: bool) -> alktunnels::producer::D
|
||||
let _ = t_write.flush().await;
|
||||
} else {
|
||||
// The truncated shape: forward ONLY
|
||||
// the 2-byte length prefix, then drop —
|
||||
// the 2-byte length prefix and drop;
|
||||
// the declared payload never arrives.
|
||||
if t_write.write_all(&framed[..2]).await.is_err() {
|
||||
return;
|
||||
@@ -1219,7 +1220,7 @@ pub fn failing_dial(message: &'static str) -> alktunnels::producer::DialFn {
|
||||
})
|
||||
}
|
||||
|
||||
/// A framed UDP echo dial (the ADR-003 codec at the target boundary —
|
||||
/// A framed UDP echo dial (the ADR-003 codec at the target boundary,
|
||||
/// the in-process stand-in for the real `local` UDP adapter): the
|
||||
/// dial's halves carry the `[len: u16 BE]` wire framing; a spawned
|
||||
/// echo task decodes frames from the channel side and re-frames the
|
||||
|
||||
+26
-25
@@ -1,6 +1,6 @@
|
||||
//! Tests for the `local` feature's BIND-FIRST half (shape 3, ADR-008):
|
||||
//! the real-socket `BindFn` sources (`bind_tcp_first` /
|
||||
//! `bind_unix_first` — the `bound` reply field's data, port 0
|
||||
//! `bind_unix_first`, the `bound` reply field's data, port 0
|
||||
//! resolved), the accept-pair loops (peer + halves per accept), and
|
||||
//! the assembly convenience closures (`local_bind_first` +
|
||||
//! `local_bindfirst_accept_loop`), through the real bind-first
|
||||
@@ -10,7 +10,7 @@
|
||||
//! OS-chosen one (a fake `bound` would strand the test's dials), two
|
||||
//! clients dial → two accepts → two forwarded channels pumping
|
||||
//! independently (peer fidelity: each forwarded open carries the REAL
|
||||
//! accepted peer), unix bind-first parity (OQ-TN-14 — the bind path is
|
||||
//! accepted peer), unix bind-first parity (OQ-TN-14; the bind path is
|
||||
//! the same shape as the dial path), and the udp bind-first refusal
|
||||
//! (a UDP socket has no accept).
|
||||
|
||||
@@ -37,14 +37,14 @@ fn init_tracing() {
|
||||
|
||||
/// The forwarded-op recording stub's peer log: the `peer` param of
|
||||
/// every forwarded open the producer's dispatcher made (the
|
||||
/// reply#2-fidelity probe surface — ADR-008 §3).
|
||||
/// reply#2-fidelity probe surface (ADR-008 §3).
|
||||
type PeerLog = Arc<tokio::sync::Mutex<Vec<SubstrateAddr>>>;
|
||||
|
||||
/// The forwarded-op opener-side serving stub (the queue-fed shape):
|
||||
/// each forwarded open pops the NEXT app-side pair (the "local BIND
|
||||
/// client" halves the test registered) and pumps the forwarded
|
||||
/// channel against it, recording the open's `peer` param (the
|
||||
/// reply#2-fidelity probe surface — ADR-008 §3). One listener session
|
||||
/// reply#2-fidelity probe surface (ADR-008 §3). One listener session
|
||||
/// = one token = MANY forwarded opens (per accept), so the
|
||||
/// correlation is a queue, not a one-shot map.
|
||||
fn recording_forwarded_handler(app_sides: AppSideQueue, peers: PeerLog) -> OpenHandler {
|
||||
@@ -80,13 +80,13 @@ fn recording_forwarded_handler(app_sides: AppSideQueue, peers: PeerLog) -> OpenH
|
||||
}
|
||||
|
||||
/// The app-side pairs the stub pops per forwarded open (the "local
|
||||
/// BIND client" halves — the stub's pump target; the far end is the
|
||||
/// BIND client" halves, the stub's pump target; the far end is the
|
||||
/// test's echo task).
|
||||
type AppSideQueue =
|
||||
Arc<tokio::sync::Mutex<std::collections::VecDeque<alktunnels::producer::TargetHandle>>>;
|
||||
|
||||
/// Install the recording stub on the opener (consumer) side (post-hoc
|
||||
/// on the captured serving registry — W2).
|
||||
/// on the captured serving registry (W2).
|
||||
async fn install_recording_stub(topo: &harness::Topology) -> (AppSideQueue, PeerLog) {
|
||||
let app_sides: AppSideQueue = Arc::new(tokio::sync::Mutex::new(Default::default()));
|
||||
let peers: PeerLog = Arc::new(tokio::sync::Mutex::new(Vec::new()));
|
||||
@@ -105,7 +105,7 @@ async fn install_recording_stub(topo: &harness::Topology) -> (AppSideQueue, Peer
|
||||
}
|
||||
|
||||
/// Call the producer's open op on channel 0 with the FULL reply (the
|
||||
/// bind-first reply projection probe — `bound` + `listen`).
|
||||
/// bind-first reply projection probe (`bound` + `listen`).
|
||||
async fn open_listen_with_reply(
|
||||
topo: &harness::Topology,
|
||||
resource: &str,
|
||||
@@ -130,7 +130,7 @@ async fn open_listen_with_reply(
|
||||
}
|
||||
|
||||
/// One app-side session: a raw duplex pair whose FAR end echoes (the
|
||||
/// app-side session shape) — the stub pumps the forwarded channel
|
||||
/// app-side session shape); the stub pumps the forwarded channel
|
||||
/// against the near end (pushed into the stub's queue); the test
|
||||
/// drives the REAL accepted socket at the tunnel's other side, whose
|
||||
/// bytes echo through the app-side session.
|
||||
@@ -161,7 +161,7 @@ fn echo_app_side() -> alktunnels::producer::TargetHandle {
|
||||
|
||||
/// THE bind-first flow over REAL TCP sockets (the `local` shape-3
|
||||
/// gate): `bind_tcp_first` binds port 0 → the reply's `bound` is the
|
||||
/// REAL OS-chosen address (the test dials it — a fake bound would
|
||||
/// REAL OS-chosen address (the test dials it; a fake bound would
|
||||
/// strand the dial) → two clients dial → two accepts → two forwarded
|
||||
/// channels → both pump independently, and each forwarded open
|
||||
/// carries the REAL accepted peer (peer fidelity, reply#2 class).
|
||||
@@ -197,7 +197,7 @@ async fn bindfirst_tcp_real_sockets_bound_fidelity_and_two_pumps() {
|
||||
let (app_sides, peers) = install_recording_stub(&topo).await;
|
||||
|
||||
// The opener adopts the LISTEN channel (the idle side). The
|
||||
// halves are HELD — dropping them EOFs the idle stream and tears
|
||||
// halves are HELD; dropping them EOFs the idle stream and tears
|
||||
// the listener down (the ADR-008 sentinel is live even here).
|
||||
let (_idle_send, _idle_recv) = topo
|
||||
.consumer_manager
|
||||
@@ -251,7 +251,7 @@ async fn bindfirst_tcp_real_sockets_bound_fidelity_and_two_pumps() {
|
||||
assert_eq!(ports.len(), 2, "two accepts carried distinct real peers");
|
||||
}
|
||||
|
||||
/// Unix bind-first parity (OQ-TN-14 — the bind path ships with the
|
||||
/// Unix bind-first parity (OQ-TN-14; the bind path ships with the
|
||||
/// dial path): `bind_unix_first` binds a temp-path listener → the
|
||||
/// reply's `bound` is the REAL bound socket path → a unix client
|
||||
/// (bound to its own path, so the peer is representable) connects →
|
||||
@@ -295,7 +295,7 @@ async fn bindfirst_unix_real_sockets_bound_and_pumped() {
|
||||
|
||||
let (app_sides, peers) = install_recording_stub(&topo).await;
|
||||
|
||||
// The opener adopts the LISTEN channel (the idle side) — held for
|
||||
// The opener adopts the LISTEN channel (the idle side), held for
|
||||
// the test's lifetime (dropping the halves EOFs the idle stream
|
||||
// and tears the listener down, the ADR-008 sentinel).
|
||||
let (_idle_send, _idle_recv) = topo
|
||||
@@ -309,7 +309,7 @@ async fn bindfirst_unix_real_sockets_bound_and_pumped() {
|
||||
|
||||
// The unix client binds its own socket at a named path FIRST (an
|
||||
// unnamed peer cannot be represented in the forwarded `peer`
|
||||
// param — ADR-008 §3; the named-peer shape is the parity case).
|
||||
// param (ADR-008 §3); the named-peer shape is the parity case).
|
||||
// socket2: std has no `UnixStream::bind`; a socket2 socket binds
|
||||
// the sockaddr at the path, then connects (the peer address is
|
||||
// the client's own bound path).
|
||||
@@ -350,7 +350,7 @@ async fn bindfirst_unix_real_sockets_bound_and_pumped() {
|
||||
}
|
||||
|
||||
/// The udp bind-first refusal: a UDP socket has no accept, so the
|
||||
/// bind closure fails loud (`handler_error`-class) — the shape is not
|
||||
/// bind closure fails loud (`handler_error`-class); the shape is not
|
||||
/// defined there, never a silent fake listener.
|
||||
#[tokio::test]
|
||||
async fn udp_bind_first_fails_loud() {
|
||||
@@ -383,7 +383,7 @@ async fn udp_bind_first_fails_loud() {
|
||||
|
||||
/// The accept-pair loop's mismatch probe: a listener NOT produced by
|
||||
/// this module's bind closures fails loud inside the closure (the
|
||||
/// downcast is the assembly side of the type-erased injection — a
|
||||
/// downcast is the assembly side of the type-erased injection; a
|
||||
/// foreign listener type is `handler_error`, never a phantom accept).
|
||||
#[tokio::test]
|
||||
async fn accept_pair_loop_downcast_miss_fails_loud() {
|
||||
@@ -401,27 +401,28 @@ async fn accept_pair_loop_downcast_miss_fails_loud() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The unnamed-peer/unnamed-bind fail-loud arms (G-5 — ADR-008 §3's
|
||||
/// The unnamed-peer/unnamed-bind fail-loud arms (G-5, ADR-008 §3's
|
||||
/// two pinned sentences: a producer that cannot determine the peer
|
||||
/// does not serve a bind-first listener over that substrate):
|
||||
///
|
||||
/// - **Unnamed bind** — an empty-path `bind_unix` is the kernel's
|
||||
/// - **Unnamed bind**: an empty-path `bind_unix` is the kernel's
|
||||
/// autobind (the kernel assigns an abstract name, addlen ==
|
||||
/// offsetof(sun_path)); the bound addr has no filesystem path, so
|
||||
/// `local_addr` (and thus `bind_unix_first`) fails loud with the
|
||||
/// "no filesystem path" handler error instead of serving an
|
||||
/// unrepresentable `bound`.
|
||||
/// - **Unnamed peer** — an addressless client (socket2 autobind
|
||||
/// - **Unnamed peer**: an addressless client (socket2 autobind
|
||||
/// here; a plain unbound client behaves identically through tokio
|
||||
/// accept — mio maps any abstract-namespace peer to unnamed) is
|
||||
/// accept; mio maps any abstract-namespace peer to unnamed) is
|
||||
/// accepted by a NAMED listener; `accept_pair` fails loud because
|
||||
/// the forwarded op's `peer` param is required on the wire.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[tokio::test]
|
||||
async fn unix_unnamed_bind_and_unnamed_peer_fail_loud() {
|
||||
// Arm 1: the autobind listener — `bind_unix("")`. tokio passes the
|
||||
// empty path through to `bind(2)` with addlen == offsetof(sun_path):
|
||||
// the kernel autobinds an abstract name, no filesystem path exists.
|
||||
// Arm 1 probes the autobind listener: `bind_unix("")`. tokio
|
||||
// passes the empty path through to `bind(2)` with addlen ==
|
||||
// offsetof(sun_path): the kernel autobinds an abstract name, no
|
||||
// filesystem path exists.
|
||||
let halves = alktunnels::local::bind_unix("")
|
||||
.expect("autobind bind succeeds (the kernel assigns the abstract name)");
|
||||
let err = halves
|
||||
@@ -436,9 +437,9 @@ async fn unix_unnamed_bind_and_unnamed_peer_fail_loud() {
|
||||
"unnamed bind maps to the handler error: {err:?}"
|
||||
);
|
||||
|
||||
// Arm 2: the unnamed peer — a socket2 autobind client connects to a
|
||||
// NAMED listener; `accept_pair` must refuse (the forwarded `peer`
|
||||
// param requires an address).
|
||||
// Arm 2 probes the unnamed peer: a socket2 autobind client
|
||||
// connects to a NAMED listener; `accept_pair` must refuse (the
|
||||
// forwarded `peer` param requires an address).
|
||||
let dir = std::env::temp_dir().join(format!("alktunnels-g5-{}", std::process::id()));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let listen_path = dir.join("named.sock");
|
||||
|
||||
+50
-50
@@ -5,8 +5,8 @@
|
||||
//!
|
||||
//! The forward topology (`wire_forward`) carries the full path: the
|
||||
//! consumer opens (`TunnelSession::open`), the producer's establisher
|
||||
//! dials via `local_dial`, the pump handler pumps — real sockets at
|
||||
//! both ends.
|
||||
//! dials via `local_dial`, and the pump handler pumps. Real sockets
|
||||
//! sit at both ends.
|
||||
|
||||
#![cfg(feature = "local")]
|
||||
|
||||
@@ -115,7 +115,7 @@ async fn udp_datagram_round_trip_through_the_framed_adapter() {
|
||||
async fn direct_udp_through_local_dial_round_trips_framed() {
|
||||
// The DIRECT op's DialFn reuse pin (the local-bind-halves task):
|
||||
// `local_dial` is the ONE dial closure the base op and the direct
|
||||
// op share — the UDP wrap (the FRAMED `UdpHalf` adapter, ADR-003)
|
||||
// op share. The UDP wrap (the FRAMED `UdpHalf` adapter, ADR-003)
|
||||
// happens INSIDE the closure (review 001 U-1), so direct-udp gets
|
||||
// datagram-boundary framing free. Driven against a REAL UDP echo
|
||||
// socket: the session sends the codec frame, the direct establisher
|
||||
@@ -219,14 +219,14 @@ async fn truncation_fails_loud_oq_tn_13() {
|
||||
// The fail-loud posture (OQ-TN-13) at the FRAMED adapter: the
|
||||
// socket-facing recv uses the full 65535-byte scratch (try_recv
|
||||
// truncates silently at the buffer size), so a well-formed
|
||||
// >caller-buffer datagram is received whole and STAGED — never
|
||||
// >caller-buffer datagram is received whole and STAGED, never
|
||||
// silently truncated into a shorter frame. The staging surface is
|
||||
// pinned by `oversized_frame_stages_across_reads` (below); this
|
||||
// test pins the fail-loud path directly at the unit level: a
|
||||
// scratch smaller than the datagram WOULD have corrupted the
|
||||
// framing (the U-3 re-validation's warning), so the scratch must
|
||||
// be full-size. A datagram at the IP/UDP max (65507) is within
|
||||
// the u16 frame bound (65535) — it must arrive intact.
|
||||
// the u16 frame bound (65535); it must arrive intact.
|
||||
let sock_a = tokio::net::UdpSocket::bind(("127.0.0.1", 0))
|
||||
.await
|
||||
.expect("bind a");
|
||||
@@ -244,14 +244,14 @@ async fn truncation_fails_loud_oq_tn_13() {
|
||||
|
||||
// The producer-side adapter (what the establisher dials): sock_a
|
||||
// wrapped as halves. A 65507-byte datagram (the UDP theoretical
|
||||
// max — a well-formed wire's largest possible frame) from the
|
||||
// max, a well-formed wire's largest possible frame) from the
|
||||
// wire side.
|
||||
let (read, _write) = alktunnels::local::UdpHalf::split(sock_a);
|
||||
let mut read = read;
|
||||
let payload: Vec<u8> = (0..65507u32).map(|i| (i % 251) as u8).collect();
|
||||
sock_b.send(&payload).await.expect("send max-size");
|
||||
|
||||
// The pump reads with the codec's chunk size (16 KiB) — far
|
||||
// The pump reads with the codec's chunk size (16 KiB), far
|
||||
// smaller than the datagram: the adapter must STAGE, never
|
||||
// truncate. Drain through small reads and reassemble the frame.
|
||||
use tokio::io::AsyncReadExt;
|
||||
@@ -269,12 +269,12 @@ async fn truncation_fails_loud_oq_tn_13() {
|
||||
assert_eq!(
|
||||
&got[2..],
|
||||
&payload[..],
|
||||
"full payload intact — no truncation"
|
||||
"full payload intact; no truncation"
|
||||
);
|
||||
}
|
||||
|
||||
/// The >caller-buffer (but within-frame-bound) datagram: the framed
|
||||
/// adapter STAGES the frame and hands it out across reads — the pump's
|
||||
/// adapter STAGES the frame and hands it out across reads; the pump's
|
||||
/// far-side de-framer reassembles the true length (no silent
|
||||
/// truncation, no shorter-datagram misread). The reassembly is pinned
|
||||
/// by the end-to-end gates below; here the staging surface is pinned
|
||||
@@ -301,7 +301,7 @@ async fn oversized_frame_stages_across_reads() {
|
||||
let payload: Vec<u8> = (0..20000u32).map(|i| (i % 251) as u8).collect();
|
||||
sock_b.send(&payload).await.expect("send 20k");
|
||||
|
||||
// Read through a small caller buffer (16 KiB — smaller than the
|
||||
// Read through a small caller buffer (16 KiB, smaller than the
|
||||
// ~20002-byte frame): the first read fills it completely, the
|
||||
// second hands out the staged remainder.
|
||||
use tokio::io::AsyncReadExt;
|
||||
@@ -333,7 +333,7 @@ fn len_lo(n: usize) -> u8 {
|
||||
/// A RECORDING UDP "target" (review 001's verification-gate probe
|
||||
/// shape, made permanent): it records every datagram it receives
|
||||
/// (byte-for-byte, to the shared log) and replies with canned,
|
||||
/// configurable datagrams — including EMPTY and RAW-UNFRAMED ones the
|
||||
/// configurable datagrams, including EMPTY and RAW-UNFRAMED ones the
|
||||
/// echo server can't produce.
|
||||
struct RecordingTarget {
|
||||
addr: std::net::SocketAddr,
|
||||
@@ -376,7 +376,7 @@ fn params(resource: &str, substrate: Substrate) -> TunnelParams {
|
||||
}
|
||||
}
|
||||
|
||||
/// GATE 1a (review 001): the target receives PAYLOAD BYTES ONLY — no
|
||||
/// GATE 1a (review 001): the target receives PAYLOAD BYTES ONLY, no
|
||||
/// `[len: u16 BE]` codec prefix (the U-1 defect's primary face).
|
||||
#[tokio::test]
|
||||
async fn gate_target_receives_payload_bytes_only() {
|
||||
@@ -408,7 +408,7 @@ async fn gate_target_receives_payload_bytes_only() {
|
||||
session.close().await;
|
||||
}
|
||||
|
||||
/// GATE 1b: an EMPTY datagram round-trips over the REAL socket path —
|
||||
/// GATE 1b: an EMPTY datagram round-trips over the REAL socket path:
|
||||
/// `recv_datagram` yields `Some(b"")` and the channel stays alive
|
||||
/// (the F-2 fix end-to-end; the raw adapter collapsed it to EOF).
|
||||
#[tokio::test]
|
||||
@@ -434,7 +434,7 @@ async fn gate_empty_datagram_round_trips_over_the_real_socket_path() {
|
||||
assert!(dg.is_empty(), "the empty datagram round-trips");
|
||||
|
||||
// The channel is still alive: a second exchange works (the
|
||||
// canned reply cycles — the target sends b"still-alive" back).
|
||||
// canned reply cycles; the target sends b"still-alive" back).
|
||||
session.send_datagram(b"second").await.expect("send 2");
|
||||
let dg2 = tokio::time::timeout(std::time::Duration::from_secs(5), session.recv_datagram())
|
||||
.await
|
||||
@@ -446,7 +446,7 @@ async fn gate_empty_datagram_round_trips_over_the_real_socket_path() {
|
||||
}
|
||||
|
||||
/// GATE 2: a >8 KiB datagram round-trips as ONE datagram at the
|
||||
/// target (the 8 KiB copy-buffer split is gone — the frame reassembles
|
||||
/// target (the 8 KiB copy-buffer split is gone; the frame reassembles
|
||||
/// at the adapter before crossing the pump).
|
||||
#[tokio::test]
|
||||
async fn gate_large_datagram_arrives_as_one_datagram() {
|
||||
@@ -460,7 +460,7 @@ async fn gate_large_datagram_arrives_as_one_datagram() {
|
||||
let mut session = TunnelSession::open(&topo.consumer, params("big", Substrate::Udp))
|
||||
.await
|
||||
.expect("udp open");
|
||||
// 9000 bytes — the review's probe size that split as 8192 + 810.
|
||||
// 9000 bytes, the review's probe size that split as 8192 + 810.
|
||||
let payload: Vec<u8> = (0..9000u32).map(|i| (i % 251) as u8).collect();
|
||||
session.send_datagram(&payload).await.expect("send 9000");
|
||||
for _ in 0..100 {
|
||||
@@ -482,10 +482,10 @@ async fn gate_large_datagram_arrives_as_one_datagram() {
|
||||
|
||||
/// GATE 3: an unframed target-initiated datagram resolves at the
|
||||
/// session (the session codec decodes the framed bytes the adapter
|
||||
/// produced — real-target traffic is not parked mid-frame).
|
||||
/// produced; real-target traffic is not parked mid-frame).
|
||||
#[tokio::test]
|
||||
async fn gate_unframed_target_datagram_resolves_at_the_session() {
|
||||
// A raw "HELLO" — a real server's greeting the raw adapter parked
|
||||
// A raw "HELLO": a real server's greeting the raw adapter parked
|
||||
// mid-frame (the probe's 2s timeout).
|
||||
let target = recording_udp_target(vec![b"HELLO".to_vec()]).await;
|
||||
let registry = ResourceRegistry::new();
|
||||
@@ -500,14 +500,14 @@ async fn gate_unframed_target_datagram_resolves_at_the_session() {
|
||||
session.send_datagram(b"ping").await.expect("send");
|
||||
let dg = tokio::time::timeout(std::time::Duration::from_secs(5), session.recv_datagram())
|
||||
.await
|
||||
.expect("recv timed out — unframed target traffic was parked/dropped")
|
||||
.expect("recv timed out; unframed target traffic was parked/dropped")
|
||||
.expect("io ok")
|
||||
.expect("datagram");
|
||||
assert_eq!(&dg[..], b"HELLO");
|
||||
session.close().await;
|
||||
}
|
||||
|
||||
/// U-2 (review 001): `connect_udp`'s bind is family-correct — a v6
|
||||
/// U-2 (review 001): `connect_udp`'s bind is family-correct. A v6
|
||||
/// target dials from a v6-bound socket (the hardcoded `0.0.0.0` bind
|
||||
/// broke every v6-only target at connect). A real ::1 target
|
||||
/// round-trips through the full path.
|
||||
@@ -583,7 +583,7 @@ async fn listen_producer_over_a_real_tcp_listener() {
|
||||
});
|
||||
|
||||
// A local client connects (the accepted connection's source). The
|
||||
// driving end comes from `try_clone` — the pump owns the accepted
|
||||
// driving end comes from `try_clone`; the pump owns the accepted
|
||||
// handle; the driver pushes/reads the echo through the same real
|
||||
// socket.
|
||||
// std-level try_clone BEFORE converting to tokio streams: one fd
|
||||
@@ -629,7 +629,7 @@ async fn listen_producer_over_a_real_tcp_listener() {
|
||||
.await
|
||||
.expect("adopt");
|
||||
// The producer's pump (wrapper-managed) copies channel <-> the
|
||||
// accepted handle; the accepted handle IS the client socket — so
|
||||
// accepted handle; the accepted handle IS the client socket, so
|
||||
// the session's borrowed halves drive it directly: the driver end
|
||||
// writes into the client socket, the pump carries it over the
|
||||
// channel, and the session halves read it (and back).
|
||||
@@ -653,13 +653,13 @@ async fn listen_producer_over_a_real_tcp_listener() {
|
||||
// UdpHalf write-side machinery (review 001's U-1 invariants, the
|
||||
// never-blocks paths the end-to-end gates don't reach): WouldBlock
|
||||
// stashing, the accepted-byte high-water backpressure, the flush
|
||||
// drain, and the shutdown best-effort drain — driven with poll_fn
|
||||
// drain, and the shutdown best-effort drain, driven with poll_fn
|
||||
// over a shrunken-SO_SNDBUF socket (deterministic, no timing).
|
||||
// =====================================================================
|
||||
|
||||
/// An UNCONNECTED UdpHalf write half: `try_send` fails
|
||||
/// deterministically with `EDESTADDRREQ` ("destination address
|
||||
/// required") — the deterministic probe for the write path's ERROR
|
||||
/// required"), the deterministic probe for the write path's ERROR
|
||||
/// arms on this host, where no UDP sender-blocking shape was
|
||||
/// reachable (loopback/veth drop silently; see the host-notes on the
|
||||
/// drain test).
|
||||
@@ -671,7 +671,7 @@ fn unconnected_write_half() -> Box<dyn tokio::io::AsyncWrite + Send + Sync + Unp
|
||||
write
|
||||
}
|
||||
|
||||
/// Drive one `poll_write` with the given buffer (one poll — the
|
||||
/// Drive one `poll_write` with the given buffer (one poll, the
|
||||
/// pump's copy shape).
|
||||
async fn poll_write_once<W: tokio::io::AsyncWrite + Unpin>(
|
||||
write: &mut W,
|
||||
@@ -700,11 +700,11 @@ async fn udp_half_write_queue_drains_on_flush_and_shutdown() {
|
||||
// accepts frames (the de-framer's queue + accepted accounting),
|
||||
// flush drains them onto the socket (Ready(Ok)), and shutdown's
|
||||
// best-effort drain reports Ready(Ok) (UDP has no shutdown).
|
||||
// The peer reads (a real probe) — the datagrams land.
|
||||
// The peer reads (a real probe), and the datagrams land.
|
||||
//
|
||||
// Host-notes (probed 2026-09-09): (a) a fresh-socket first
|
||||
// `try_send` may report WouldBlock spuriously on this kernel
|
||||
// (one readiness+retry cycle clears it, ~20% of runs) — the
|
||||
// (one readiness+retry cycle clears it, ~20% of runs); the
|
||||
// flush loop tolerates it, which also exercises the `sending`
|
||||
// stash + re-offer path whenever it fires. (b) The test runs
|
||||
// multi-thread: on a current-thread runtime the kernel's
|
||||
@@ -746,7 +746,7 @@ async fn udp_half_write_queue_drains_on_flush_and_shutdown() {
|
||||
);
|
||||
}
|
||||
|
||||
// Flush drains the queue onto the socket — bounded cycles: the
|
||||
// Flush drains the queue onto the socket (bounded cycles): the
|
||||
// quirk's stash+Pending resolves on the next flush (poll_send_ready
|
||||
// is Ok), a saturated socket stays queued (not this host's case).
|
||||
let mut flushed = false;
|
||||
@@ -792,12 +792,12 @@ async fn udp_half_write_queue_drains_on_flush_and_shutdown() {
|
||||
#[tokio::test]
|
||||
async fn udp_half_write_error_maps_loud_on_unconnected_socket() {
|
||||
// The ERROR arms: an unconnected socket's `try_send` fails
|
||||
// deterministically (`EDESTADDRREQ`) — poll_write's drain maps
|
||||
// deterministically (`EDESTADDRREQ`), so poll_write's drain maps
|
||||
// the socket error loud (the `pump_queue_to_socket` Err arm),
|
||||
// and flush re-raises it (the `poll_flush` error surface). The
|
||||
// high-water backpressure and the WouldBlock stash remain
|
||||
// host-dependent (see connected_udp_pair's note) — covered by the
|
||||
// leftover-coverage task.
|
||||
// host-dependent (see connected_udp_pair's note); coverage lives
|
||||
// with the leftover-coverage task.
|
||||
let mut write = unconnected_write_half();
|
||||
|
||||
let frame = [0, 3, b'x', b'y', b'z'];
|
||||
@@ -822,9 +822,9 @@ async fn udp_half_write_error_maps_loud_on_unconnected_socket() {
|
||||
#[tokio::test]
|
||||
async fn udp_half_poll_shutdown_drains_best_effort_and_is_idempotent() {
|
||||
// UDP has no shutdown: poll_shutdown drains the queue best-effort
|
||||
// and reports Ready(Ok) — the drop-is-close posture. On a
|
||||
// and reports Ready(Ok), the drop-is-close posture. On a
|
||||
// saturated socket the drain is best-effort too (the error is
|
||||
// swallowed — shutdown never fails the pump).
|
||||
// swallowed; shutdown never fails the pump).
|
||||
let mut write = unconnected_write_half();
|
||||
use tokio::io::AsyncWriteExt;
|
||||
write
|
||||
@@ -843,7 +843,7 @@ async fn udp_half_flush_drains_repeatedly_until_the_queue_is_empty() {
|
||||
// The flush loop's pump-again shape (tokio's copy polls flush
|
||||
// after every window drain): each flush drains that window's
|
||||
// queued datagrams; five write+flush cycles → five datagrams at
|
||||
// the peer (the fresh-socket quirk tolerated per cycle — see the
|
||||
// the peer (the fresh-socket quirk tolerated per cycle; see the
|
||||
// host-notes on the drain test; multi-thread runtime for the same
|
||||
// delivery-timing reason).
|
||||
let peer = tokio::net::UdpSocket::bind(("127.0.0.1", 0))
|
||||
@@ -874,7 +874,7 @@ async fn udp_half_flush_drains_repeatedly_until_the_queue_is_empty() {
|
||||
for i in 0..5u8 {
|
||||
let frame = [0, 1, i];
|
||||
poll_write_once(&mut write, &frame).await.expect("accept");
|
||||
// Each cycle flushes (bounded retries — the fresh-socket
|
||||
// Each cycle flushes (bounded retries: the fresh-socket
|
||||
// quirk's stash clears on the second readiness cycle).
|
||||
let mut flushed = false;
|
||||
for _ in 0..8 {
|
||||
@@ -906,7 +906,7 @@ async fn udp_half_flush_drains_repeatedly_until_the_queue_is_empty() {
|
||||
}
|
||||
|
||||
/// The resolve arm (`connect_udp`'s no-addresses mapping): a target
|
||||
/// whose name cannot be resolved (an RFC 6761 `.invalid` name —
|
||||
/// whose name cannot be resolved (an RFC 6761 `.invalid` name,
|
||||
/// guaranteed NXDOMAIN, resolved instantly and without network I/O)
|
||||
/// maps to `dial_failed` (the resolve step's error posture, ADR-004's
|
||||
/// `DialFn` contract). Rootless, deterministic.
|
||||
@@ -928,14 +928,14 @@ async fn connect_udp_unresolvable_target_maps_to_dial_failed() {
|
||||
|
||||
// =====================================================================
|
||||
// The sender-block machinery (the coverage-weak-spots task's option 1,
|
||||
// CLOSED — the recipe was found): a veth fixture with the peer
|
||||
// CLOSED; the recipe was found): a veth fixture with the peer
|
||||
// address UNASSIGNED and the neighbor table flushed puts the
|
||||
// connected UDP sender into a PERMANENT WouldBlock/ENOSPC state
|
||||
// (the unresolved neighbor never resolves; the blocked write path's
|
||||
// skb stays charged to the socket's wmem with a shrunken SO_SNDBUF).
|
||||
// Assigning the peer address + binding the peer resolves ARP; the
|
||||
// queued/stashed datagrams drain and DELIVER. Validated on this host
|
||||
// (kernel 6.8.0-110-generic, tokio 1.53.1, socket2 0.6.5) — 3/3
|
||||
// (kernel 6.8.0-110-generic, tokio 1.53.1, socket2 0.6.5): 3/3
|
||||
// deterministic probe runs, 2026-09-09.
|
||||
//
|
||||
// Root-gated (`ip link add`): auto-skipped without root. Never
|
||||
@@ -944,7 +944,7 @@ async fn connect_udp_unresolvable_target_maps_to_dial_failed() {
|
||||
|
||||
/// The veth fixture (created and torn down per run): `alktx/alktx-p`
|
||||
/// with 10.240.0.1/24 (the sender side) and 10.240.0.9 (the peer
|
||||
/// side, UNASSIGNED during the block phase — its absence is what
|
||||
/// side, UNASSIGNED during the block phase; its absence is what
|
||||
/// pins the sender). The peer address is assigned mid-test (the
|
||||
/// resolve step). A dead-simple guard skips the test when `ip` is
|
||||
/// unavailable or the command fails (non-root hosts).
|
||||
@@ -992,7 +992,7 @@ impl VethFixture {
|
||||
return None;
|
||||
}
|
||||
let _ = ip_cmd(&["addr", "add", "10.240.0.1/24", "dev", "alktx"]).await;
|
||||
// A jumbo MTU (65536): a 60 KB datagram crosses as ONE frame —
|
||||
// A jumbo MTU (65536): a 60 KB datagram crosses as ONE frame,
|
||||
// no IP fragmentation, so no fragment-reassembly races at the
|
||||
// peer (fragment-ID collisions under the drain burst merged
|
||||
// datagrams on an MTU-1500 veth during the probe runs).
|
||||
@@ -1039,14 +1039,14 @@ impl Drop for VethFixture {
|
||||
/// A sender socket blocked on the unresolved neighbor: connect to the
|
||||
/// UNASSIGNED peer address, flush the neighbor table, shrunken
|
||||
/// SO_SNDBUF. `try_send` on a 60 KB datagram (truesize beyond the
|
||||
/// buffer) then reports WouldBlock/ENOSPC persistently — the state
|
||||
/// buffer) then reports WouldBlock/ENOSPC persistently; the state
|
||||
/// the write-side machinery is for. Returns the tokio socket ready
|
||||
/// for `UdpHalf::split`.
|
||||
async fn blocked_sender_socket(peer_port: u16) -> Option<tokio::net::UdpSocket> {
|
||||
// The BLOCK-STATE PROBE runs on a THROWAWAY socket: an oversized
|
||||
// probe datagram's partially-built skb chain stays corked in
|
||||
// sk_write_queue on EAGAIN (the kernel flushes it on the next
|
||||
// successful send) — poisoning the socket with a stale partial
|
||||
// successful send), poisoning the socket with a stale partial
|
||||
// datagram. The test socket below is created fresh afterwards.
|
||||
let probe = socket2::Socket::new(
|
||||
socket2::Domain::IPV4,
|
||||
@@ -1065,7 +1065,7 @@ async fn blocked_sender_socket(peer_port: u16) -> Option<tokio::net::UdpSocket>
|
||||
|
||||
// Confirm the block actually holds (the host invariant): with a
|
||||
// 64 KiB SO_SNDBUF, the FIRST 60 KB datagram queues on the
|
||||
// unresolved neighbor (Ok — its skb is charged to wmem); every
|
||||
// unresolved neighbor (Ok; its skb is charged to wmem); every
|
||||
// SUBSEQUENT send EAGAINs (the charge exceeds the buffer) until
|
||||
// the neighbor resolves. That persistent EAGAIN/ENOBUFS pair IS
|
||||
// the block state. A resolved neighbor would send Ok throughout.
|
||||
@@ -1101,10 +1101,10 @@ async fn blocked_sender_socket(peer_port: u16) -> Option<tokio::net::UdpSocket>
|
||||
Some(snd)
|
||||
}
|
||||
|
||||
/// THE sender-block machinery, end to end (option 1 — one test, the
|
||||
/// THE sender-block machinery, end to end (option 1: one test, the
|
||||
/// coherent behavior): the WouldBlock stash (`poll_write`'s
|
||||
/// opportunistic drain stashes the mid-send datagram and registers
|
||||
/// the send waker — `pump_queue_to_socket`'s Pending arms), the
|
||||
/// the send waker, `pump_queue_to_socket`'s Pending arms), the
|
||||
/// accepted-byte high-water backpressure (`poll_write` Pends when the
|
||||
/// queue stays over [`alktunnels::local::WRITE_QUEUE_HIGH_WATER`]),
|
||||
/// the flush re-pump (readiness → drain), and the post-resolve
|
||||
@@ -1185,7 +1185,7 @@ async fn udp_half_write_side_blocks_stashes_and_drains_on_resolve() {
|
||||
}
|
||||
assert!(pending_flushes > 0, "the blocked socket Pends the flush");
|
||||
|
||||
// Phase B: resolve — assign the peer address (the neighbor
|
||||
// Phase B: resolve. Assign the peer address (the neighbor
|
||||
// completes), bind the peer, wait for ARP; the queued datagrams
|
||||
// drain and DELIVER.
|
||||
assert!(veth.assign_peer(), "assign the peer address");
|
||||
@@ -1212,10 +1212,10 @@ async fn udp_half_write_side_blocks_stashes_and_drains_on_resolve() {
|
||||
assert!(flushed, "the queued datagrams drained after the resolve");
|
||||
|
||||
// The peer received the queued datagrams (delivery across the
|
||||
// blocked→resolved window — the stash/queue never lost them; at
|
||||
// least the stashed tail must arrive — the periodic
|
||||
// INCOMPLETE→FAILED drops may consume some queued ones, which
|
||||
// is kernel-side loss, not adapter loss).
|
||||
// blocked→resolved window): the stash/queue never lost them, and
|
||||
// at least the stashed tail must arrive. The periodic
|
||||
// INCOMPLETE→FAILED drops may consume some queued ones; that
|
||||
// loss is kernel-side, not adapter loss).
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
|
||||
let mut got = 0usize;
|
||||
let mut buf = vec![0u8; 65535];
|
||||
|
||||
+28
-28
@@ -1,8 +1,8 @@
|
||||
//! Integration tests for the bind-first establisher + accept
|
||||
//! dispatcher (shape 3 — ADR-008 §1): the producer whose establisher
|
||||
//! dispatcher (shape 3, ADR-008 §1): the producer whose establisher
|
||||
//! BINDS (via the injected `BindFn`), mints the correlation token,
|
||||
//! contributes `bound` + `listen` to the open reply, and whose pump
|
||||
//! handler is the accept dispatcher — per accept it opens
|
||||
//! handler is the accept dispatcher, which per accept opens
|
||||
//! `channels/tunnel/forwarded` back toward the listen opener and pumps
|
||||
//! the accepted halves against the adopted channel.
|
||||
//!
|
||||
@@ -48,7 +48,7 @@ fn listen_params(resource: &str) -> TunnelParams {
|
||||
}
|
||||
|
||||
/// Call the producer's open op on channel 0 with the FULL reply
|
||||
/// (the consumer's bind-first surface — the reply projection probe).
|
||||
/// (the consumer's bind-first surface, the reply projection probe).
|
||||
async fn open_listen_with_reply(
|
||||
topo: &Topology,
|
||||
params: &TunnelParams,
|
||||
@@ -112,7 +112,7 @@ async fn push_accept(
|
||||
}
|
||||
|
||||
/// The assembly's bind: produces a "listener" (the queue's identity)
|
||||
/// and the OS-chosen bound address (the test's fake — a real
|
||||
/// and the OS-chosen bound address (the test's fake; a real
|
||||
/// `bind_tcp` variant lives in the `local` feature).
|
||||
fn test_bind() -> BindFn {
|
||||
Arc::new(|_substrate, _backing| {
|
||||
@@ -128,7 +128,7 @@ fn test_bind() -> BindFn {
|
||||
})
|
||||
}
|
||||
|
||||
/// A failing bind (the `dial_failed`-class probe — the bind IS the
|
||||
/// A failing bind (the `dial_failed`-class probe, since the bind IS the
|
||||
/// dial here).
|
||||
fn failing_bind(message: &'static str) -> BindFn {
|
||||
Arc::new(move |_substrate, _backing| {
|
||||
@@ -150,7 +150,7 @@ fn only_channel_0(topo: &Topology) -> bool {
|
||||
}
|
||||
|
||||
/// The bind-first topology keeps the LISTEN channel open (the
|
||||
/// dispatcher holds it) — the "no stray channels" probe excludes it
|
||||
/// dispatcher holds it); the "no stray channels" probe excludes it
|
||||
/// (and channel 0, the call plane) on both sides.
|
||||
fn no_stray_channels(topo: &Topology, listen_id: u32) -> bool {
|
||||
topo.consumer_manager
|
||||
@@ -208,7 +208,7 @@ async fn bindfirst_full_flow_end_to_end() {
|
||||
)
|
||||
.await;
|
||||
|
||||
// The opener opens the listen tunnel (with-reply surface — the
|
||||
// The opener opens the listen tunnel (with-reply surface, the
|
||||
// bind-first reply projection).
|
||||
let (channel_id, reply) = open_listen_with_reply(&topo, &listen_params("bind"))
|
||||
.await
|
||||
@@ -232,7 +232,7 @@ async fn bindfirst_full_flow_end_to_end() {
|
||||
let correlations = install_forwarded_stub(&topo).await;
|
||||
|
||||
// The LOCAL BIND client (the app-server-side session on the
|
||||
// opener): a raw duplex pair — the map's halves are the stub's
|
||||
// opener): a raw duplex pair; the map's halves are the stub's
|
||||
// pump target, the far end is the test's client end. The stub's
|
||||
// `pump_bidi` bridges the forwarded channel ↔ these halves, so
|
||||
// bytes the test writes on the far end traverse the whole path
|
||||
@@ -247,7 +247,7 @@ async fn bindfirst_full_flow_end_to_end() {
|
||||
},
|
||||
);
|
||||
|
||||
// The opener adopts the LISTEN channel (the idle side — its EOF
|
||||
// The opener adopts the LISTEN channel (the idle side; its EOF
|
||||
// is the teardown signal).
|
||||
topo.consumer_manager
|
||||
.adopt_channel(channel_id, alktunnels::TUNNEL_ALPN, None)
|
||||
@@ -259,7 +259,7 @@ async fn bindfirst_full_flow_end_to_end() {
|
||||
|
||||
// The dispatcher opens forwarded; the opener's stub handler
|
||||
// correlates by token and pumps against the local halves. The
|
||||
// forwarded channel ID arrives on the opener's manager — find it
|
||||
// forwarded channel ID arrives on the opener's manager; find it
|
||||
// (the only non-0, non-listen channel on the opener side after
|
||||
// the adopt + forwarded open settle).
|
||||
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
||||
@@ -291,7 +291,7 @@ async fn bindfirst_full_flow_end_to_end() {
|
||||
|
||||
// Teardown follows the accepted side: the far echo closes when the
|
||||
// accepted handle's pump sees the test client's EOF. (No channel
|
||||
// assertions here — the listen channel legitimately stays open.)
|
||||
// assertions here: the listen channel legitimately stays open.)
|
||||
}
|
||||
|
||||
/// The listen-channel EOF teardown: drop the listener session (close
|
||||
@@ -323,7 +323,7 @@ async fn listen_channel_eof_cancels_loop_and_tears_listener_down() {
|
||||
.expect("token");
|
||||
|
||||
// The opener adopts the listen channel and then DROPS its stream
|
||||
// (the listener session is dropped — the zero-byte EOF sentinel).
|
||||
// (the listener session is dropped, hence the zero-byte EOF sentinel).
|
||||
let (send, _recv) = topo
|
||||
.consumer_manager
|
||||
.adopt_channel(channel_id, alktunnels::TUNNEL_ALPN, None)
|
||||
@@ -338,7 +338,7 @@ async fn listen_channel_eof_cancels_loop_and_tears_listener_down() {
|
||||
"the listen channel reaped after the session dropped"
|
||||
);
|
||||
|
||||
// A follow-on accept attempt: the loop is gone — the accept queue
|
||||
// A follow-on accept attempt: the loop is gone, so the accept queue
|
||||
// accepts nothing new (the listener was torn down with the plan).
|
||||
// Observable via the queue: nothing pumps it anymore.
|
||||
let _ = token;
|
||||
@@ -358,7 +358,7 @@ async fn listen_channel_eof_cancels_loop_and_tears_listener_down() {
|
||||
async fn forwarded_open_failure_isolates_the_accept() {
|
||||
init_tracing();
|
||||
// The topology's consumer identity holds `tunnel:open` but NOT
|
||||
// `tunnel:forwarded` — every forwarded open fails FORBIDDEN at the
|
||||
// `tunnel:forwarded`, so every forwarded open fails FORBIDDEN at the
|
||||
// opener's ACL. The loop must keep serving (a second accept
|
||||
// produces a second forwarded-open attempt).
|
||||
let registry = ResourceRegistry::new();
|
||||
@@ -383,7 +383,7 @@ async fn forwarded_open_failure_isolates_the_accept() {
|
||||
.await
|
||||
.expect("listen open");
|
||||
|
||||
// First accept: the forwarded open fails (scope-less opener) —
|
||||
// First accept: the forwarded open fails (scope-less opener),
|
||||
// the accepted connection drops, the loop continues.
|
||||
push_accept(&queue, ¬ify, (198, 55233)).await;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
||||
@@ -392,7 +392,7 @@ async fn forwarded_open_failure_isolates_the_accept() {
|
||||
"a failed forwarded open leaves no channel beyond the listen channel"
|
||||
);
|
||||
|
||||
// Second accept: the loop is still alive (per-accept isolation) —
|
||||
// Second accept: the loop is still alive (per-accept isolation);
|
||||
// this one fails the same way, but the LOOP's health is what the
|
||||
// test pins: the accept was CONSUMED (the queue drained).
|
||||
push_accept(&queue, ¬ify, (198, 55234)).await;
|
||||
@@ -459,7 +459,7 @@ async fn establishment_errors_are_typed_on_the_wire() {
|
||||
|
||||
/// Token discipline: two opens on the same producer mint distinct
|
||||
/// tokens; each dispatcher stamps ITS OWN plan's token (the second
|
||||
/// open's forwarded opens carry the second token — pinned via the
|
||||
/// open's forwarded opens carry the second token, pinned via the
|
||||
/// correlation stub's map: the FIRST token's entry is left
|
||||
/// unconsumed).
|
||||
#[tokio::test]
|
||||
@@ -507,7 +507,7 @@ async fn token_discipline_two_opens_distinct_tokens() {
|
||||
|
||||
// Both dispatchers are live. Correlate BOTH tokens (each opener
|
||||
// session keys its own), but only register the local halves for
|
||||
// t1 — a forwarded open stamped t2 would correlation-miss. Push
|
||||
// t1; a forwarded open stamped t2 would correlation-miss. Push
|
||||
// one accept AFTER open 2: whichever dispatcher consumed it, the
|
||||
// STAMPED token is the plan's own. To pin "each dispatcher stamps
|
||||
// ITS OWN plan's token", correlate both but track which token the
|
||||
@@ -541,7 +541,7 @@ async fn token_discipline_two_opens_distinct_tokens() {
|
||||
|
||||
/// The forwarded-op serving stub's forwarded-substrate log: the
|
||||
/// `substrate` param of every forwarded open (the M-1 remediation
|
||||
/// probe surface — the substrate's SOURCE: the plan, not a re-parse).
|
||||
/// probe surface, the substrate's SOURCE: the plan, not a re-parse).
|
||||
pub type SubstrateLog = Arc<tokio::sync::Mutex<Vec<alktunnels::params::Substrate>>>;
|
||||
|
||||
/// The forwarded-op serving stub variant that records each forwarded
|
||||
@@ -610,16 +610,16 @@ async fn install_substrate_recording_stub(
|
||||
|
||||
/// M-1 remediation pin: the forwarded opens' `substrate` comes from
|
||||
/// the PLAN (stamped at establisher time), not a re-parse of wire
|
||||
/// input — a UDP bind-first listen produces forwarded opens carrying
|
||||
/// input: a UDP bind-first listen produces forwarded opens carrying
|
||||
/// `substrate: udp` even though the re-parse-and-fallback shape would
|
||||
/// have stamped `tcp` if it ever fell back. (The fallback itself is
|
||||
/// REMOVED — this test pins the plan as the source by observing the
|
||||
/// REMOVED, so this test pins the plan as the source by observing the
|
||||
/// forwarded params through a real dispatcher round trip.)
|
||||
#[tokio::test]
|
||||
async fn forwarded_opens_carry_plan_stamped_substrate() {
|
||||
init_tracing();
|
||||
// A UDP-flavored bind-first registry: the open is
|
||||
// `{resource: "bind", substrate: "udp"}` — the plan must stamp
|
||||
// `{resource: "bind", substrate: "udp"}`, so the plan must stamp
|
||||
// Udp (the old fallback shape would have stamped Tcp on any
|
||||
// parse failure).
|
||||
let registry = ResourceRegistry::new();
|
||||
@@ -639,7 +639,7 @@ async fn forwarded_opens_carry_plan_stamped_substrate() {
|
||||
let correlations = install_substrate_recording_stub(&topo, Arc::clone(&substrates)).await;
|
||||
|
||||
// The listen open over the UDP substrate (the harness's
|
||||
// open-call helper hardcodes "tcp" — call with the udp input
|
||||
// open-call helper hardcodes "tcp", so call with the udp input
|
||||
// directly; the schema gate accepts the registered UDP resource).
|
||||
let payload = serde_json::json!({
|
||||
"operationId": "channels/tunnel/sub",
|
||||
@@ -668,7 +668,7 @@ async fn forwarded_opens_carry_plan_stamped_substrate() {
|
||||
.await
|
||||
.expect("adopt listen channel");
|
||||
|
||||
// One accept: the dispatcher opens forwarded — the forwarded
|
||||
// One accept: the dispatcher opens forwarded, and the forwarded
|
||||
// params' `substrate` must be the PLAN's (Udp), not a fallback.
|
||||
push_accept(&queue, ¬ify, (198, 55236)).await;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
||||
@@ -682,7 +682,7 @@ async fn forwarded_opens_carry_plan_stamped_substrate() {
|
||||
}
|
||||
|
||||
/// A plain dial open sees NEITHER reply field (the projection
|
||||
/// property — the reserved-key guard upstream guarantees `channel_id`
|
||||
/// property; the reserved-key guard upstream guarantees `channel_id`
|
||||
/// stays wrapper-owned; a dial establisher contributes nothing).
|
||||
#[tokio::test]
|
||||
async fn dial_open_reply_has_neither_field() {
|
||||
@@ -721,7 +721,7 @@ fn harness_queue_loop(
|
||||
let notify = Arc::clone(¬ify);
|
||||
Box::pin(async move {
|
||||
// The queue-driven accept: check once, wait for a push,
|
||||
// check again (the Notify lost-wakeup pattern — the
|
||||
// check again (the Notify lost-wakeup pattern: the
|
||||
// `Notified` future is created BEFORE the state check on
|
||||
// the waiting path). One accepted pair per closure call
|
||||
// (the dispatcher invokes the closure per accept).
|
||||
@@ -747,7 +747,7 @@ fn harness_queue_loop(
|
||||
})
|
||||
}
|
||||
|
||||
/// A never-accepting loop (the establishment-error probes — the loop
|
||||
/// A never-accepting loop (the establishment-error probes; the loop
|
||||
/// never runs: the open fails at the establisher).
|
||||
fn noop_loop() -> BindFirstAcceptLoop {
|
||||
Arc::new(|_listener| {
|
||||
@@ -756,7 +756,7 @@ fn noop_loop() -> BindFirstAcceptLoop {
|
||||
}
|
||||
|
||||
/// The "local BIND client" pair (the opener-side pump target): a raw
|
||||
/// duplex pair — the map's halves are the stub's pump target, the far
|
||||
/// duplex pair; the map's halves are the stub's pump target, the far
|
||||
/// end is the test's client end (driven directly; the accepted side's
|
||||
/// echo task closes the loop).
|
||||
fn bind_client_pair() -> (
|
||||
|
||||
+12
-12
@@ -2,12 +2,12 @@
|
||||
//! ADR-007) end to end over the duplex harness: the params → spec →
|
||||
//! establisher → registration → `TunnelSession::open_direct` chain.
|
||||
//!
|
||||
//! Covered: e2e round-trips through `pump_bidi` (tcp + udp — the
|
||||
//! Covered: e2e round-trips through `pump_bidi` (tcp + udp, with the
|
||||
//! framed adapter wraps at the dial closure, datagram boundary
|
||||
//! preserved), the schema-class failure for malformed targets (no
|
||||
//! phantom session), the `NOT_FOUND` posture on a producer without
|
||||
//! the direct op registered, the structural scope separation
|
||||
//! (`tunnel:open` does NOT imply `tunnel:direct` — FORBIDDEN, and
|
||||
//! (`tunnel:open` does NOT imply `tunnel:direct`: FORBIDDEN, and
|
||||
//! vice versa), and the CF-006 per-call opener identity on the
|
||||
//! direct establisher (witness).
|
||||
|
||||
@@ -68,7 +68,7 @@ async fn adopt_and_pump(
|
||||
(l_read, l_write, pump)
|
||||
}
|
||||
|
||||
/// Only channel 0 may remain — a failed open leaves no data channel
|
||||
/// Only channel 0 may remain: a failed open leaves no data channel
|
||||
/// on either side (the phantom-channel property).
|
||||
fn no_data_channels(topo: &Topology) -> bool {
|
||||
let consumer = topo.consumer_manager.channel_ids();
|
||||
@@ -128,7 +128,7 @@ async fn direct_tcp_round_trips_through_pump_bidi() {
|
||||
#[tokio::test]
|
||||
async fn direct_udp_round_trips_framed() {
|
||||
// The UDP dial closure wraps the framed adapter (ADR-003) at the
|
||||
// boundary — the establisher stays substrate-agnostic; the
|
||||
// boundary, and the establisher stays substrate-agnostic; the
|
||||
// session's datagram plane sees boundary-preserved frames.
|
||||
let topo = wire_direct(all_substrate_dial()).await;
|
||||
|
||||
@@ -168,7 +168,7 @@ async fn direct_udp_round_trips_framed() {
|
||||
async fn malformed_target_is_schema_class_and_leaves_no_channel() {
|
||||
// The registry's input-schema gate runs before the establisher: a
|
||||
// unix target without `path` (and a tcp target without `port`)
|
||||
// fails as `INVALID_INPUT` — never a phantom session, never a
|
||||
// fails as `INVALID_INPUT`; never a phantom session, never a
|
||||
// establisher dial.
|
||||
let topo = wire_direct(echo_dial()).await;
|
||||
|
||||
@@ -228,7 +228,7 @@ async fn dial_failure_passes_through_typed() {
|
||||
#[tokio::test]
|
||||
async fn producer_without_direct_op_rejects_loudly() {
|
||||
// An old producer (only the base op registered) rejects the
|
||||
// unknown op loudly — NOT_FOUND, the SSH "unknown channel type"
|
||||
// unknown op loudly (NOT_FOUND, the SSH "unknown channel type"
|
||||
// posture.
|
||||
let registry = alktunnels::producer::ResourceRegistry::new();
|
||||
registry
|
||||
@@ -256,7 +256,7 @@ async fn producer_without_direct_op_rejects_loudly() {
|
||||
#[tokio::test]
|
||||
async fn tunnel_open_scope_does_not_imply_tunnel_direct() {
|
||||
// ADR-006 Amendment 1: the scopes are structurally separate
|
||||
// grants — an identity holding `tunnel:open` (but not
|
||||
// grants (an identity holding `tunnel:open` but not
|
||||
// `tunnel:direct`) is FORBIDDEN on the direct op.
|
||||
let topo = wire_direct_with_identity(consumer_identity()).await;
|
||||
|
||||
@@ -308,7 +308,7 @@ async fn direct_scope_does_not_imply_tunnel_open() {
|
||||
async fn direct_establisher_witnesses_per_call_identity() {
|
||||
// CF-006: the direct establisher's per-call auth carried the END
|
||||
// CALLER's identity (the direct op is the arbitrary-egress
|
||||
// capability — identity visibility matters more, not less).
|
||||
// capability, so identity visibility matters more, not less).
|
||||
let topo = wire_direct(echo_dial()).await;
|
||||
|
||||
let out = call_direct(
|
||||
@@ -334,7 +334,7 @@ async fn direct_establisher_witnesses_per_call_identity() {
|
||||
async fn establisher_parse_backstop_maps_handler_error() {
|
||||
// The establisher's serde parse is the backstop behind the schema
|
||||
// gate: feed an input the schema would have rejected but a direct
|
||||
// establisher call never schema-checks (the raw-call shape —
|
||||
// establisher call never schema-checks (the raw-call shape:
|
||||
// assembly variants, or a registry whose schema gate is looser)
|
||||
// and observe the typed `handler_error` mapping (ADR-007 §4).
|
||||
// An unknown substrate value fails the enum parse.
|
||||
@@ -360,7 +360,7 @@ async fn establisher_parse_backstop_maps_handler_error() {
|
||||
async fn direct_establisher_renders_target_to_backing_string() {
|
||||
// The design pin: the establisher renders the target to the SAME
|
||||
// backing-string form the registry backings use and calls the
|
||||
// injected DialFn with it — the dial closure observes the exact
|
||||
// injected DialFn with it, and the dial closure observes the exact
|
||||
// rendered form ("host:port" / path), per ADR-007 §4.
|
||||
let observed: Arc<tokio::sync::Mutex<Vec<(Substrate, String)>>> =
|
||||
Arc::new(tokio::sync::Mutex::new(Vec::new()));
|
||||
@@ -508,8 +508,8 @@ async fn open_direct_malformed_target_no_phantom_session() {
|
||||
#[tokio::test]
|
||||
async fn open_direct_scope_separated_session_level() {
|
||||
// Session-level scope separation (ADR-006 Amendment 1): a
|
||||
// tunnel:open-only consumer client is FORBIDDEN on open_direct —
|
||||
// the scopes are separate grants, and the session constructor
|
||||
// tunnel:open-only consumer client is FORBIDDEN on open_direct
|
||||
// (the scopes are separate grants, and the session constructor
|
||||
// states which capability it invokes.
|
||||
let topo = harness::wire_direct_forward_with_identity(echo_dial(), consumer_identity()).await;
|
||||
|
||||
|
||||
+28
-28
@@ -1,6 +1,6 @@
|
||||
//! Integration tests for the forwarded op's SERVING side (the
|
||||
//! listen-opener half — ADR-008 §3, the `tunnels/forwarded-receipt`
|
||||
//! shape): `register_tunnel_forwarded_openable` — the
|
||||
//! listen-opener half: ADR-008 §3, the `tunnels/forwarded-receipt`
|
||||
//! shape): `register_tunnel_forwarded_openable`, the
|
||||
//! `register_openable` shape (no establisher; the data plane starts
|
||||
//! immediately), scope-gated `tunnel:forwarded`, whose handler parses
|
||||
//! `TunnelForwardedParams`, hands the correlation material (parsed
|
||||
@@ -14,8 +14,8 @@
|
||||
//! `tunnel:forwarded` → FORBIDDEN, never implied by
|
||||
//! `tunnel:open`/`tunnel:direct`), malformed params (unknown field,
|
||||
//! `listen: 0`, missing `peer` → loud rejection; the serde-gated shape
|
||||
//! tears the channel down at birth — no phantom), and the
|
||||
//! correlation-miss teardown (the assembly's rejection — drop the
|
||||
//! tears the channel down at birth (no phantom), and the
|
||||
//! correlation-miss teardown (the assembly's rejection: drop the
|
||||
//! stream, the channel reaps).
|
||||
//!
|
||||
//! The reply reader's unit tests land in `src/params.rs` (this task's
|
||||
@@ -24,7 +24,7 @@
|
||||
//!
|
||||
//! Topology (the harness's producer-leg stub, mirrored): the producer
|
||||
//! leg is the connect side (it holds the `ChannelClient` whose
|
||||
//! `call_open_op` is the per-accept call plane — the reverse POC's
|
||||
//! `call_open_op` is the per-accept call plane (the reverse POC's
|
||||
//! `open_reverse_channel` shape, reduced); the opener leg is the
|
||||
//! accept side whose install-hook serving registry carries the
|
||||
//! forwarded op under test. The test plays the producer: per probe it
|
||||
@@ -33,7 +33,7 @@
|
||||
//! producer-side manager, and pumps the adopted channel against a
|
||||
//! driving duplex (the dispatcher-pump dance, test-driven). The
|
||||
//! opener-side assembly correlates by the `listen` token and pumps the
|
||||
//! forwarded channel against the local "BIND client" halves — bytes
|
||||
//! forwarded channel against the local "BIND client" halves; bytes
|
||||
//! the test writes on the client end traverse the whole path and echo
|
||||
//! back.
|
||||
|
||||
@@ -50,7 +50,7 @@ use alktunnels::producer::{register_tunnel_forwarded_openable, TargetHandle};
|
||||
use harness::{wire_with, RegistrationMode};
|
||||
|
||||
/// The opener-side identity holding ONLY `tunnel:forwarded` (the
|
||||
/// ingress grant — pinned separate from `tunnel:open`/`tunnel:direct`,
|
||||
/// ingress grant, pinned separate from `tunnel:open`/`tunnel:direct`,
|
||||
/// never implied by either; this identity holds neither).
|
||||
fn forwarded_only_identity() -> Identity {
|
||||
Identity {
|
||||
@@ -60,7 +60,7 @@ fn forwarded_only_identity() -> Identity {
|
||||
}
|
||||
}
|
||||
|
||||
/// A plain dial-egress identity (`tunnel:open` only — the
|
||||
/// A plain dial-egress identity (`tunnel:open` only, the
|
||||
/// never-implied probe's identity).
|
||||
fn open_only_identity() -> Identity {
|
||||
Identity {
|
||||
@@ -83,10 +83,10 @@ type ReceivedWitness = Arc<tokio::sync::Mutex<Received>>;
|
||||
/// Serve the forwarded op on the opener side with the injected
|
||||
/// assembly handler: record the parsed params, pop the correlated
|
||||
/// halves for the token, and `pump_bidi` the channel `BiStream`
|
||||
/// against them (convention 8 — the assembly owns the pump; the
|
||||
/// against them (convention 8: the assembly owns the pump; the
|
||||
/// protocol-side built-in handler only bridges wrapper→assembly). A
|
||||
/// correlation miss (no halves for the token) is the assembly's
|
||||
/// rejection: drop the stream, return — teardown-at-birth reaps the
|
||||
/// rejection: drop the stream, return; teardown-at-birth reaps the
|
||||
/// forwarded channel.
|
||||
fn serve_forwarded_on(
|
||||
consumer_registry: &Arc<alkcall::registry::registration::OperationRegistry>,
|
||||
@@ -121,7 +121,7 @@ fn serve_forwarded_on(
|
||||
}
|
||||
|
||||
/// A topology for the forwarded-op serving probes: the producer leg
|
||||
/// (connect side) serves only the unused base op (failing dial — the
|
||||
/// (connect side) serves only the unused base op (a failing dial, the
|
||||
/// base op is not what's under test); the opener leg is the accept
|
||||
/// side whose serving registry carries the forwarded op. The test
|
||||
/// plays the producer: `topo.producer.call_open_op(OP_TUNNEL_FORWARDED,
|
||||
@@ -147,7 +147,7 @@ fn forwarded_input(listen: u32, peer: (u32, u16)) -> serde_json::Value {
|
||||
}
|
||||
|
||||
/// Call the forwarded op on the producer's per-accept call plane
|
||||
/// (`call_open_op` — the producer-side initiator's surface; the
|
||||
/// (`call_open_op`, the producer-side initiator's surface; the
|
||||
/// opener's wrapper allocates the channel ID, the producer adopts).
|
||||
async fn call_forwarded(
|
||||
producer: &alkcall::channels::client::ChannelClient,
|
||||
@@ -160,7 +160,7 @@ async fn call_forwarded(
|
||||
}
|
||||
|
||||
/// The accepted-side echo pair (the producer-side pump target): a
|
||||
/// duplex pair — the driving end an echo task (the app server's
|
||||
/// duplex pair; the driving end an echo task (the app server's
|
||||
/// socket stand-in), the far end the pump's halves.
|
||||
fn driving_halves() -> TargetHandle {
|
||||
let (drive_end, target_end) = tokio::io::duplex(64 * 1024);
|
||||
@@ -187,7 +187,7 @@ fn driving_halves() -> TargetHandle {
|
||||
}
|
||||
|
||||
/// The "local BIND client" pair (the opener-side pump target): a raw
|
||||
/// duplex pair — the map's halves are the assembly pump's target, the
|
||||
/// duplex pair; the map's halves are the assembly pump's target, the
|
||||
/// far end is the test's client end (driven directly).
|
||||
fn bind_client_pair() -> (
|
||||
tokio::io::ReadHalf<tokio::io::DuplexStream>,
|
||||
@@ -226,7 +226,7 @@ async fn forwarded_serving_round_trip_end_to_end() {
|
||||
Arc::clone(&local_halves),
|
||||
);
|
||||
|
||||
// Correlate FIRST (the waiting listener session's bookkeeping —
|
||||
// Correlate FIRST (the waiting listener session's bookkeeping:
|
||||
// the assembly pre-registered the local halves for the token
|
||||
// before the forwarded open arrives; a late push is the
|
||||
// correlation-miss shape, probed separately below).
|
||||
@@ -266,7 +266,7 @@ async fn forwarded_serving_round_trip_end_to_end() {
|
||||
// The producer-side adopted pump (the test drives the accepted
|
||||
// side's echo): adopt the opener-allocated ID on the
|
||||
// producer-side manager and pump the adopted channel against a
|
||||
// driving duplex — the dispatcher-pump dance, test-driven.
|
||||
// driving duplex (the dispatcher-pump dance, test-driven).
|
||||
let (send, recv) = topo
|
||||
.producer
|
||||
.manager()
|
||||
@@ -298,8 +298,8 @@ async fn forwarded_serving_round_trip_end_to_end() {
|
||||
assert_eq!(&buf, b"forwarded-ping");
|
||||
}
|
||||
|
||||
/// The scope gate: an identity WITHOUT `tunnel:forwarded` is FORBIDDEN
|
||||
/// — never implied by `tunnel:open` (this identity holds exactly that
|
||||
/// The scope gate: an identity WITHOUT `tunnel:forwarded` is FORBIDDEN,
|
||||
/// never implied by `tunnel:open` (this identity holds exactly that
|
||||
/// grant; ADR-006 Amendment 1's separation).
|
||||
#[tokio::test]
|
||||
async fn forwarded_scope_gate_is_independent_grant() {
|
||||
@@ -342,7 +342,7 @@ async fn forwarded_scope_gate_is_independent_grant() {
|
||||
}
|
||||
|
||||
/// Malformed params: `listen: 0` and a missing `peer` fail the
|
||||
/// registry's schema gate LOUD (`INVALID_INPUT`, before the wrapper —
|
||||
/// registry's schema gate LOUD (`INVALID_INPUT`; before the wrapper,
|
||||
/// no channel ever); an unknown top-level field passes the schema (the
|
||||
/// additive posture) but fails the serde parse (the loud backstop) →
|
||||
/// the wrapper logs + returns early → the channel tears down at birth
|
||||
@@ -360,7 +360,7 @@ async fn forwarded_malformed_params_tear_down_loud() {
|
||||
local_halves,
|
||||
);
|
||||
|
||||
// `listen: 0` — the schema gate (minimum: 1) fires first.
|
||||
// `listen: 0`: the schema gate (minimum: 1) fires first.
|
||||
match call_forwarded(
|
||||
&topo.producer,
|
||||
serde_json::json!({
|
||||
@@ -377,7 +377,7 @@ async fn forwarded_malformed_params_tear_down_loud() {
|
||||
}
|
||||
}
|
||||
|
||||
// Missing `peer` — the schema gate again (all three required).
|
||||
// Missing `peer`, the schema gate again (all three required).
|
||||
match call_forwarded(
|
||||
&topo.producer,
|
||||
serde_json::json!({"listen": 7, "substrate": "tcp"}),
|
||||
@@ -390,10 +390,10 @@ async fn forwarded_malformed_params_tear_down_loud() {
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown top-level field — passes the schema (additive posture),
|
||||
// Unknown top-level field: passes the schema (additive posture),
|
||||
// fails the serde parse (deny-unknown backstop) → the wrapper
|
||||
// returns early → teardown-at-birth (the channel was allocated —
|
||||
// the reply IS the channel_id — but reaps promptly).
|
||||
// returns early → teardown-at-birth (the channel was allocated;
|
||||
// the reply IS the channel_id, but reaps promptly).
|
||||
let out = call_forwarded(
|
||||
&topo.producer,
|
||||
serde_json::json!({
|
||||
@@ -415,18 +415,18 @@ async fn forwarded_malformed_params_tear_down_loud() {
|
||||
assert!(received.lock().await.params.is_none());
|
||||
}
|
||||
|
||||
/// The correlation-miss teardown (the assembly's rejection — it holds
|
||||
/// The correlation-miss teardown (the assembly's rejection; it holds
|
||||
/// the map): a valid-shaped open whose `listen` token matches no
|
||||
/// waiting session → the assembly's error path drops the stream and
|
||||
/// returns → teardown-at-birth reaps the forwarded channel. The
|
||||
/// producer's accept loop drops the accepted connection on the open's
|
||||
/// failure (the shapes mirror — pinned producer-side in
|
||||
/// failure (the shapes mirror; pinned producer-side in
|
||||
/// `tunnels/bindfirst-producer`).
|
||||
#[tokio::test]
|
||||
async fn forwarded_correlation_miss_tears_channel_down() {
|
||||
let topo = wire_forwarded_serving(forwarded_only_identity()).await;
|
||||
let received: ReceivedWitness = Arc::new(tokio::sync::Mutex::new(Received::default()));
|
||||
// NO correlated halves for the token — the map is empty.
|
||||
// NO correlated halves for the token (the map is empty).
|
||||
let local_halves: Arc<tokio::sync::Mutex<Vec<TargetHandle>>> =
|
||||
Arc::new(tokio::sync::Mutex::new(Vec::new()));
|
||||
serve_forwarded_on(
|
||||
@@ -445,7 +445,7 @@ async fn forwarded_correlation_miss_tears_channel_down() {
|
||||
!topo.consumer_manager.has_channel(channel_id),
|
||||
"a correlation miss tears the forwarded channel down (teardown-at-birth)"
|
||||
);
|
||||
// The assembly handler RAN (the invocation was witnessed — the
|
||||
// The assembly handler RAN (the invocation was witnessed; the
|
||||
// rejection is the assembly's) and its map missed.
|
||||
let witnessed = received.lock().await.params.clone();
|
||||
assert_eq!(witnessed.expect("invoked").listen, 42113);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Integration tests for the forwarded op's establisher variant
|
||||
//! (ADR-008 Amendment 2 —
|
||||
//! (ADR-008 Amendment 2:
|
||||
//! `register_tunnel_forwarded_openable_with_establisher` + the
|
||||
//! injected [`ForwardedGate`]): the four pins plan 003 §W1's
|
||||
//! "Tests:" bullet asks for, mirroring `tests/producer_forwarded.rs`
|
||||
@@ -10,10 +10,10 @@
|
||||
//! - the gate MISS: a `listen` token with no waiting session → the
|
||||
//! open fails as a typed `channel:open_failed` with
|
||||
//! `details.reason == "unknown_resource"`; the transiently
|
||||
//! allocated channel is torn down by the wrapper (ADR-049 §5 — the
|
||||
//! allocated channel is torn down by the wrapper (ADR-049 §5; the
|
||||
//! reply is the typed error, not a phantom channel); the pump
|
||||
//! handler NEVER runs.
|
||||
//! - the gate PASS: the existing serving flow is unchanged — the
|
||||
//! - the gate PASS: the existing serving flow is unchanged; the
|
||||
//! injected handler receives the parsed params + the `BiStream`,
|
||||
//! the byte round-trip works end to end.
|
||||
//! - the residual race: gate passes, route gone at handler time →
|
||||
@@ -23,7 +23,7 @@
|
||||
//! `correlation-miss` class).
|
||||
//! - the ordering: an identity WITHOUT `tunnel:forwarded` → FORBIDDEN
|
||||
//! and the gate closure is never invoked (the registry runs the ACL
|
||||
//! before the open op's establishment phase — schema → ACL →
|
||||
//! before the open op's establishment phase (schema → ACL →
|
||||
//! allocate → establisher → handler).
|
||||
//! - the establisher-variant malformed-params delta: an unknown
|
||||
//! top-level field (passes the schema's additive posture, fails the
|
||||
@@ -59,7 +59,7 @@ fn forwarded_only_identity() -> Identity {
|
||||
}
|
||||
}
|
||||
|
||||
/// A plain dial-egress identity (`tunnel:open` only — no
|
||||
/// A plain dial-egress identity (`tunnel:open` only, no
|
||||
/// `tunnel:forwarded`; the ordering probe's identity).
|
||||
fn open_only_identity() -> Identity {
|
||||
Identity {
|
||||
@@ -70,7 +70,7 @@ fn open_only_identity() -> Identity {
|
||||
}
|
||||
|
||||
/// What the assembly's test handler received per forwarded open (the
|
||||
/// invocation probe — unmoved = the handler never ran).
|
||||
/// invocation probe; unmoved = the handler never ran).
|
||||
#[derive(Default)]
|
||||
struct Received {
|
||||
params: Option<alktunnels::params::TunnelForwardedParams>,
|
||||
@@ -86,12 +86,12 @@ type GateWitness = Arc<AtomicBool>;
|
||||
/// The waiting-session map: `listen` token → present. The gate's
|
||||
/// sync in-memory lookup surface (convention 11's "the gate is an
|
||||
/// in-memory map lookup"): a token present at gate time = a waiting
|
||||
/// session; absent = the miss (`TunnelEstablishError::UnknownResource`
|
||||
/// — the canonical stale-token reason ADR-008 Amendment 2 pins).
|
||||
/// session; absent = the miss (`TunnelEstablishError::UnknownResource`,
|
||||
/// the canonical stale-token reason ADR-008 Amendment 2 pins).
|
||||
type WaitingTokens = Arc<Mutex<HashSet<u32>>>;
|
||||
|
||||
/// The gate closure under test: set the witness on entry (even for
|
||||
/// opens it will reject — the ordering probe needs the untouched
|
||||
/// opens it will reject, because the ordering probe needs the untouched
|
||||
/// signal), then the token lookup.
|
||||
fn gate_with_tokens(waiting: WaitingTokens, invoked: GateWitness) -> ForwardedGate {
|
||||
Arc::new(move |params: &alktunnels::params::TunnelForwardedParams| {
|
||||
@@ -115,7 +115,7 @@ fn gate_with_tokens(waiting: WaitingTokens, invoked: GateWitness) -> ForwardedGa
|
||||
/// variant: the same injected handler shape as
|
||||
/// `tests/producer_forwarded.rs`'s `serve_forwarded_on` (record the
|
||||
/// parsed params, pop the correlated halves for the token, `pump_bidi`
|
||||
/// against them; a handler-time miss drops the stream and returns —
|
||||
/// against them; a handler-time miss drops the stream and returns,
|
||||
/// teardown-at-birth) with the gate establisher wrapped around it.
|
||||
fn serve_forwarded_establisher_on(
|
||||
consumer_registry: &Arc<alkcall::registry::registration::OperationRegistry>,
|
||||
@@ -153,7 +153,7 @@ fn serve_forwarded_establisher_on(
|
||||
|
||||
/// A topology for the establisher-variant probes (the
|
||||
/// `wire_forwarded_serving` shape): the producer leg is the connect
|
||||
/// side (the base op fails-dial — not what's under test); the opener
|
||||
/// side (the base op fails-dial; not what's under test); the opener
|
||||
/// leg is the accept side whose serving registry carries the
|
||||
/// establisher-registered forwarded op. The test plays the producer,
|
||||
/// calling the forwarded op on the producer's call plane; the
|
||||
@@ -238,9 +238,9 @@ fn bind_client_pair() -> (
|
||||
/// The gate MISS: a `listen` token with no waiting session → the open
|
||||
/// fails as a typed `channel:open_failed` with
|
||||
/// `details.reason == "unknown_resource"`; the transiently allocated
|
||||
/// channel is torn down by the wrapper (no phantom channel — ADR-049
|
||||
/// channel is torn down by the wrapper (no phantom channel, ADR-049
|
||||
/// §5); the pump handler NEVER runs. The gate DID run (its witness is
|
||||
/// set — the miss is AT the gate, not before it).
|
||||
/// set; the miss is AT the gate, not before it).
|
||||
#[tokio::test]
|
||||
async fn gate_miss_fails_typed_no_phantom_no_handler() {
|
||||
let topo = wire_forwarded_establisher(forwarded_only_identity()).await;
|
||||
@@ -302,7 +302,7 @@ async fn gate_miss_fails_typed_no_phantom_no_handler() {
|
||||
}
|
||||
|
||||
/// The gate PASS: the registration serves exactly like the base shape
|
||||
/// (`tests/producer_forwarded.rs`'s e2e) — the injected handler
|
||||
/// (`tests/producer_forwarded.rs`'s e2e): the injected handler
|
||||
/// receives the parsed params + the `BiStream`, and the byte
|
||||
/// round-trip works end to end through the establisher-registered
|
||||
/// variant.
|
||||
@@ -393,7 +393,7 @@ async fn gate_pass_serves_the_unchanged_serving_flow() {
|
||||
/// halves) → the injected handler's error path drops the stream and
|
||||
/// returns → teardown-at-birth reaps the forwarded channel. The
|
||||
/// establisher-pass→handler-run window is the residual
|
||||
/// `correlation-miss` class — the handler-side fallback is what reaps
|
||||
/// `correlation-miss` class; the handler-side fallback is what reaps
|
||||
/// it, under both shapes.
|
||||
#[tokio::test]
|
||||
async fn residual_race_still_reaps_cleanly() {
|
||||
@@ -411,7 +411,7 @@ async fn residual_race_still_reaps_cleanly() {
|
||||
);
|
||||
|
||||
// The gate passes (the waiting token is present at establisher
|
||||
// time), but nothing was correlated for the handler — the race.
|
||||
// time), but nothing was correlated for the handler: the race.
|
||||
const TOKEN: u32 = 42114;
|
||||
waiting
|
||||
.lock()
|
||||
@@ -419,15 +419,15 @@ async fn residual_race_still_reaps_cleanly() {
|
||||
.insert(TOKEN);
|
||||
|
||||
// The open itself STILL RESOLVES (the gate passed; the call-plane
|
||||
// success is what makes this the residual — post-open — race).
|
||||
// success is what makes this a residual, post-open race).
|
||||
let out = call_forwarded(&topo.producer, forwarded_input(TOKEN, (198, 55232)))
|
||||
.await
|
||||
.expect("the gate-pass open");
|
||||
let channel_id = out["channel_id"].as_u64().expect("channel_id") as u32;
|
||||
|
||||
// The handler ran (the invocation was witnessed — the rejection is
|
||||
// the handler's, the stream dropped) and its miss reaps the
|
||||
// channel (teardown-at-birth).
|
||||
// The handler ran (the invocation was witnessed; the rejection is
|
||||
// the handler's and the stream was dropped), and its miss reaps
|
||||
// the channel (teardown-at-birth).
|
||||
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
||||
let witnessed = received.lock().await.params.clone();
|
||||
assert_eq!(witnessed.expect("invoked").listen, TOKEN);
|
||||
@@ -439,7 +439,7 @@ async fn residual_race_still_reaps_cleanly() {
|
||||
|
||||
/// The ordering: an identity WITHOUT `tunnel:forwarded` → FORBIDDEN
|
||||
/// and the gate closure is NEVER invoked (a witness the gate sets on
|
||||
/// entry stays unmoved) — the registry runs the ACL before the open
|
||||
/// entry stays unmoved): the registry runs the ACL before the open
|
||||
/// op's establishment phase, so the gate never sees an unauthorized
|
||||
/// open.
|
||||
#[tokio::test]
|
||||
@@ -460,7 +460,7 @@ async fn scope_gate_precedes_the_establisher() {
|
||||
);
|
||||
|
||||
// The scope-less open is FORBIDDEN (even though the token WOULD
|
||||
// have passed if the gate had run first — the tie-break is the
|
||||
// have passed if the gate had run first; the tie-break is the
|
||||
// order, not the gate's answer).
|
||||
match call_forwarded(&topo.producer, forwarded_input(77, (10, 1))).await {
|
||||
Ok(_) => panic!("a scope-less opener must not reach forwarded establishes"),
|
||||
@@ -481,7 +481,7 @@ async fn scope_gate_precedes_the_establisher() {
|
||||
// channel allocation, no handler execution.
|
||||
assert!(
|
||||
!gate_invoked.load(Ordering::SeqCst),
|
||||
"the ACL precedes the establisher — the gate never saw the open"
|
||||
"the ACL precedes the establisher; the gate never saw the open"
|
||||
);
|
||||
assert!(
|
||||
topo.consumer_manager
|
||||
@@ -497,8 +497,8 @@ async fn scope_gate_precedes_the_establisher() {
|
||||
/// `forwarded_malformed_params_tear_down_loud` covers the base
|
||||
/// posture; this pins the delta): the schema gates (`listen: 0`,
|
||||
/// missing `peer`) fire first exactly as before (`INVALID_INPUT`),
|
||||
/// but an unknown top-level field — passes the schema (additive
|
||||
/// posture), fails the establisher's serde parse — becomes a TYPED
|
||||
/// but an unknown top-level field (passes the schema's additive
|
||||
/// posture, fails the establisher's serde parse) becomes a TYPED
|
||||
/// `channel:open_failed` with `details.reason == "handler_error"`.
|
||||
/// The parse precedes the gate (the gate's witness is never set), the
|
||||
/// wrapper tears the transient allocation down, and the pump handler
|
||||
@@ -522,7 +522,7 @@ async fn malformed_params_fail_typed_at_the_establisher() {
|
||||
);
|
||||
|
||||
// The schema gates are unchanged by the establisher (they precede
|
||||
// it — the wrapper order's schema step).
|
||||
// it, the wrapper order's schema step).
|
||||
match call_forwarded(
|
||||
&topo.producer,
|
||||
serde_json::json!({
|
||||
@@ -548,7 +548,7 @@ async fn malformed_params_fail_typed_at_the_establisher() {
|
||||
|
||||
// THE DELTA: an unknown top-level field fails the establisher's
|
||||
// serde parse → a typed open failure (vs the base shape's
|
||||
// success-with-channel_id + birth-teardown — the typed posture is
|
||||
// success-with-channel_id + birth-teardown; the typed posture is
|
||||
// the improvement ADR-008 Amendment 2 pins).
|
||||
let err = call_forwarded(
|
||||
&topo.producer,
|
||||
@@ -573,7 +573,7 @@ async fn malformed_params_fail_typed_at_the_establisher() {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
||||
assert!(
|
||||
!gate_invoked.load(Ordering::SeqCst),
|
||||
"the establisher parses BEFORE the gate — the gate never ran"
|
||||
"the establisher parses BEFORE the gate; the gate never ran"
|
||||
);
|
||||
assert!(
|
||||
topo.consumer_manager
|
||||
|
||||
+14
-14
@@ -1,9 +1,9 @@
|
||||
//! Integration tests for the listen establisher (shape 2 — producer.md
|
||||
//! Integration tests for the listen establisher (shape 2, producer.md
|
||||
//! §The Establisher): a producer whose resource is a LISTENER. The
|
||||
//! assembly layer owns the listener + its accept loop and feeds an
|
||||
//! [`AcceptQueue`]; the establisher pops one accepted handle during
|
||||
//! the open op's establishment phase. Same open op, same params, same
|
||||
//! typed errors — the pump handler is unchanged.
|
||||
//! typed errors; the pump handler is unchanged.
|
||||
//!
|
||||
//! Covered: the listen flow end-to-end (in-process listener queue fed
|
||||
//! by a test accept loop; consumer opens toward it; the accepted
|
||||
@@ -127,7 +127,7 @@ async fn listen_flow_round_trips() {
|
||||
#[tokio::test]
|
||||
async fn pop_during_establishment_is_always_before_take() {
|
||||
// The always-before-take guarantee: two accepts pushed BEFORE an
|
||||
// open are popped in FIFO order across two opens — each open's
|
||||
// open are popped in FIFO order across two opens; each open's
|
||||
// establisher sees the handle accepted before it (the plan flow:
|
||||
// every open's result flows to ITS pump).
|
||||
let queue = AcceptQueue::new();
|
||||
@@ -179,10 +179,10 @@ async fn pop_during_establishment_is_always_before_take() {
|
||||
async fn empty_queue_is_resource_shortage() {
|
||||
// producer.md's error mapping: an empty queue during establishment
|
||||
// → `resource_shortage`. The QUEUE's pop waits (a late accept is
|
||||
// legitimate — see accept_wait_resolves_when_push_arrives_late);
|
||||
// legitimate; see accept_wait_resolves_when_push_arrives_late);
|
||||
// the assembly layer decides its own posture. An assembly that
|
||||
// knows nothing is coming (a drained listener, a bounded accept
|
||||
// budget) closes its queue or maps empty-pop to the typed error —
|
||||
// budget) closes its queue or maps empty-pop to the typed error;
|
||||
// here, the closure maps it directly (fail-fast posture).
|
||||
let queue = AcceptQueue::new();
|
||||
queue.close().await;
|
||||
@@ -194,7 +194,7 @@ async fn empty_queue_is_resource_shortage() {
|
||||
let queue = queue.clone();
|
||||
Box::pin(async move {
|
||||
// Bounded wait (the assembly's own deadline, not the
|
||||
// wrapper's): a closed queue is an empty listen backlog —
|
||||
// wrapper's): a closed queue is an empty listen backlog,
|
||||
// resource_shortage per producer.md's table.
|
||||
match tokio::time::timeout(std::time::Duration::from_millis(50), queue.pop()).await {
|
||||
Ok(Some(handle)) => Ok(handle),
|
||||
@@ -288,7 +288,7 @@ async fn accept_wait_resolves_when_push_arrives_late() {
|
||||
// Pop-during-establishment ordering under a LIVE accept loop: the
|
||||
// establisher's pop waits; the accept loop pushes after a tick;
|
||||
// the open resolves once the push lands (the wrapper's bounded
|
||||
// wait — ADR-049 §2's deadline applies above us).
|
||||
// wait; ADR-049 §2's deadline applies above us).
|
||||
let queue = AcceptQueue::new();
|
||||
let accept_loop = tokio::spawn({
|
||||
let queue = queue.clone();
|
||||
@@ -323,7 +323,7 @@ async fn accept_wait_resolves_when_push_arrives_late() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn push_after_close_returns_the_handle_and_pop_resolves_none() {
|
||||
// The queue contract: a push after close is `Err(handle)` — the
|
||||
// The queue contract: a push after close is `Err(handle)`, the
|
||||
// accept loop's signal to stop feeding (the accepted connection is
|
||||
// returned untouched; nothing is queued); a closed-and-empty pop
|
||||
// resolves `None` immediately.
|
||||
@@ -340,7 +340,7 @@ async fn push_after_close_returns_the_handle_and_pop_resolves_none() {
|
||||
.push(handle)
|
||||
.await
|
||||
.expect_err("push after close must be Err");
|
||||
// The handle comes back (the caller drops it — the accepted
|
||||
// The handle comes back (the caller drops it, so the accepted
|
||||
// connection closes; nothing leaks into the queue).
|
||||
drop(dropped);
|
||||
drop(target_side);
|
||||
@@ -356,7 +356,7 @@ async fn push_after_close_returns_the_handle_and_pop_resolves_none() {
|
||||
async fn accept_loop_stops_feeding_after_close_and_maps_the_drop() {
|
||||
// The accept loop's queue-closed mapping (the `local` feature's
|
||||
// shape): a `push` after close errors the loop with the handler
|
||||
// error — the assembly layer aborts the loop task on teardown;
|
||||
// error; the assembly layer aborts the loop task on teardown;
|
||||
// here the loop itself exits on the mapped error. The unaccepted
|
||||
// far end is dropped (the loop accepted it; nothing pumps it once
|
||||
// the queue is closed).
|
||||
@@ -384,9 +384,9 @@ async fn accept_loop_stops_feeding_after_close_and_maps_the_drop() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The unix accept loop's queue-closed mapping (G-4 — the unix parity
|
||||
/// The unix accept loop's queue-closed mapping (G-4, the unix parity
|
||||
/// twin of the TCP test above): the same loop shape over
|
||||
/// `UnixListenerHalves` — a `push` after close errors the loop with
|
||||
/// `UnixListenerHalves`: a `push` after close errors the loop with
|
||||
/// the handler error, the loop exits on the mapped error, and the
|
||||
/// unaccepted far end is dropped.
|
||||
#[tokio::test]
|
||||
@@ -429,7 +429,7 @@ async fn unix_accept_loop_stops_feeding_after_close_and_maps_the_drop() {
|
||||
/// runs the pop's check and the future's first poll back to back),
|
||||
/// hence the multi-thread flavor. The fix creates the `Notified`
|
||||
/// future BEFORE the state check on every iteration (tokio's
|
||||
/// documented notify_waiters pattern — the counter comparison at
|
||||
/// documented notify_waiters pattern (the counter comparison at
|
||||
/// first poll captures a pre-registration broadcast); the naive
|
||||
/// create-after-check shape loses it. Structural: no choreography can
|
||||
/// force the window deterministically, so this test pins the pattern
|
||||
@@ -444,7 +444,7 @@ async fn pop_wakeup_survives_push_landing_in_the_registration_window() {
|
||||
let popper = tokio::spawn(async move {
|
||||
tokio::time::timeout(std::time::Duration::from_millis(500), queue_popper.pop())
|
||||
.await
|
||||
.expect("pop timed out — lost wakeup")
|
||||
.expect("pop timed out (lost wakeup)")
|
||||
.expect("queue closed early")
|
||||
});
|
||||
// The push races the popper's check→register window on the
|
||||
|
||||
+11
-11
@@ -1,13 +1,13 @@
|
||||
//! Integration tests for the producer half — the open-op path
|
||||
//! Integration tests for the producer half: the open-op path
|
||||
//! (establisher + pump handler + registration) over the duplex
|
||||
//! harness, ported from the reverse POC's suite (16 tests rode the
|
||||
//! topology) with the dial closure standing in for real sockets.
|
||||
//!
|
||||
//! Covered: establishment success + the typed-error table
|
||||
//! (`unknown_resource` / `dial_failed` / `FORBIDDEN`), the plan flow
|
||||
//! under same-resource concurrency (R-01 — no handoff race), the
|
||||
//! under same-resource concurrency (R-01, no handoff race), the
|
||||
//! per-call opener identity (CF-006 witness), late-registration
|
||||
//! visibility (W2), pump round-trip through `pump_bidi` (R-02 — the
|
||||
//! visibility (W2), pump round-trip through `pump_bidi` (R-02, the
|
||||
//! wrapper-managed pump, no spawn-and-forget), and out-of-band
|
||||
//! `channel/close`.
|
||||
|
||||
@@ -67,7 +67,7 @@ async fn call_open_params(
|
||||
}
|
||||
|
||||
/// Adopt the producer-allocated channel and pump it against in-process
|
||||
/// halves (the consumer-side data plane — the session's take_halves
|
||||
/// halves (the consumer-side data plane, the session's take_halves
|
||||
/// shape, driven manually until the consumer session task lands).
|
||||
async fn adopt_and_pump(
|
||||
topo: &Topology,
|
||||
@@ -90,7 +90,7 @@ async fn adopt_and_pump(
|
||||
(l_read, l_write, pump)
|
||||
}
|
||||
|
||||
/// Only channel 0 (the pre-negotiated call channel) may remain — a
|
||||
/// Only channel 0 (the pre-negotiated call channel) may remain; a
|
||||
/// failed open leaves no data channel on either side (the
|
||||
/// phantom-channel property).
|
||||
fn no_data_channels(topo: &Topology) -> bool {
|
||||
@@ -183,7 +183,7 @@ async fn dial_failure_is_typed_and_leaves_no_channel() {
|
||||
#[tokio::test]
|
||||
async fn schema_gate_rejects_missing_substrate_before_the_establisher() {
|
||||
// The registry's input-schema gate runs before the establisher:
|
||||
// a missing substrate is `INVALID_INPUT` with no reason code —
|
||||
// a missing substrate is `INVALID_INPUT` with no reason code, and
|
||||
// the establisher's own `handler_error` parse branch is
|
||||
// unreachable past this gate (the schema pins both fields).
|
||||
let registry = ResourceRegistry::new();
|
||||
@@ -251,7 +251,7 @@ async fn open_on_transport_identity_alone() {
|
||||
#[tokio::test]
|
||||
async fn open_token_overrides_transport_identity() {
|
||||
// CF-005 precedence: the payload token wins. The provider resolves
|
||||
// the scoped token to the consumer identity — the witness proves
|
||||
// the scoped token to the consumer identity; the witness proves
|
||||
// the establisher saw it end-to-end.
|
||||
let registry = ResourceRegistry::new();
|
||||
registry
|
||||
@@ -277,7 +277,7 @@ async fn open_token_overrides_transport_identity() {
|
||||
#[tokio::test]
|
||||
async fn concurrent_opens_same_resource_no_plan_race() {
|
||||
// The R-01 plan flow under concurrency: two opens of the SAME
|
||||
// resource — the establisher returns each dialed handle via ITS
|
||||
// resource; the establisher returns each dialed handle via ITS
|
||||
// plan; no handoff map to race. Each round-trips its own marker.
|
||||
let registry = ResourceRegistry::new();
|
||||
registry
|
||||
@@ -312,7 +312,7 @@ async fn concurrent_opens_same_resource_no_plan_race() {
|
||||
async fn late_registration_is_visible() {
|
||||
// W2: the harness registers the openable AFTER
|
||||
// from_connection_with_serving. If the dispatcher cached
|
||||
// registrations, opens would fail with unknown-operation — the
|
||||
// registrations, opens would fail with unknown-operation, which
|
||||
// successful opens elsewhere prove it; assert registry state too.
|
||||
let registry = ResourceRegistry::new();
|
||||
registry
|
||||
@@ -370,7 +370,7 @@ async fn producer_outbound_calls_still_resolve_while_serving() {
|
||||
#[tokio::test]
|
||||
async fn registry_lookup_respects_substrate_key() {
|
||||
// The registry is keyed by (resource, substrate): a resource
|
||||
// produced for TCP is unknown for UDP — the same name on a
|
||||
// produced for TCP is unknown for UDP: the same name on a
|
||||
// different substrate is a different resource (ADR-001).
|
||||
let registry = ResourceRegistry::new();
|
||||
registry.register("svc", Substrate::Tcp, "in-process").await;
|
||||
@@ -386,7 +386,7 @@ async fn registry_lookup_respects_substrate_key() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn tunnel_establisher_no_witness_wrapper_opens() {
|
||||
// The no-witness wrapper (`tunnel_establisher` — the public
|
||||
// The no-witness wrapper (`tunnel_establisher`, the public
|
||||
// default shape, producer.rs's witness-less establisher body):
|
||||
// a full open through the wrapper round-trips. The wrapper
|
||||
// differs from `tunnel_establisher_with_witness` ONLY in the
|
||||
|
||||
Reference in new issue
Block a user