Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d72820ddc2 | ||
|
|
74e6289a8e | ||
|
|
ecb24678bb | ||
|
|
0ceecf582b | ||
|
|
cd0afe0497 | ||
|
|
6b20924f68 | ||
|
|
92c3cc5123 | ||
|
|
c7e8c875fd | ||
|
|
f42ce47d17 | ||
|
|
5b2f7255f7 | ||
|
|
7d2b6d11f0 | ||
|
|
aa3db39e3e | ||
|
|
bd8a82b4c4 | ||
|
|
ec52a9620d | ||
|
|
9cca6ed3c4 |
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.
|
||||
+102
-58
@@ -6,11 +6,53 @@ this crate adheres to [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.5.0] - 2026-11-03
|
||||
|
||||
The correlation-gate release: the forwarded op's serving half gains an
|
||||
establishment phase (review 003 W1 absorbed in-repo, ADR-008 Amendment
|
||||
2), prompted by alkssh's consumer input on the composed `-R`
|
||||
deployment's failure vocabulary. Additive public API only (the minor
|
||||
bump, not a patch); the wire delta is empty and the base registration
|
||||
stays byte-for-byte. The rest of the wave is doc-only: the em-dash
|
||||
prose sweep across repo docs, spec docs, doc comments, and task files
|
||||
(no code-path change), and review 003's doc trail closure (W2/W3 are
|
||||
the sibling repos' adoption waves and remain booked there).
|
||||
|
||||
### 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, 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
|
||||
`Cargo.toml`/`Cargo.lock`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **alkcall 0.10.0 → 0.10.1** (crates.io). The bug-fix release
|
||||
(review 013, routed from alkgit's wave-2 gate): the
|
||||
`adopt_channel` install→drain window is now one atomic section, so
|
||||
a chunk routed by the demux inside the window can no longer be
|
||||
delivered ahead of earlier-parked early arrivals (the observed
|
||||
downstream shape was pkt-line desync in alkgit's open-op
|
||||
integration test under multi-process load), and a related loss
|
||||
variant (a chunk parked after the drain removed the queue, parked
|
||||
forever) is closed. Relevant in principle to this crate's
|
||||
reverse-path adopt flow (the bind-first establisher surfaces
|
||||
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
|
||||
only strengthens. 136 tests pass; wasm-clean verified (check +
|
||||
clippy on `wasm32-unknown-unknown`).
|
||||
|
||||
## [0.4.0] - 2026-10-19
|
||||
|
||||
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
|
||||
@@ -22,11 +64,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
|
||||
@@ -42,7 +84,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
|
||||
@@ -60,7 +102,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
|
||||
@@ -78,7 +120,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,
|
||||
@@ -90,7 +132,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
|
||||
@@ -98,12 +140,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.
|
||||
@@ -113,7 +155,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.
|
||||
|
||||
@@ -125,98 +167,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
|
||||
@@ -224,36 +266,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`).
|
||||
@@ -263,20 +305,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
|
||||
@@ -303,8 +345,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
|
||||
@@ -312,7 +354,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`);
|
||||
@@ -320,9 +362,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.
|
||||
@@ -332,7 +374,9 @@ complete (Phase 2, review 001 findings remediated).
|
||||
matrix). Wasm-clean default crate verified (`wasm32-unknown-unknown`
|
||||
check + clippy).
|
||||
|
||||
[Unreleased]: https://git.alk.dev/alkdev/alktunnels/compare/v0.4.0...HEAD
|
||||
[Unreleased]: https://git.alk.dev/alkdev/alktunnels/compare/v0.5.0...HEAD
|
||||
[0.5.0]: https://git.alk.dev/alkdev/alktunnels/compare/v0.4.1...v0.5.0
|
||||
[0.4.1]: https://git.alk.dev/alkdev/alktunnels/compare/v0.4.0...v0.4.1
|
||||
[0.4.0]: https://git.alk.dev/alkdev/alktunnels/compare/v0.3.0...v0.4.0
|
||||
[0.3.0]: https://git.alk.dev/alkdev/alktunnels/compare/v0.2.1...v0.3.0
|
||||
[0.2.1]: https://git.alk.dev/alkdev/alktunnels/compare/v0.2.0...v0.2.1
|
||||
|
||||
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.5.0"
|
||||
dependencies = [
|
||||
"alkcall",
|
||||
"bytes",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "alktunnels"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.88"
|
||||
license = "MIT OR Apache-2.0"
|
||||
@@ -23,7 +23,7 @@ default = []
|
||||
local = ["tokio/net"]
|
||||
|
||||
[dependencies]
|
||||
alkcall = "0.10.0"
|
||||
alkcall = "0.10.1"
|
||||
tokio = { version = "1", default-features = false, features = ["rt", "sync", "io-util", "macros", "time"] }
|
||||
bytes = "1"
|
||||
futures = "0.3"
|
||||
|
||||
@@ -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
|
||||
@@ -108,13 +108,23 @@ register_tunnel_bindfirst_openable(&core, ®istry, &op_registry, auth, injecti
|
||||
|
||||
// The forwarded op's opener-side serving half (on the consumer's
|
||||
// connection): correlate the token, pump against the local socket.
|
||||
// The establisher variant (recommended; the gate runs first) fails a
|
||||
// stale/missing token as a typed `channel:open_failed` BEFORE the
|
||||
// channel is born:
|
||||
register_tunnel_forwarded_openable(
|
||||
&core, &op_registry, auth,
|
||||
/* ForwardedHandler: (params, BiStream) -> pump task */,
|
||||
)?;
|
||||
|
||||
// With the correlation gate (stale token = `unknown_resource`); the
|
||||
// handler shape is unchanged:
|
||||
register_tunnel_forwarded_openable_with_establisher(
|
||||
&core, &op_registry, auth, gate /* ForwardedGate */,
|
||||
/* ForwardedHandler: (params, BiStream) -> pump task */,
|
||||
)?;
|
||||
```
|
||||
|
||||
### Consumer — open a tunnel channel (the `-L` path)
|
||||
### Consumer: open a tunnel channel (the `-L` path)
|
||||
|
||||
```rust
|
||||
use alkcall::channels::client::ChannelClient;
|
||||
@@ -131,13 +141,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 +156,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 +183,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 +200,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 +229,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 +251,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 +261,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,59 +288,19 @@ 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
|
||||
|
||||
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 bind-first establisher + the forwarded op**
|
||||
(`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` +
|
||||
`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
|
||||
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 —
|
||||
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
|
||||
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
|
||||
`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.
|
||||
|
||||
## Features
|
||||
|
||||
| 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).
|
||||
|
||||
|
||||
+25
-25
@@ -3,13 +3,13 @@ status: draft
|
||||
last_updated: 2026-09-16 (the graduation ADRs 007/008 + the amendments)
|
||||
---
|
||||
|
||||
# alktunnels — Architecture
|
||||
# alktunnels: Architecture
|
||||
|
||||
Arbitrary bidirectional tunnels over alkcall channels: the
|
||||
`alk/tunnel`-family producer/consumer protocol crate. This directory
|
||||
holds the architecture spec docs and the ADRs. It is the sibling of
|
||||
alktty's architecture (`/workspace/@alkdev/alktty/docs/architecture/`)
|
||||
— same substrate (alkcall channels), same shape (a producer
|
||||
alktty's architecture (`/workspace/@alkdev/alktty/docs/architecture/`):
|
||||
same substrate (alkcall channels), same shape (a producer
|
||||
open-handler + a typed consumer session), different service (tunnels:
|
||||
raw byte/datagram transport, not terminal sessions).
|
||||
|
||||
@@ -18,41 +18,41 @@ raw byte/datagram transport, not terminal sessions).
|
||||
| Document | Status | Description |
|
||||
|----------|--------|-------------|
|
||||
| [overview.md](overview.md) | draft | Crate purpose, the resource model in brief, dependencies, ALPN, feature gates, module map |
|
||||
| [wire.md](wire.md) | draft | The wire format: open-op params JSON (ADR-001; the direct op's `{substrate, target}` — ADR-007), the `bound` reply field + the forwarded op (ADR-008), the data-plane codec (raw pass-through for stream substrates; mandatory `[len: u16 BE]` framing for UDP — ADR-003), sentinels, half-close semantics |
|
||||
| [wire.md](wire.md) | draft | The wire format: open-op params JSON (ADR-001; the direct op's `{substrate, target}` per ADR-007), the `bound` reply field + the forwarded op (ADR-008), the data-plane codec (raw pass-through for stream substrates; mandatory `[len: u16 BE]` framing for UDP per ADR-003), sentinels, half-close semantics |
|
||||
| [producer.md](producer.md) | draft | The producer half: the `channels/tunnel/sub` + `channels/tunnel/direct` open ops, three establisher shapes (dial, accept-first listen, bind-first listen), the pump handler, the forwarded-op opener side, registration, scope gating |
|
||||
| [consumer.md](consumer.md) | draft | The consumer half: `TunnelSession` (stream + datagram variants, `open`/`open_direct`), `TunnelListener` (the listen flow), the reverse-flow initiator, teardown ownership (ADR-005/008) |
|
||||
| [bast.md](bast.md) | draft | The BAST (Binary Abstract Syntax Tree) document for the data-plane's binary framing (the UDP `[len: u16 BE]` codec — ADR-003); the stream pass-through has no binary framing to describe |
|
||||
| [bast.md](bast.md) | draft | The BAST (Binary Abstract Syntax Tree) document for the data-plane's binary framing (the UDP `[len: u16 BE]` codec, ADR-003); the stream pass-through has no binary framing to describe |
|
||||
| [open-questions.md](open-questions.md) | draft | Centralized OQ tracker (OQ-TN-01..16; the Phase 0 ledger promoted) |
|
||||
|
||||
## Applicable ADRs
|
||||
|
||||
alktunnels-native ADRs (001..008). Upstream ADRs (alkcall/alknet) are
|
||||
referenced by their own numbers and are NOT ported — the alkcall
|
||||
referenced by their own numbers and are NOT ported; the alkcall
|
||||
originals at `/workspace/@alkdev/alkcall/docs/architecture/decisions/`
|
||||
remain authoritative for the shared surfaces (establishment, channels
|
||||
wire format, `pump_bidi`, identity seam).
|
||||
|
||||
| ADR | Title | Status |
|
||||
|-----|-------|--------|
|
||||
| [001](decisions/001-open-params-layout.md) | Open-Op Params Layout: Resource + Substrate | Accepted; amended (params layout is per-op — the direct op's params ride ADR-007) |
|
||||
| [001](decisions/001-open-params-layout.md) | Open-Op Params Layout: Resource + Substrate | Accepted; amended (params layout is per-op; the direct op's params ride ADR-007) |
|
||||
| [002](decisions/002-alpn-strategy.md) | Single `alk/tunnel` ALPN (Option A) | Accepted; amended (flavor-form op ids: `…/sub`, `…/direct`, `…/forwarded` on the one ALPN) |
|
||||
| [003](decisions/003-codec-and-udp-framing.md) | Data-Plane Codec: Raw Pass-Through (Stream) / Mandatory Length Framing (UDP) | Accepted |
|
||||
| [004](decisions/004-no-backend-trait.md) | No TunnelBackend Trait — Halves Functions at the Assembly Layer | Accepted |
|
||||
| [004](decisions/004-no-backend-trait.md) | No TunnelBackend Trait: Halves Functions at the Assembly Layer | Accepted |
|
||||
| [005](decisions/005-consumer-session-owns-teardown.md) | The Consumer Session Type Owns Teardown | Accepted |
|
||||
| [006](decisions/006-access-control-posture.md) | Access-Control Posture — the Open Gate Is the Security Boundary | Accepted; amended (`tunnel:direct`/`tunnel:forwarded` scopes; the hub re-produce policy sentence) |
|
||||
| [007](decisions/007-direct-open-op.md) | The Direct Open Op — Dynamic-Target Egress | Accepted |
|
||||
| [008](decisions/008-listen-metadata-forwarded-op.md) | Listen Metadata — Bind-First Establisher and the Forwarded Op | Accepted |
|
||||
| [006](decisions/006-access-control-posture.md) | Access-Control Posture: the Open Gate Is the Security Boundary | Accepted; amended (`tunnel:direct`/`tunnel:forwarded` scopes; the hub re-produce policy sentence) |
|
||||
| [007](decisions/007-direct-open-op.md) | The Direct Open Op: Dynamic-Target Egress | Accepted |
|
||||
| [008](decisions/008-listen-metadata-forwarded-op.md) | Listen Metadata: Bind-First Establisher and the Forwarded Op | Accepted |
|
||||
|
||||
## Key Design Principles
|
||||
|
||||
1. **A tunnel is a resource, not an address — with one additive
|
||||
exception.** Base params identify a produced resource + substrate
|
||||
1. **A tunnel is a resource, not an address (with one additive
|
||||
exception).** Base params identify a produced resource + substrate
|
||||
discriminator; the producer owns the backing (local port, docker
|
||||
container, in-process service, unix socket). Rich addressing
|
||||
enters through `-D`/dynamic composition, which speaks its protocol
|
||||
inside the tunnel payload. The exception (ADR-007): the direct op
|
||||
lets a consumer ask the producer to dial an arbitrary target — a
|
||||
different capability on its own op and scope (`tunnel:direct`),
|
||||
lets a consumer ask the producer to dial an arbitrary target. That
|
||||
is a different capability on its own op and scope (`tunnel:direct`),
|
||||
never implied by `tunnel:open`. See
|
||||
[ADR-001](decisions/001-open-params-layout.md),
|
||||
[ADR-007](decisions/007-direct-open-op.md), and
|
||||
@@ -61,7 +61,7 @@ wire format, `pump_bidi`, identity seam).
|
||||
2. **Role follows the resource; connection direction is independent.**
|
||||
Whoever can reach the target is the producer (registers openable
|
||||
channels); whoever wants the bytes is the consumer. `-L` and `-R`
|
||||
are the same open op with the entry point on different machines —
|
||||
are the same open op with the entry point on different machines;
|
||||
no protocol-level direction (the hub-owns-the-connection model,
|
||||
OQ-TN-03). The composed `-R` flow rides the bind-first establisher
|
||||
+ the forwarded op (ADR-008). Validated by the reverse-flow POC:
|
||||
@@ -70,15 +70,15 @@ wire format, `pump_bidi`, identity seam).
|
||||
[consumer.md](consumer.md).
|
||||
|
||||
3. **Raw pass-through for stream substrates; mandatory length framing
|
||||
for UDP.** A tunnel has one data stream per direction — no
|
||||
for UDP.** A tunnel has one data stream per direction: no
|
||||
sub-demux key, no tunnel-level header on stream substrates (0 B
|
||||
tunnel overhead; the channels 8-byte header is the only cost).
|
||||
UDP rides `[len: u16 BE]` framing — mandatory for correctness
|
||||
UDP rides `[len: u16 BE]` framing, which is mandatory for correctness
|
||||
(F-2; the rationale and the executable pin live in ADR-003). See
|
||||
[ADR-003](decisions/003-codec-and-udp-framing.md) and
|
||||
[wire.md](wire.md).
|
||||
|
||||
4. **The two-pump shape is `alkcall::channels::pump_bidi` — never
|
||||
4. **The two-pump shape is `alkcall::channels::pump_bidi`; it is never
|
||||
hand-rolled.** The producer's pump handler and the consumer-side
|
||||
pump both consume the upstream helper (ADR-050); each pump shuts
|
||||
down the opposite sink on completion (half-close semantics fall
|
||||
@@ -87,7 +87,7 @@ wire format, `pump_bidi`, identity seam).
|
||||
[producer.md](producer.md).
|
||||
|
||||
5. **No backend trait.** Stream substrates need "produce boxed halves
|
||||
for a resource" — a function, not a trait (the UDP POC's OQ-TN-05
|
||||
for a resource"; that is a function, not a trait (the UDP POC's OQ-TN-05
|
||||
finding). The inversion point is the assembly layer wiring
|
||||
substrate halves into the establisher/pump; substrate types stay
|
||||
in feature-gated backend modules. The listen variant (producer
|
||||
@@ -96,7 +96,7 @@ wire format, `pump_bidi`, identity seam).
|
||||
|
||||
6. **The consumer session type owns teardown.** One session = one
|
||||
channel = one tunnel (the channel ID is the flow key). The adopted
|
||||
side has no wrapper awaiting its pump (reverse POC W3) — the
|
||||
side has no wrapper awaiting its pump (reverse POC W3): the
|
||||
session must hold the pump handle and reap the channel on
|
||||
completion/close. See [ADR-005](decisions/005-consumer-session-owns-teardown.md)
|
||||
and [consumer.md](consumer.md).
|
||||
@@ -110,13 +110,13 @@ wire format, `pump_bidi`, identity seam).
|
||||
8. **Security posture: the open gate is the boundary.** Scope-gated
|
||||
opens (`TUNNEL_OPEN_SCOPE = "tunnel:open"`,
|
||||
`TUNNEL_DIRECT_SCOPE = "tunnel:direct"`,
|
||||
`TUNNEL_FORWARDED_SCOPE = "tunnel:forwarded"` — each stable once
|
||||
`TUNNEL_FORWARDED_SCOPE = "tunnel:forwarded"` (each stable once
|
||||
published; ADR-006 + Amendment 1), alkcall's op-level ACL via
|
||||
`register_openable`; caller identity resolves in the 0.7.0
|
||||
precedence order (token > `ServingConfig.identity` > transport);
|
||||
the establisher/pump handler see the per-call opener identity. No
|
||||
target allowlists in the protocol (an assembly establisher MAY
|
||||
enforce them — policy, not protocol). Hub re-produce: the hub's
|
||||
enforce them, but that is policy, not protocol). Hub re-produce: the hub's
|
||||
ACL governs the final dial (the hub is the network boundary). See
|
||||
[ADR-006](decisions/006-access-control-posture.md),
|
||||
[ADR-007](decisions/007-direct-open-op.md), and
|
||||
@@ -155,10 +155,10 @@ Questions section.
|
||||
(CF-005/CF-006 = the identity seam, resolved in 0.7.0)
|
||||
- The graduation research (the two new ADRs implement it):
|
||||
`docs/research/tunnels-graduation.md`; the upstream alkcall asks
|
||||
(review 008) **landed in alkcall 0.8.0** — flavor-form discovery
|
||||
(review 008) **landed in alkcall 0.8.0**: flavor-form discovery
|
||||
derivation + the establisher → reply `bound` projection, plus the
|
||||
in-tree `ChannelRelay`/`HubLegTemplate` (alkcall ADR-051)
|
||||
- Sibling precedent: alktty (`/workspace/@alkdev/alktty`) — the
|
||||
- Sibling precedent: alktty (`/workspace/@alkdev/alktty`), i.e. the
|
||||
producer/consumer-on-channels shape, wasm-clean default,
|
||||
feature-gated local backend, BAST convention
|
||||
- POC code: `/workspace/alktunnels-udp-poc` (forward),
|
||||
|
||||
+17
-17
@@ -3,11 +3,11 @@ status: implemented
|
||||
last_updated: 2026-09-10
|
||||
---
|
||||
|
||||
# alktunnels — BAST Document for the `alk/tunnel` Wire Format
|
||||
# alktunnels: BAST Document for the `alk/tunnel` Wire Format
|
||||
|
||||
This document is the **BAST (Binary Abstract Syntax Tree)**
|
||||
specification for the binary portions of the `alk/tunnel` wire format
|
||||
— per AGENTS.md convention 12, the mandatory UDP length-framing codec
|
||||
specification for the binary portions of the `alk/tunnel` wire format.
|
||||
Per AGENTS.md convention 12, the mandatory UDP length-framing codec
|
||||
([ADR-003](decisions/003-codec-and-udp-framing.md)) is binary framing
|
||||
beyond pass-through, so the BAST document exists even though the
|
||||
format is trivial. It is a normative JSON document conforming to the
|
||||
@@ -24,31 +24,31 @@ an optional dep and this document is there to feed it.
|
||||
## Scope
|
||||
|
||||
The `alk/tunnel` wire format is: a JSON open op on the channels call
|
||||
plane (a UTF-8 JSON object inside the channels envelope — not a
|
||||
plane (a UTF-8 JSON object inside the channels envelope, not a
|
||||
binary layout; specified in [wire.md](wire.md) §The Open Op and
|
||||
implemented by `TunnelParams`/`tunnel_open_spec` in the Rust source),
|
||||
and a binary data plane inside the channel `BiStream`.
|
||||
|
||||
Per the BAST format spec, a BAST document describes **binary data
|
||||
layouts** — not JSON payloads
|
||||
layouts**, not JSON payloads
|
||||
([`validate_bytes` vs `validate_json`](https://alk.dev/bast/v1/spec)).
|
||||
This document therefore covers only the data plane's binary framing:
|
||||
|
||||
1. **The datagram frame** (`[len: u16 BE][datagram bytes]`) — the
|
||||
1. **The datagram frame** (`[len: u16 BE][datagram bytes]`), which is the
|
||||
UDP-substrate codec (ADR-003; mandatory, F-2). Stream substrates
|
||||
ride raw pass-through — zero tunnel-level framing, so there is
|
||||
ride raw pass-through, i.e. zero tunnel-level framing, so there is
|
||||
nothing for BAST to describe there.
|
||||
|
||||
What this BAST deliberately does **not** cover:
|
||||
|
||||
- **The open-op params JSON shape** (`{resource, substrate}`) — UTF-8
|
||||
- **The open-op params JSON shape** (`{resource, substrate}`), which is UTF-8
|
||||
JSON on the call plane; specified in
|
||||
[ADR-001](decisions/001-open-params-layout.md) and
|
||||
[wire.md](wire.md), implemented by `TunnelParams`.
|
||||
- **The channels 8-byte chunk header** — the channels layer's own
|
||||
- **The channels 8-byte chunk header**, the channels layer's own
|
||||
framing (alknet ADR-071/093); stripped transparently before the
|
||||
tunnel data plane sees bytes. Specified upstream, not here.
|
||||
- **The EOF sentinel** — the channels-level zero-length chunk
|
||||
- **The EOF sentinel**, i.e. the channels-level zero-length chunk
|
||||
(upstream); the tunnel codec never emits it (that is the F-2
|
||||
invariant).
|
||||
|
||||
@@ -72,12 +72,12 @@ What this BAST deliberately does **not** cover:
|
||||
|
||||
## Annotations
|
||||
|
||||
### `DatagramFrame` — binary, exact
|
||||
### `DatagramFrame`: binary, exact
|
||||
|
||||
Maps 1:1 to the on-wire bytes. `len` as `uint16` big-endian (2 bytes)
|
||||
followed by exactly `len` bytes of datagram payload. The zero-byte
|
||||
payload (`len == 0`) is a legal empty datagram — a real datagram, NOT
|
||||
EOF (the F-2 invariant; EOF is the channels-level sentinel, outside
|
||||
payload (`len == 0`) is a legal empty datagram (a real datagram, NOT
|
||||
EOF; the F-2 invariant; EOF is the channels-level sentinel, outside
|
||||
this codec). The `maxLength: 65535` encodes the u16 bound; the codec
|
||||
rejects framing a larger datagram at frame time (`Oversize`) rather
|
||||
than wrapping the length field.
|
||||
@@ -85,13 +85,13 @@ than wrapping the length field.
|
||||
One frame per datagram, in both directions of the channel
|
||||
`BiStream`. Frames are self-delimiting on the byte stream; a decoder
|
||||
is an incremental state machine across chunk boundaries (chunk
|
||||
splitting/batching is transparent — forward POC, 7-byte chunk splits
|
||||
splitting/batching is transparent, per the forward POC's 7-byte chunk splits
|
||||
verified).
|
||||
|
||||
### Layering note
|
||||
|
||||
The codec sits at the substrate/pump boundary (the establisher wraps
|
||||
the dialed UDP half in the framed adapter — ADR-003's placement, so
|
||||
the dialed UDP half in the framed adapter, per ADR-003's placement, so
|
||||
the pump handler stays substrate-agnostic). The BAST describes the
|
||||
wire inside the `BiStream` only; the pump/pump_bidi contract
|
||||
(ADR-050) is upstream's, not re-specified here.
|
||||
@@ -100,9 +100,9 @@ wire inside the `BiStream` only; the pump/pump_bidi contract
|
||||
|
||||
- [ADR-003](decisions/003-codec-and-udp-framing.md) (the codec
|
||||
decision + F-2 mandate), [ADR-001](decisions/001-open-params-layout.md)
|
||||
(the JSON open-op shape — outside BAST scope)
|
||||
(the JSON open-op shape, outside BAST scope)
|
||||
- [wire.md](wire.md) (the normative prose; §Normative byte diagrams)
|
||||
- alktty `tty-bast.md` (the BAST precedent — same meta-schema, same
|
||||
- alktty `tty-bast.md` (the BAST precedent, with the same meta-schema and the same
|
||||
"binary framing only" scope rule)
|
||||
- Forward POC `docs/research/poc-summary.md` §3 (the codec's
|
||||
validation matrix); reverse POC
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-09-18 (`open_direct`, `TunnelListener`, the forwarded-op serving side — ADR-007/008 as amended: the correlation token)
|
||||
last_updated: 2026-09-18 (`open_direct`, `TunnelListener`, the forwarded-op serving side; ADR-007/008 as amended: the correlation token)
|
||||
---
|
||||
|
||||
# alktunnels — Consumer Half
|
||||
# alktunnels: Consumer Half
|
||||
|
||||
The consumer half: `TunnelSession` — the typed client for tunnel
|
||||
channels — plus `TunnelListener` (the listen-flow session, ADR-008)
|
||||
The consumer half: `TunnelSession`, the typed client for tunnel
|
||||
channels, plus `TunnelListener` (the listen-flow session, ADR-008)
|
||||
and the forwarded-op serving side. The consumer is whoever wants the
|
||||
bytes; it opens tunnel channels toward the producer (forward path), or
|
||||
per local accept toward a connect-side serving producer (the reverse
|
||||
flow). Both are the same session type — role follows the resource, not
|
||||
flow). Both are the same session type; role follows the resource, not
|
||||
the connection (OQ-TN-03). Design decisions:
|
||||
[ADR-005](decisions/005-consumer-session-owns-teardown.md) (session
|
||||
owns teardown), [ADR-003](decisions/003-codec-and-udp-framing.md)
|
||||
@@ -20,24 +20,24 @@ metadata + the forwarded op); this document is the normative WHAT.
|
||||
|
||||
## Opening a Tunnel
|
||||
|
||||
### Forward path (`-L`/direct — the consumer dials the producer's transport)
|
||||
### Forward path (`-L`/direct, the consumer dials the producer's transport)
|
||||
|
||||
```rust
|
||||
let session = TunnelSession::open(&channel_client, params).await?;
|
||||
```
|
||||
|
||||
- `params`: `{resource, substrate}` (ADR-001) — the produced resource
|
||||
- `params`: `{resource, substrate}` (ADR-001), i.e. the produced resource
|
||||
+ substrate discriminator.
|
||||
- Flow: `ChannelClient::open_channel("channels/tunnel/sub", params,
|
||||
"alk/tunnel")` → adopt the returned channel ID → split the channel
|
||||
`BiStream` → present the substrate-shaped data plane.
|
||||
- Errors: a failed open resolves a typed error —
|
||||
- Errors: a failed open resolves a typed error:
|
||||
`ChannelOpenError::CallFailed` carrying the wire `CallError`;
|
||||
branch on `establishment_reason()` for `channel:open_failed`'s
|
||||
reason (dial_failed / unknown_resource / resource_shortage /
|
||||
handler_error / timeout), or the pre-establishment codes
|
||||
(`FORBIDDEN`, `channel:too_many_channels`). **A failed open never
|
||||
yields a session** — no phantom session, mirroring the
|
||||
yields a session**; no phantom session, mirroring the
|
||||
no-phantom-channel property (POC-verified).
|
||||
|
||||
### Direct path (dynamic-target egress, ADR-007)
|
||||
@@ -51,20 +51,20 @@ let session = TunnelSession::open_direct(&client, target, substrate).await?;
|
||||
caller states which one it holds. The session type, data planes,
|
||||
and teardown API are identical.
|
||||
- `target`: the substrate-shaped JSON object (`{host, port}` /
|
||||
`{path}` — ADR-007 §2); domain-form hosts stay unresolved (the
|
||||
`{path}`; ADR-007 §2); domain-form hosts stay unresolved (the
|
||||
producer resolves).
|
||||
- Flow: `open_channel("channels/tunnel/direct", {substrate, target},
|
||||
"alk/tunnel")` → same adopt/split/data-plane flow as `open`.
|
||||
- Errors: the same typed surface minus `unknown_resource` (no
|
||||
registry lookup); malformed targets are the schema/
|
||||
`invalid_input`-class failure. An old producer rejects the unknown
|
||||
op loudly (`NOT_FOUND` — the SSH posture).
|
||||
op loudly (`NOT_FOUND`, the SSH posture).
|
||||
|
||||
### Reverse path (`-R` — the consumer initiates toward a connect-side serving producer)
|
||||
### Reverse path (`-R`, the consumer initiates toward a connect-side serving producer)
|
||||
|
||||
The hub/accept-side shape (the reverse POC's `ReverseTunnel`, the
|
||||
seed of this path). Construction differs from the forward path only
|
||||
in how the channel is obtained — the session type and teardown API
|
||||
in how the channel is obtained; the session type and teardown API
|
||||
are identical:
|
||||
|
||||
```rust
|
||||
@@ -74,29 +74,29 @@ let session = TunnelSession::adopt(hub_manager, channel_id, substrate, TUNNEL_AL
|
||||
let pumped = session.pump_against(accepted_halves).await; // or drive the halves directly
|
||||
```
|
||||
|
||||
- `open_reverse_channel` — call the worker's open op on channel 0
|
||||
- `open_reverse_channel`: call the worker's open op on channel 0
|
||||
(`call_with_payload`; optional `auth_token`), extract the
|
||||
worker-allocated `channel_id`. Caller identity rides the transport
|
||||
by default (alkcall 0.7.0 CF-005 (b): `Connection::set_identity`
|
||||
before dialing); the payload `auth_token` is the optional
|
||||
hub-forwarding path (precedence: token > `ServingConfig.identity` >
|
||||
transport).
|
||||
- `TunnelSession::adopt(manager, channel_id, substrate, alpn)` —
|
||||
adopt the worker-allocated ID (connection-owner rule, ADR-047 §5 —
|
||||
- `TunnelSession::adopt(manager, channel_id, substrate, alpn)`:
|
||||
adopt the worker-allocated ID (connection-owner rule, ADR-047 §5:
|
||||
the SERVING side allocates; the initiator adopts; early-arrival
|
||||
parking covers the adoption race), install the session's data plane
|
||||
from the adopted halves. The substrate parameter is required — a
|
||||
from the adopted halves. The substrate parameter is required; a
|
||||
datagram session cannot exist without it (the data plane variant is
|
||||
substrate-shaped). `adopt` takes the same typed-error surface as
|
||||
`open` (an adopt failure is `AdoptFailed`-class, not an
|
||||
establishment error).
|
||||
- `pump_against(accepted_halves)` — spawn `pump_bidi` against the
|
||||
- `pump_against(accepted_halves)`: spawn `pump_bidi` against the
|
||||
accepted local connection and hold the pump handle (ADR-005).
|
||||
|
||||
The session type and its teardown API are identical on both paths —
|
||||
The session type and its teardown API are identical on both paths:
|
||||
one API, `-L` and `-R` alike.
|
||||
|
||||
## The Listen Flow — `TunnelListener` (ADR-008)
|
||||
## The Listen Flow: `TunnelListener` (ADR-008)
|
||||
|
||||
The listen-flow sibling of `TunnelSession`: the session that OPENS a
|
||||
bind-first listen tunnel and owns its teardown (ADR-005's ownership
|
||||
@@ -106,25 +106,25 @@ opener's manager (`adopt` gains nothing).
|
||||
|
||||
```rust
|
||||
let listener = TunnelListener::open(&client, params).await?; // bind-first: fast establishment
|
||||
let bound = listener.bound(); // the resolved OS-chosen address (Option — absent when unknown)
|
||||
let bound = listener.bound(); // the resolved OS-chosen address (Option; absent when unknown)
|
||||
let token = listener.listen(); // the correlation token (the producer's; from the `listen` reply field)
|
||||
let sessions = listener.forwarded(); // the forwarded sessions, as they arrive
|
||||
listener.close(self); // or Drop — tears the listen channel + far listener down
|
||||
listener.close(self); // or Drop; tears the listen channel + far listener down
|
||||
```
|
||||
|
||||
- **`TunnelListener::open`** — the same `channels/tunnel/sub` open
|
||||
- **`TunnelListener::open`**: the same `channels/tunnel/sub` open
|
||||
with `{resource, substrate}`; the establisher shape (bind-first vs
|
||||
accept-first) is the producer's per-registration choice, invisible
|
||||
to the constructor. What differs: bind-first replies carry
|
||||
`"bound"` (ADR-008 §2 — the resolved address; informational, never
|
||||
a dial target) and `"listen"` (ADR-008 Amendment 1 — the
|
||||
`"bound"` (ADR-008 §2: the resolved address; informational, never
|
||||
a dial target) and `"listen"` (ADR-008 Amendment 1: the
|
||||
correlation token the producer minted).
|
||||
- **`bound()`** — `Option`-shaped: `Some` when the producer supplied
|
||||
- **`bound()`**: `Option`-shaped: `Some` when the producer supplied
|
||||
the field (bind-first), `None` otherwise (accept-first, dial
|
||||
establishers, or old producers). reply#1-class fidelity (BND.ADDR).
|
||||
- **`listen()`** — `Option<u32>`: the correlation token (bind-first
|
||||
producers). The opener-side correlation registry — token → waiting
|
||||
session — is assembly-side bookkeeping (consumer.md's posture:
|
||||
- **`listen()`**: `Option<u32>`: the correlation token (bind-first
|
||||
producers). The opener-side correlation registry, token → waiting
|
||||
session, is assembly-side bookkeeping (consumer.md's posture:
|
||||
the protocol provides the op and params, not the wait state
|
||||
machine); `TunnelListener` hands the assembly the key.
|
||||
- **The forwarded side:** each accepted connection at the far
|
||||
@@ -135,7 +135,7 @@ listener.close(self); // or Drop — tears the listen channel + far li
|
||||
session, adopts the allocated channel (`TunnelSession::adopt`),
|
||||
and pumps against the local BIND client's halves.
|
||||
- **`peer` fidelity:** the forwarded op's params carry the accepted
|
||||
connection's real remote address (reply#2-class fidelity — the
|
||||
connection's real remote address (reply#2-class fidelity; the
|
||||
alksocks POC's sentinel retires). Producer-asserted metadata, not
|
||||
transport truth.
|
||||
- **Teardown:** dropping/`close`ing the listener EOFs the listen
|
||||
@@ -145,18 +145,18 @@ listener.close(self); // or Drop — tears the listen channel + far li
|
||||
|
||||
## The Data Plane (substrate-shaped)
|
||||
|
||||
- **Stream variant (`tcp`, `unix`):** `stream_halves()` — raw
|
||||
- **Stream variant (`tcp`, `unix`):** `stream_halves()`: raw
|
||||
`AsyncRead`/`AsyncWrite` halves; the halves ARE the tunnel (raw
|
||||
pass-through, ADR-003). For owned access (spawning local pumps),
|
||||
`take_halves()` consumes the session's halves.
|
||||
- **Datagram variant (`udp`):** `send_datagram(&[u8])` /
|
||||
`recv_datagram() -> Option<Bytes>` over the mandatory
|
||||
`[len: u16 BE]` codec (ADR-003). `recv_datagram` returns
|
||||
`Some(bytes)` per datagram — possibly empty (`len=0` is a legal
|
||||
empty datagram) — and `None` only on stream EOF (the channels-level
|
||||
`Some(bytes)` per datagram (possibly empty; `len=0` is a legal
|
||||
empty datagram), and `None` only on stream EOF (the channels-level
|
||||
sentinel; the codec never collides with it, F-2). A >65535-byte
|
||||
send is rejected at frame time (`Oversize`). `take_halves()` on a
|
||||
datagram session yields the RAW halves — the wire bytes carry the
|
||||
datagram session yields the RAW halves; the wire bytes carry the
|
||||
codec framing (ADR-005's N-3 amendment: one `take_halves` semantic
|
||||
for every substrate; keep the session for codec-applied I/O, or
|
||||
compose the framed adapter at your own boundary).
|
||||
@@ -165,27 +165,27 @@ listener.close(self); // or Drop — tears the listen channel + far li
|
||||
|
||||
One session = one channel = one tunnel. The session owns:
|
||||
|
||||
- **The adopted channel entry** — nothing upstream awaits the
|
||||
- **The adopted channel entry**: nothing upstream awaits the
|
||||
adopter's pump (the W3 gap); the session closes it structurally.
|
||||
- **The session-owned pump handle** (when the session spawned one —
|
||||
the reverse path's `pump_against`).
|
||||
- **The session-owned pump handle** (when the session spawned one,
|
||||
i.e. the reverse path's `pump_against`).
|
||||
|
||||
Teardown API:
|
||||
|
||||
- `close(self)` — abort the pump (if session-owned), tear down the
|
||||
- `close(self)`: abort the pump (if session-owned), tear down the
|
||||
adopted channel (`ChannelManager::teardown_channel`), consume the
|
||||
session. The ungraceful path.
|
||||
- `join(self)` — await pump completion (both directions finished;
|
||||
half-close semantics fall out of `pump_bidi` — one direction EOFs,
|
||||
- `join(self)`: await pump completion (both directions finished;
|
||||
half-close semantics fall out of `pump_bidi`: one direction EOFs,
|
||||
the opposite sink shuts down, the other keeps pumping until its own
|
||||
EOF; W4-validated), THEN reap the adopted channel. Returns the
|
||||
`(u64, u64)` copy counts for observability. The graceful path.
|
||||
**Pump-less sessions** (after `take_halves`, or a datagram session
|
||||
the caller drives directly): there is no session-owned pump —
|
||||
the caller drives directly): there is no session-owned pump, so
|
||||
`join` completes immediately (nothing to await) and reaps only;
|
||||
the copy counts are `(0, 0)`.
|
||||
- `Drop` — aborts the pump and sync-reaps the channel (teardown
|
||||
semantics of `close`, best-effort — `Drop` cannot await). Dropping
|
||||
- `Drop`: aborts the pump and sync-reaps the channel (teardown
|
||||
semantics of `close`; best-effort, since `Drop` cannot await). Dropping
|
||||
without close/join never leaks the channel entry.
|
||||
- **`TunnelSession` does not implement `Clone`.** Aliasing a session
|
||||
would alias its teardown; multi-channel consumers hold a
|
||||
@@ -195,7 +195,7 @@ Teardown API:
|
||||
|
||||
A peer-initiated `channel/close` (the generic channel op on the
|
||||
producer's serving registry) tears the SERVING side down; the
|
||||
consumer's session still owns its adopted entry — `close`/`join` reaps
|
||||
consumer's session still owns its adopted entry, so `close`/`join` reaps
|
||||
it. The reverse POC pinned this split (the close ran on the worker;
|
||||
the hub reaped its own entry, W3). EOF propagation is symmetric:
|
||||
either side's teardown reaches the other as the channels-level
|
||||
@@ -208,20 +208,20 @@ roles (alkcall ADR-022 §2): a worker dials the hub (connect side),
|
||||
serves its open ops (serving side), and its own outbound calls still
|
||||
resolve while serving (reverse POC: worker_outbound_calls_still_
|
||||
resolve_while_serving). The consumer half adds nothing to the
|
||||
connection's serving posture — a pure consumer (`from_connection`)
|
||||
connection's serving posture: a pure consumer (`from_connection`)
|
||||
never dispatches inbound requests; a reverse-flow initiator needs no
|
||||
serving registry of its own.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- **OQ-TN-12**: hub re-produce composition (deferred — a hub proxy
|
||||
- **OQ-TN-12**: hub re-produce composition (deferred; a hub proxy
|
||||
re-exporting a consumed resource would add a
|
||||
`register_openable_with_establisher` whose establisher opens a
|
||||
consumer channel; no concrete consumer yet. The re-produce policy
|
||||
sentence is pinned: the hub's ACL governs the final dial — ADR-006
|
||||
sentence is pinned: the hub's ACL governs the final dial, per ADR-006
|
||||
Amendment 1)
|
||||
- **OQ-TN-13**: UDP truncation (resolved — fail-loud, ADR-003)
|
||||
- **OQ-TN-15/16**: resolved (ADR-007/008 — `open_direct`,
|
||||
- **OQ-TN-13**: UDP truncation (resolved, fail-loud, ADR-003)
|
||||
- **OQ-TN-15/16**: resolved (ADR-007/008: `open_direct`,
|
||||
`TunnelListener`, the forwarded op)
|
||||
|
||||
## References
|
||||
@@ -232,8 +232,8 @@ serving registry of its own.
|
||||
`ReverseTunnel` open_and_pump / close / join_and_reap; W3, W4)
|
||||
- `docs/research/tunnels-graduation.md` (the graduation research)
|
||||
- Upstream asks (review 008): `alkcall/docs/reviews/
|
||||
008-tunnels-graduation-upstream-review.md` — landed in alkcall
|
||||
008-tunnels-graduation-upstream-review.md`; landed in alkcall
|
||||
0.8.0
|
||||
- alkcall ADR-047 §5 (allocation — the initiator adopts), ADR-050
|
||||
- alkcall ADR-047 §5 (allocation; the initiator adopts), ADR-050
|
||||
(`pump_bidi`), ledger
|
||||
CF-005/CF-006 (identity on the reverse path)
|
||||
@@ -1,8 +1,8 @@
|
||||
# ADR-001: Open-Op Params Layout — Resource + Substrate
|
||||
# ADR-001: Open-Op Params Layout (Resource + Substrate)
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (2026-09-07); amended 2026-09-16 (params layout is per-op —
|
||||
Accepted (2026-09-07); amended 2026-09-16 (params layout is per-op:
|
||||
the direct op pins its own params, see Amendment 1)
|
||||
|
||||
## Context
|
||||
@@ -13,7 +13,7 @@ carrier of "what to open." alknet ADR-071 §ALPN table noted
|
||||
`alknet/tunnel` as `[0, 1]` data in/out only; the addressing scheme
|
||||
was never decided there. Phase 0 resolved the framing of the problem
|
||||
(the OQ-TN-01 reframe, 2026-09-05): the rich "remote addressing"
|
||||
framing was an XY problem — except in the `-D`/dynamic case (which
|
||||
framing was an XY problem. 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.
|
||||
@@ -21,7 +21,7 @@ general-purpose address.
|
||||
Constraints that shaped the decision:
|
||||
|
||||
- `params` is ALPN-specific JSON, interpreted by the open op's
|
||||
establisher/handler — never by the channels layer (alkcall
|
||||
establisher/handler, and never by the channels layer (alkcall
|
||||
ADR-039). The layout is a one-way door once a consumer exists
|
||||
(wire-stable once published; additive changes only).
|
||||
- The identifier must be substrate-extensible without format changes
|
||||
@@ -34,7 +34,7 @@ Constraints that shaped the decision:
|
||||
address. A `host:port` in params would leak the producer's
|
||||
topology into the wire and pin the wire to address-shaped targets.
|
||||
- alkcall's registry schema-validates the open op's input
|
||||
(`input_schema` runs before the establisher — review 007 Part B),
|
||||
(`input_schema` runs before the establisher, per review 007 Part B),
|
||||
so the layout must be schema-describable (no per-substrate
|
||||
polymorphic payloads in v1).
|
||||
- Both POCs used exactly this shape (`TunnelParams {resource,
|
||||
@@ -52,21 +52,21 @@ JSON object:
|
||||
}
|
||||
```
|
||||
|
||||
- **`resource`** (string, required) — the produced resource
|
||||
- **`resource`** (string, required): the produced resource
|
||||
identifier: the producer's stable name for the tunnel target. NOT
|
||||
an address; the producer's registry (an assembly-side construct,
|
||||
OQ-TN-11) maps the name to its backing (a local port, a docker
|
||||
container's port, an in-process service, a unix socket path).
|
||||
- **`substrate`** (string, required) — the extensible discriminator:
|
||||
- **`substrate`** (string, required): the extensible discriminator:
|
||||
`"tcp"` | `"udp"` | `"unix"` in v1. A new substrate is a new value,
|
||||
not a format change — the same additive property SSH gets from
|
||||
not a format change, the same additive property SSH gets from
|
||||
channel-type strings and SOCKS5 gets from ATYP values. The
|
||||
discriminator also selects the data-plane framing (ADR-003): the
|
||||
establisher and both pumps must agree on it, so it rides the open
|
||||
op rather than being inferred.
|
||||
- The open op's `input_schema` pins both fields as required strings;
|
||||
`substrate` is enum-validated against the v1 set. Unknown
|
||||
substrate values are a schema failure (typed open error) — a
|
||||
substrate values are a schema failure (typed open error): a
|
||||
consumer speaking a newer substrate to an older producer fails
|
||||
loudly, not silently.
|
||||
- Substrate-specific detail (the path of a unix socket, the UDP
|
||||
@@ -75,12 +75,12 @@ JSON object:
|
||||
- The open-failure error path rides alkcall ADR-049 (typed
|
||||
`channel:open_failed` with `details.reason` ∈ `dial_failed` /
|
||||
`unknown_resource` / `resource_shortage` / `handler_error` /
|
||||
`timeout`): an unknown resource name is `unknown_resource` — no
|
||||
`timeout`): an unknown resource name is `unknown_resource`; there is no
|
||||
params-level error field, no establishment frame (the pre-ADR-049
|
||||
OQ-TN-09 hunch is superseded).
|
||||
|
||||
Rich in-band addressing (SOCKS5 ATYP, per-datagram remote addresses)
|
||||
enters only through the `-D`/dynamic-target composition path — it is
|
||||
enters only through the `-D`/dynamic-target composition path; it is
|
||||
carried INSIDE the tunnel payload by whatever protocol the tunnel
|
||||
carries (socks5, udpgw-style framing), never in base params.
|
||||
|
||||
@@ -90,7 +90,7 @@ carries (socks5, udpgw-style framing), never in base params.
|
||||
renames are a breaking wire change (do not). Adding an optional
|
||||
field is additive-safe; adding a `substrate` value is
|
||||
consumer-progressive (an older producer rejects with
|
||||
`invalid_input`-class schema error — the SSH "unknown channel type"
|
||||
`invalid_input`-class schema error, in the SSH "unknown channel type"
|
||||
posture, loud not silent).
|
||||
- **The BAST question is moot for params.** Params is JSON inside the
|
||||
channels open op (which is already a JSON envelope); the data
|
||||
@@ -101,7 +101,7 @@ carries (socks5, udpgw-style framing), never in base params.
|
||||
- **Producers own a naming discipline.** Resource ids are
|
||||
producer-scoped names (OQ-TN-11: collision domain = the producer's
|
||||
registry). Consumers learn available resources through the
|
||||
ACL-filtered ops listing (OQ-TN-08) — the spec's
|
||||
ACL-filtered ops listing (OQ-TN-08), so the spec's
|
||||
`OperationSpec.description` SHOULD carry a human-readable hint
|
||||
(e.g. "postgres on the worker's tailnet") since it round-trips
|
||||
through discovery.
|
||||
@@ -125,10 +125,10 @@ carries (socks5, udpgw-style framing), never in base params.
|
||||
The `{resource, substrate}` layout pins the `channels/tunnel/sub` open
|
||||
op. It is **per-op, not per-ALPN**: ADR-007 adds the
|
||||
`channels/tunnel/direct` op on the same `alk/tunnel` ALPN with its own
|
||||
params (`{substrate, target}` — a dynamic target, a different
|
||||
capability with its own scope; the resource-not-address invariant is
|
||||
unchanged because a dynamic target is not a produced resource). The
|
||||
per-op reading was always latent in the layout's basis — params are
|
||||
params (`{substrate, target}`, i.e. a dynamic target, a different
|
||||
capability with its own scope; the resource-not-address invariant is
|
||||
unchanged because a dynamic target is not a produced resource). The
|
||||
per-op reading was always latent in the layout's basis, since params are
|
||||
"the `input` of the operation" (alkcall ADR-047), and ops are
|
||||
per-flavor once flavor-form op ids exist (ADR-002 Amendment 1).
|
||||
Additive-only evolution per op stands: this amendment adds nothing to
|
||||
|
||||
@@ -25,7 +25,7 @@ enum) and the ALPN namespace (a published string is wire-stable).
|
||||
|
||||
Prior art surveyed in Phase 0 (`ssh-socks5-survey.md`):
|
||||
|
||||
- SSH uses ONE channel mechanism for all forwarding flavors — the
|
||||
- SSH uses ONE channel mechanism for all forwarding flavors; the
|
||||
channel type string (`direct-tcpip`, `forwarded-tcpip`,
|
||||
`direct-streamlocal`) is per-open metadata on one transport, not a
|
||||
separate transport per flavor.
|
||||
@@ -35,7 +35,7 @@ Prior art surveyed in Phase 0 (`ssh-socks5-survey.md`):
|
||||
stream (length-framed datagrams inside a TCP tunnel).
|
||||
- With OQ-TN-02 resolved as endpoint-at-open for base UDP resources,
|
||||
the substrate discriminator in `params` tells the establisher and
|
||||
pumps which framing to expect — exactly option A's shape. The
|
||||
pumps which framing to expect, exactly option A's shape. The
|
||||
discriminator is load-bearing (it selects the framing, ADR-003),
|
||||
so it must ride the open op; the ALPN carries no substrate
|
||||
information at all.
|
||||
@@ -49,16 +49,16 @@ pump handler, and consumer session type are shared across substrates
|
||||
session types).
|
||||
|
||||
- The open op id is `channels/tunnel/sub` (the `channels/<alpn>/sub`
|
||||
convention of alkcall ADR-047 — the open op is a `Sub`-typed
|
||||
convention of alkcall ADR-047 (the open op is a `Sub`-typed
|
||||
operation: the reply carries `channel_id` once, the data plane
|
||||
flows on the channel's `BiStream`).
|
||||
- The channel's ALPN marker on the open-op spec is `alk/tunnel`
|
||||
(`ChannelOpenSpec::new("alk/tunnel")`) — the value the
|
||||
(`ChannelOpenSpec::new("alk/tunnel")`, which is the value the
|
||||
per-ALPN dispatch and the adopted-side manager record for
|
||||
observability.
|
||||
- If a future tunnel flavor needs structurally different framing from
|
||||
byte zero with no params-dependent dispatch, that is a NEW ALPN
|
||||
decided by a new ADR before its first consumer — never a change to
|
||||
decided by a new ADR before its first consumer, and never a change to
|
||||
`alk/tunnel`'s meaning. ALPN strings are wire-stable once
|
||||
published.
|
||||
|
||||
@@ -71,12 +71,12 @@ session types).
|
||||
`(resource, substrate)`.
|
||||
- **Framing is params-dependent, not ALPN-dependent.** The establisher
|
||||
validates `substrate` semantically (the registry lookup); the pump
|
||||
handler is framing-agnostic (raw pass-through halves — the codec
|
||||
handler is framing-agnostic (raw pass-through halves; the codec
|
||||
lives on the consumer/producer edge for UDP, ADR-003). A mismatched
|
||||
`substrate` value fails at schema/establisher time, loudly.
|
||||
- **ALPN namespace hygiene:** `alk/tunnel` is published here. The
|
||||
`alknet/tunnel` spelling in the alknet ADR-071 table predates the
|
||||
`alknet/` → `alk/` prefix swap (alkcall v0.1.1) — docs and code
|
||||
`alknet/` → `alk/` prefix swap (alkcall v0.1.1), so docs and code
|
||||
must not perpetuate the old prefix.
|
||||
|
||||
## References
|
||||
@@ -88,20 +88,20 @@ session types).
|
||||
|
||||
## Amendment 1 (flavor-form op ids, 2026-09-16)
|
||||
|
||||
The decision stands — one ALPN, no ALPN split per flavor. What
|
||||
The decision stands: one ALPN, no ALPN split per flavor. What
|
||||
changes: the one-ALPN rule is realized as **one ALPN, several
|
||||
`Sub`-typed open ops**, because ADR-007 (the direct op) and ADR-008
|
||||
(the forwarded op) are different capabilities sharing the tunnel
|
||||
transport — ssh's one-transport-many-channel-types shape.
|
||||
transport, ssh's one-transport-many-channel-types shape.
|
||||
|
||||
- The op-id convention extends to flavor form: `channels/<alpn>/sub`
|
||||
(the original resource op, reserved and unchanged),
|
||||
`channels/<alpn>/direct` (dial an arbitrary target), and
|
||||
`channels/<alpn>/forwarded` (accept-as-open toward the listen
|
||||
opener). New flavors are new op ids — additive; the `…/sub` op is
|
||||
opener). New flavors are new op ids, which is additive; the `…/sub` op is
|
||||
never reused for a different meaning.
|
||||
- The alternative — sibling ALPNs (`alk/tunnel-direct`,
|
||||
`alk/tunnel-forwarded`) with standard `…/sub` op ids — was rejected:
|
||||
- The alternative, sibling ALPNs (`alk/tunnel-direct`,
|
||||
`alk/tunnel-forwarded`) with standard `…/sub` op ids, was rejected:
|
||||
it fragments the tunnel namespace per flavor, forces hub relay to
|
||||
track three ALPNs, and amends this ADR's own new-ALPN justification
|
||||
clause (the flavors share framing; none needs structurally different
|
||||
@@ -109,7 +109,7 @@ transport — ssh's one-transport-many-channel-types shape.
|
||||
- Upstream coordination: alkcall ADR-047 pins discovery's ALPN
|
||||
derivation to exactly the `…/sub`/`…/pub` suffix shapes
|
||||
(`derive_alpn_from_op_name`); flavor-form op ids need that
|
||||
derivation (and the hub-relay reconstruction riding it) extended —
|
||||
derivation (and the hub-relay reconstruction riding it) extended; it was
|
||||
filed upstream
|
||||
(`alkcall/docs/reviews/008-tunnels-graduation-upstream-review.md`,
|
||||
ask #1) before the first consumer. Runtime was never blocked:
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# ADR-003: Data-Plane Codec — Raw Pass-Through (Stream) / Mandatory Length Framing (UDP)
|
||||
# ADR-003: Data-Plane Codec, Raw Pass-Through (Stream) / Mandatory Length Framing (UDP)
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (2026-09-07; supersedes the Phase 0 "codec direction" hunch
|
||||
by making the UDP framing mandatory — reverse POC finding F-2).
|
||||
by making the UDP framing mandatory, per reverse POC finding F-2).
|
||||
Amended (2026-09-08, review 001 U-1 remediation): the substrate-
|
||||
boundary placement is pinned mechanically — the `local` feature's
|
||||
boundary placement is pinned mechanically, so the `local` feature's
|
||||
`UdpHalf` composes the codec at the boundary (channel side framed,
|
||||
target side raw), with the pump-window and recv-scratch disciplines
|
||||
the composition requires. See §Amendment (2026-09-08).
|
||||
@@ -14,10 +14,10 @@ the composition requires. See §Amendment (2026-09-08).
|
||||
|
||||
The tunnel data plane rides a channels data channel: raw bytes inside
|
||||
the channel `BiStream` (the channels layer strips its 8-byte header
|
||||
transparently — alknet ADR-093/ADR-071; AGENTS.md convention 5). The
|
||||
transparently, per alknet ADR-093/ADR-071 and AGENTS.md convention 5). The
|
||||
tunnel protocol owns whatever framing it puts inside the `BiStream`.
|
||||
A tunnel has one data stream per direction, so there is no
|
||||
sub-demux key — a 5-byte header like alktty's (with `stream_type`)
|
||||
sub-demux key (a 5-byte header like alktty's, with `stream_type`,
|
||||
would be pure overhead here.
|
||||
|
||||
The Phase 0 direction (`phase-0-findings.md` checklist, 2026-09-06):
|
||||
@@ -29,14 +29,14 @@ Two validation passes sharpened this:
|
||||
|
||||
1. **Forward POC (2026-09-06, 17 tests):** the codec round-trips
|
||||
datagrams split across chunks, batched in one chunk, partial
|
||||
headers, and empty datagrams (`len=0` is a legal payload, NOT EOF
|
||||
— EOF is the channels-level `length=0` chunk sentinel; the two
|
||||
headers, and empty datagrams (`len=0` is a legal payload, NOT EOF;
|
||||
EOF is the channels-level `length=0` chunk sentinel; the two
|
||||
coexist at different layers). The 1400-byte (max ethernet MTU
|
||||
payload) datagram rides the bounded-buffer path; >65535 is
|
||||
rejected at frame time.
|
||||
2. **Reverse POC finding F-2 (2026-09-07, executable-pinned):** in
|
||||
the RAW pass-through pump shape, an empty UDP datagram is a
|
||||
zero-byte read from the substrate adapter — `tokio::io::copy`
|
||||
zero-byte read from the substrate adapter, and `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 test pins the non-round-trip so the
|
||||
@@ -49,7 +49,7 @@ The data plane inside a `alk/tunnel` channel is:
|
||||
- **Stream substrates (`tcp`, `unix`): raw pass-through.** The halves
|
||||
are the tunnel; zero tunnel-level framing. The only wire overhead
|
||||
is the channels 8-byte chunk header. A zero-length read on a stream
|
||||
substrate is genuinely EOF (stream semantics) — no collision. Both
|
||||
substrate is genuinely EOF (stream semantics), so there is no collision. Both
|
||||
pumps run `alkcall::channels::pump_bidi` (ADR-050) over the raw
|
||||
halves.
|
||||
- **Datagram substrate (`udp`): mandatory `[len: u16 BE][datagram]`
|
||||
@@ -57,7 +57,7 @@ The data plane inside a `alk/tunnel` channel is:
|
||||
length-prefixed frame, on BOTH directions:
|
||||
- The **establisher** wraps the dialed UDP socket in the framed
|
||||
adapter BEFORE returning the plan (the plan payload is the
|
||||
adapter, not a raw socket) — the pump handler stays
|
||||
adapter, not a raw socket), so the pump handler stays
|
||||
substrate-agnostic (ADR-004's invariant; producer.md's placement).
|
||||
- Consumer side: the `TunnelSession`'s datagram variant does the
|
||||
same over the adopted halves.
|
||||
@@ -66,29 +66,29 @@ The data plane inside a `alk/tunnel` channel is:
|
||||
POC). EOF is exclusively the channels-level sentinel; the codec
|
||||
never emits zero-length reads, so the layers never collide. This
|
||||
is the load-bearing property F-2 validated from a second angle:
|
||||
the length prefix makes an empty datagram two bytes — unambiguous
|
||||
the length prefix makes an empty datagram two bytes, which is unambiguous
|
||||
with EOF.
|
||||
- **MTU discipline:** the codec rejects framing a >65535-byte
|
||||
datagram (`Oversize` error at frame time, never a wire overflow —
|
||||
datagram (`Oversize` error at frame time, never a wire overflow;
|
||||
the u16 length field would wrap). A truncated receive (caller
|
||||
buffer smaller than the datagram) fails loudly at the adapter
|
||||
level — resolved 2026-09-07 (OQ-TN-13, fail-loud): silent
|
||||
level, resolved 2026-09-07 (OQ-TN-13, fail-loud): silent
|
||||
truncation would corrupt the framing invariants (the same
|
||||
invariant class F-2 protects).
|
||||
- **Chunking is transparent.** Datagrams split across channel chunks,
|
||||
batch into single chunks, and survive partial headers — the
|
||||
batch into single chunks, and survive partial headers; the
|
||||
incremental decoder is the only consumer-visible decode path
|
||||
(forward POC: maximally awkward 7-byte chunk splits verified).
|
||||
|
||||
## Consequences
|
||||
|
||||
### Amendment (2026-09-08 — the boundary mechanics, from review 001 U-1's remediation)
|
||||
### Amendment (2026-09-08, the boundary mechanics from review 001 U-1's remediation)
|
||||
|
||||
The placement sentence ("the establisher wraps the dialed UDP socket
|
||||
in the framed adapter before returning the plan") leaves the adapter's
|
||||
*mechanics* open; review 001 found `UdpHalf` shipping as a raw
|
||||
boundary-preserving adapter (the codec on one end only — real targets
|
||||
saw the tunnel's length prefixes; empty datagrams collapsed to EOF at
|
||||
boundary-preserving adapter (the codec on one end only, so real targets
|
||||
saw the tunnel's length prefixes; empty datagrams collapsed to EOF at
|
||||
the pump's zero-read check; >8 KiB datagrams split at the copy
|
||||
buffer). The remediation pins the mechanics:
|
||||
|
||||
@@ -98,29 +98,29 @@ buffer). The remediation pins the mechanics:
|
||||
sends each decoded payload as one raw datagram; `poll_read` **frames**
|
||||
(`frame_datagram`) each raw target datagram and hands frame bytes to
|
||||
the pump. "Frame on write, de-frame on read" describes the TARGET's
|
||||
face and is the inverted (wrong) reading — the review's original
|
||||
face and is the inverted (wrong) reading; the review's original
|
||||
fix sketch had it inverted and was corrected in re-validation.
|
||||
- **The write half accepts all offered bytes and reports them
|
||||
consumed.** The pump's copy engine re-offers a *full* window
|
||||
unsatisfied until it drains (tokio `copy`'s `read_done` path never
|
||||
tops the window up), so a partial-frame tail could sit forever in a
|
||||
full window if the adapter accepted only complete frames — the
|
||||
full window if the adapter accepted only complete frames; the
|
||||
>8 KiB frame would deadlock the pump. Completed datagrams queue for
|
||||
the socket; a trailing partial frame waits in the `DatagramReader`
|
||||
(its state is bounded by construction — the u16 cap, review 001
|
||||
(its state is bounded by construction, by the u16 cap, review 001
|
||||
N-2). Backpressure is an accepted-byte high-water mark on
|
||||
`poll_write`, and the socket drain pumps opportunistically on
|
||||
write/flush/shutdown (tokio's copy polls flush after every window
|
||||
drain).
|
||||
- **The recv scratch is full-size (65535) and lazy.** `try_recv`
|
||||
truncates SILENTLY at the buffer size — a smaller scratch corrupts
|
||||
truncates SILENTLY at the buffer size, and a smaller scratch corrupts
|
||||
the framing before any size check could reject it (the review's
|
||||
"size to the MTU" suggestion is unsafe as written). The 64 KiB
|
||||
eager allocation is instead lazy (first recv), the U-3 mitigation.
|
||||
- **A >caller-buffer frame is staged, not an error.** The frame's
|
||||
length prefix reaches the pump on the first read; the remainder
|
||||
hands out across reads. The pump's far-side de-framer reassembles
|
||||
the true length — the raw adapter's read-level `InvalidData` is
|
||||
the true length; the raw adapter's read-level `InvalidData` is
|
||||
replaced by staging (the fail-loud posture now protects the *frame
|
||||
stream*, and a wire-level over-bound datagram remains a loud
|
||||
`Oversize`-class error at the frame path).
|
||||
@@ -131,9 +131,9 @@ recording-target suite).
|
||||
|
||||
## Consequences (original)
|
||||
|
||||
- **TCP/unix tunnels have 0 B tunnel overhead** — the SSH
|
||||
- **TCP/unix tunnels have 0 B tunnel overhead**, matching the SSH
|
||||
`direct-tcpip` experience (the payload is the bytes).
|
||||
- **UDP correctness requires the codec** — raw pass-through over UDP
|
||||
- **UDP correctness requires the codec**, since raw pass-through over UDP
|
||||
is structurally broken for empty datagrams (F-2). The substrate
|
||||
discriminator (ADR-001) is what tells both sides to engage the
|
||||
codec; a consumer bypassing it for UDP is a spec violation, not a
|
||||
@@ -141,8 +141,8 @@ recording-target suite).
|
||||
- **`tokio::io::copy`-shaped pumps fit UDP only through the framed
|
||||
adapter** (the `UdpHalf` + codec wrapper presents
|
||||
`AsyncRead`/`AsyncWrite` whose zero-length read never occurs for a
|
||||
well-formed stream — `len=0` yields a 2-byte wire frame).
|
||||
- **The BAST document exists** (AGENTS.md convention 12 — the
|
||||
well-formed stream; `len=0` yields a 2-byte wire frame).
|
||||
- **The BAST document exists** (AGENTS.md convention 12; the
|
||||
mandatory framing IS binary framing beyond pass-through, so the
|
||||
trigger fired): `docs/architecture/bast.md` describes the
|
||||
`[len: u16 BE]` datagram frame per the BAST meta-schema; the
|
||||
@@ -160,6 +160,6 @@ recording-target suite).
|
||||
sentinel layering + MTU); reverse POC:
|
||||
`docs/research/reverse-poc-summary.md` §F-2 (the mandate)
|
||||
- `docs/research/ssh-socks5-survey.md` §UDP (tun2proxy udpgw framing
|
||||
precedent — no CONN_ID, no FLAGS; boundary framing only)
|
||||
precedent (no CONN_ID, no FLAGS; boundary framing only)
|
||||
- alknet ADR-071/093 (channels wire format; the zero-length sentinel)
|
||||
- alkcall `channels::pump_bidi` (ADR-050)
|
||||
@@ -1,4 +1,4 @@
|
||||
# ADR-004: No TunnelBackend Trait — Halves Functions at the Assembly Layer
|
||||
# ADR-004: No TunnelBackend Trait (Halves Functions at the Assembly Layer)
|
||||
|
||||
## Status
|
||||
|
||||
@@ -8,8 +8,8 @@ re-produce thread)
|
||||
## Context
|
||||
|
||||
alktty has a `TtyBackend` trait because backends (local PTY, docker,
|
||||
SSH) produce complex handles — `TtyHandle` carries
|
||||
stdin/stdout/stderr/exit-code quadruple + control — and the adapter
|
||||
SSH) produce complex handles: `TtyHandle` carries
|
||||
stdin/stdout/stderr/exit-code quadruple + control, and the adapter
|
||||
pumps them. The Phase 0 question (OQ-TN-05): does alktunnels need a
|
||||
`TunnelBackend` trait, or is the two-pump handler + feature-gated
|
||||
substrate modules the whole story?
|
||||
@@ -24,7 +24,7 @@ backend's:
|
||||
connection is the same boxed-halves shape.
|
||||
|
||||
The UDP POC gave the executable answer for dial flows (2026-09-06):
|
||||
`pump_halves` is generic over boxed `(AsyncRead, AsyncWrite)` halves —
|
||||
`pump_halves` is generic over boxed `(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.
|
||||
@@ -33,7 +33,7 @@ The one remaining thread was the hub re-produce composition (does a
|
||||
hub proxy that re-produces a consumed resource need a trait or a
|
||||
composition helper?). The reverse POC (2026-09-07) resolved the
|
||||
mechanics: a hub proxy is a consumer + producer composed at the
|
||||
assembly layer — the reverse POC's hub is exactly this shape (it
|
||||
assembly layer; the reverse POC's hub is exactly this shape (it
|
||||
consumes a channels connection and produces reverse-tunnel opens
|
||||
toward the worker); re-exporting (registering an openable for a
|
||||
resource the hub itself consumes) would add a
|
||||
@@ -51,7 +51,7 @@ assembly layer:
|
||||
|
||||
- **The produce function.** The producer's establisher resolves
|
||||
`(resource, substrate)` → boxed halves
|
||||
(`Box<dyn AsyncRead + Send + Sync + Unpin>` ×2 — the `+ Sync` is
|
||||
(`Box<dyn AsyncRead + Send + Sync + Unpin>` ×2; the `+ Sync` is
|
||||
the `ChannelPlan` bound, reverse POC F-1) or a framed UDP adapter.
|
||||
The dial itself is substrate code living in feature-gated backend
|
||||
modules (`local` feature: real sockets), injected at assembly:
|
||||
@@ -64,12 +64,12 @@ assembly layer:
|
||||
open op; its establisher pops the next accepted connection from an
|
||||
assembly-owned listener queue and returns it as the plan payload.
|
||||
The listener lifecycle (bind, accept loop, cancel) is assembly
|
||||
code — the protocol never binds (OQ-TN-04). The reverse POC's
|
||||
code; the protocol never binds (OQ-TN-04). The reverse POC's
|
||||
worker (dial) and the forward POC's producer (dial) both rode this
|
||||
shape; the listen establisher is the same plan-flow with a
|
||||
different halves source.
|
||||
- **The pump handler is substrate-agnostic by construction:**
|
||||
`pump_bidi(bidi, t_read, t_write)` over the downcast plan payload —
|
||||
`pump_bidi(bidi, t_read, t_write)` over the downcast plan payload;
|
||||
it cannot know whether the halves came from TCP, UDP, a unix
|
||||
socket, or an in-process pipe, and must not.
|
||||
- **Datagram substrates** contribute a framed adapter (ADR-003)
|
||||
@@ -78,9 +78,9 @@ assembly layer:
|
||||
every assembly layer re-implements the same glue (registry + dial +
|
||||
establisher construction + pump wiring) three or more times, extract
|
||||
a `TunnelBackend`-shaped trait THEN (the alknet ADR-078
|
||||
convergence-test threshold — extract when the shapes have converged
|
||||
convergence-test threshold: extract when the shapes have converged
|
||||
across consumers, not before)
|
||||
— as an additive layer, not a v1 wire/ABI commitment. Until then
|
||||
and do it as an additive layer, not a v1 wire/ABI commitment. Until then
|
||||
the function-plus-closure shape keeps the crate dependency-light
|
||||
and wasm-clean.
|
||||
|
||||
@@ -89,14 +89,14 @@ assembly layer:
|
||||
- **The default crate stays wasm-clean** with no socket/platform
|
||||
deps; `local` (sockets) and future backends (docker, process) are
|
||||
feature-gated modules implementing dial/listen functions.
|
||||
- **Assembly layers write more glue than a trait would save** — the
|
||||
accepted cost, validated twice (both POCs' producers are ~200 lines
|
||||
- **Assembly layers write more glue than a trait would save**, which is
|
||||
the accepted cost, validated twice (both POCs' producers are ~200 lines
|
||||
including the UDP adapter).
|
||||
- **Plan payloads are `Send + Sync`** (reverse POC F-1, documented on
|
||||
alkcall's `ChannelPlan` type in 0.7.0): socket-backed handles carry
|
||||
`+ Sync` naturally; non-Sync handles (process pipes as boxed trait
|
||||
objects) need a wrapper. The spec documents the constraint.
|
||||
- **The listen variant needs no new wire surface** — same open op,
|
||||
- **The listen variant needs no new wire surface**: same open op,
|
||||
same params, same typed errors (an empty listener queue during
|
||||
establishment is `resource_shortage`; a closed listener is
|
||||
`dial_failed`-class).
|
||||
@@ -108,8 +108,8 @@ assembly layer:
|
||||
- Forward POC: `docs/research/poc-summary.md` §5 (halves-not-trait);
|
||||
reverse POC: `docs/research/reverse-poc-summary.md` (the hub shape,
|
||||
F-1)
|
||||
- alktty ADR-002 (`TtyBackend` — the contrast case: when a trait IS
|
||||
warranted), alktty ADR-003 (backend placement — the feature-gate
|
||||
- alktty ADR-002 (`TtyBackend`, the contrast case: when a trait IS
|
||||
warranted), alktty ADR-003 (backend placement, the feature-gate
|
||||
pattern)
|
||||
- AGENTS.md conventions 4/7/14 (wasm-clean, substrate-agnostic,
|
||||
feature flags)
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Accepted (2026-09-07; resolves the reverse POC W3 finding).
|
||||
Amended (2026-09-08, review 001 N-3 decision): the datagram
|
||||
`take_halves` posture is pinned — raw framed halves stay the only
|
||||
`take_halves` posture is pinned: raw framed halves stay the only
|
||||
owned-halves shape (`take_halves` keeps its semantics for every
|
||||
substrate), the datagram accessors remain the codec path, and a
|
||||
framed-halves convenience is deferred to the first consumer that
|
||||
@@ -17,23 +17,23 @@ A tunnel channel's two ends have asymmetric lifecycle machinery:
|
||||
- **The serving side (the side the open op ran on)** is
|
||||
wrapper-managed: the open wrapper awaits the pump handler's
|
||||
`JoinHandle`, and its completion triggers channel teardown (drop of
|
||||
the demux sender = EOF to the handler's read half — alkcall ADR-049
|
||||
the demux sender = EOF to the handler's read half, per alkcall ADR-049
|
||||
+ review 007 R-02). Out-of-band `channel/close` also tears it down.
|
||||
Nothing leaks.
|
||||
- **The adopting side (the side that called the open op and adopted
|
||||
the channel ID — the consumer)** has NO such machinery:
|
||||
the channel ID, i.e. the consumer)** has NO such machinery:
|
||||
`ChannelManager::adopt_channel` installs routing state nothing
|
||||
awaits. The consumer-side pump (`pump_bidi` spawned locally) is
|
||||
somebody's `JoinHandle`; the adopted channel state is nobody's to
|
||||
reap. If the assembly layer drops both, the channel entry leaks in
|
||||
the manager until transport EOF (`clear_all`) — and worse, a
|
||||
the manager until transport EOF (`clear_all`); and worse, a
|
||||
consumer that drops the pump handle mid-flight aborts the pump
|
||||
without reaping, leaving half-open state on the peer.
|
||||
|
||||
The reverse POC found this by construction (W3, 2026-09-07): the hub
|
||||
must hold its pump handle and reap (`teardown_channel`) itself —
|
||||
must hold its pump handle and reap (`teardown_channel`) itself.
|
||||
`ReverseTunnel::join_and_reap`/`close` in the POC. The lifecycle is
|
||||
assembly-layer by design (OQ-TN-04 — no forced binding means no
|
||||
assembly-layer by design (OQ-TN-04: no forced binding means no
|
||||
protocol-owned listener lifecycle either), but the POC demonstrated
|
||||
that leaving teardown discipline to each assembly layer's memory is
|
||||
exactly the kind of gap the spec must close with a type, not a doc
|
||||
@@ -49,31 +49,31 @@ plane (raw halves vs datagram codec, ADR-003).
|
||||
The consumer half is a typed session, **`TunnelSession`**, and it owns
|
||||
its teardown:
|
||||
|
||||
- **Construction:** `TunnelSession::open(client, params)` — calls the
|
||||
- **Construction:** `TunnelSession::open(client, params)`: calls the
|
||||
open op (`ChannelClient::open_channel` on the forward path; the
|
||||
reverse path's `open_reverse_channel` + `adopt_channel` equivalent),
|
||||
adopts the returned channel ID, splits the channel `BiStream`, and
|
||||
presents the substrate-shaped data plane:
|
||||
- **Stream variant:** raw halves (`AsyncRead`/`AsyncWrite`) — the
|
||||
- **Stream variant:** raw halves (`AsyncRead`/`AsyncWrite`); the
|
||||
halves ARE the tunnel.
|
||||
- **Datagram variant:** `send_datagram`/`recv_datagram` over the
|
||||
mandatory codec (ADR-003) — boundary-preserving, `len=0` is an
|
||||
mandatory codec (ADR-003), which is boundary-preserving; `len=0` is an
|
||||
empty datagram, `recv_datagram` returns `None` only on stream EOF.
|
||||
- **Pump ownership:** the session's `pump_against(accepted_halves)`
|
||||
(the reverse-flow use) spawns `pump_bidi` and holds the returned
|
||||
`JoinHandle`. For the pure consumer (no local pump — the session
|
||||
`JoinHandle`. For the pure consumer (no local pump; the session
|
||||
halves ARE handed to the caller), the session does not spawn; the
|
||||
caller drives the halves and the session still owns the channel
|
||||
entry.
|
||||
- **Teardown API (the point of the ADR):**
|
||||
- `close(self)` — abort the pump (if session-owned), tear down the
|
||||
- `close(self)`: abort the pump (if session-owned), tear down the
|
||||
adopted channel (`teardown_channel`), consume the session. The
|
||||
abort path (ungraceful).
|
||||
- `join(self)` — await pump completion (both directions finished),
|
||||
- `join(self)`: await pump completion (both directions finished),
|
||||
THEN reap the adopted channel, return the `(u64, u64)` copy
|
||||
counts from `pump_bidi` for observability. The graceful path.
|
||||
- Dropping the session without either: the `Drop` impl tears the
|
||||
channel down (the panic-free fallback — never leak the entry).
|
||||
channel down (the panic-free fallback, never leaking the entry).
|
||||
`Drop` cannot await, so it aborts the pump and calls the
|
||||
sync `teardown_channel`; this is semantically `close`.
|
||||
- **Half-open semantics fall out of `pump_bidi`** (ADR-050): one
|
||||
@@ -81,7 +81,7 @@ its teardown:
|
||||
crosses the mux) → the other pump keeps running until its own EOF.
|
||||
The session's `join` completes when BOTH pumps finish. Half-close
|
||||
semantics validated end-to-end (reverse POC W4).
|
||||
- **Error surface:** a failed open resolves a typed error —
|
||||
- **Error surface:** a failed open resolves a typed error:
|
||||
`ChannelOpenError::CallFailed` carrying the wire `CallError`;
|
||||
branch on `establishment_reason()` for `channel:open_failed`'s
|
||||
reason (ADR-049 §4). A failed open never yields a session (no
|
||||
@@ -92,19 +92,19 @@ its teardown:
|
||||
|
||||
## Consequences
|
||||
|
||||
### Amendment (2026-09-08 — the N-3 datagram `take_halves` posture, decided before the first external consumer)
|
||||
### Amendment (2026-09-08, the N-3 datagram `take_halves` posture, decided before the first external consumer)
|
||||
|
||||
Review 001 N-3: a datagram session exposes codec-applied accessors
|
||||
(`send_datagram`/`recv_datagram`) until `take_halves` — then the
|
||||
(`send_datagram`/`recv_datagram`) until `take_halves`, at which point the
|
||||
caller owns RAW halves whose wire bytes carry the `[len: u16 BE]`
|
||||
framing. Documented, but a footgun asymmetry the API window
|
||||
(this ADR's surface becomes ABI-stable at the first external
|
||||
consumer) forced a decision on. Decided: **option 1 — keep as-is,
|
||||
carried by docs**, with the rationale:
|
||||
consumer) forced a decision on. Decided: **option 1 (keep as-is,
|
||||
carried by docs)**, with the rationale:
|
||||
|
||||
- **No external consumer exists** (verified 2026-09-08 — no crate in
|
||||
- **No external consumer exists** (verified 2026-09-08; no crate in
|
||||
the workspace depends on alktunnels); the window is open, so a
|
||||
breaking reshape was *available* — but nothing yet demonstrates a
|
||||
breaking reshape was *available*, but nothing yet demonstrates a
|
||||
need for a different shape.
|
||||
- **The framing asymmetry is ADR-003's spec, not drift.** The
|
||||
datagram data plane is mandatory-framing by substrate
|
||||
@@ -113,23 +113,23 @@ carried by docs**, with the rationale:
|
||||
boundary." `take_halves` yielding raw halves keeps ONE semantic
|
||||
across substrates (the tunnel's wire bytes, uninterpreted); a
|
||||
per-substrate `take_halves` (option 3) diverges semantics by
|
||||
substrate — the worse confusion at the type level even as it
|
||||
substrate (the worse confusion at the type level even as it
|
||||
removes the byte-level footgun.
|
||||
- **`pump_against` needs the framed adapter, not framed halves.** The
|
||||
adjacent seam (N-11, resolved in-review with the doc note): a UDP
|
||||
session pumped against an accepted local half requires the FRAMED
|
||||
adapter on the accepted side — the `local` feature's `UdpHalf` (or
|
||||
adapter on the accepted side, namely the `local` feature's `UdpHalf` (or
|
||||
an assembly equivalent), not a raw socket. What composes there is
|
||||
the adapter AT the boundary, not a session-side wrapper pair; a
|
||||
`take_framed_halves` convenience would not feed this use.
|
||||
- **The convenience is additive and can land later.** Option 2's
|
||||
`take_framed_halves` breaks nothing when added after the first
|
||||
consumer (unlike a `take_halves` reshape), so its timing is
|
||||
decoupled from this window — deferring it costs nothing now, and
|
||||
decoupled from this window, so deferring it costs nothing now, and
|
||||
the first real consumer's reach defines its shape (a
|
||||
`DatagramReader`-wrapped pair has an open design question: an
|
||||
`AsyncRead` face re-collides with the F-2 empty-datagram/EOF
|
||||
ambiguity the codec exists to prevent — the codec-side framing
|
||||
ambiguity the codec exists to prevent: the codec-side framing
|
||||
wants accessor-shaped surface, which the session already has).
|
||||
- **The docs carry the contract.** `take_halves`'s doc states the
|
||||
framing (landed with the implementation); consumer.md's datagram
|
||||
@@ -138,14 +138,14 @@ carried by docs**, with the rationale:
|
||||
**Consequence:** datagram callers wanting codec-applied I/O after
|
||||
taking ownership should keep the session (drive `send_datagram`/
|
||||
`recv_datagram`; the session IS the owner) or compose `UdpHalf` at
|
||||
their own boundary — `take_halves` is the raw-wire escape hatch and
|
||||
their own boundary; `take_halves` is the raw-wire escape hatch and
|
||||
stays documented as such. Revisit only on a demonstrated consumer
|
||||
need (file an OQ at that point).
|
||||
|
||||
### Consequences (original)
|
||||
|
||||
- **Assembly layers cannot leak adopted channels** by forgetting to
|
||||
reap — the type is the discipline (the W3 gap closes structurally).
|
||||
reap; the type is the discipline (the W3 gap closes structurally).
|
||||
- **The reverse-flow initiator gets the same session shape** as the
|
||||
forward consumer: the reverse POC's hub-side `ReverseTunnel` is the
|
||||
seed; the spec generalizes it so `-L` and `-R` consumers share one
|
||||
@@ -156,7 +156,7 @@ need (file an OQ at that point).
|
||||
already wrapper-managed (R-02); the session only fixes the adopter
|
||||
asymmetry.
|
||||
- **`Drop`-based teardown is best-effort** (abort + sync reap, no
|
||||
await) — the documented contract; graceful flows call `join` or
|
||||
await): the documented contract; graceful flows call `join` or
|
||||
`close` explicitly.
|
||||
|
||||
## References
|
||||
@@ -165,7 +165,7 @@ need (file an OQ at that point).
|
||||
finding), §W4 (half-close validation)
|
||||
- OQ-TN-03, OQ-TN-04, OQ-TN-09 (promoted)
|
||||
- alkcall ADR-049 + review 007 R-02 (wrapper-managed serving side),
|
||||
ADR-050 (`pump_bidi` + copy counts), ADR-047 §5 (allocation — the
|
||||
ADR-050 (`pump_bidi` + copy counts), ADR-047 §5 (allocation; the
|
||||
initiator adopts)
|
||||
- AGENTS.md convention 10 (limits inherited — the session adds no
|
||||
- AGENTS.md convention 10 (limits inherited; the session adds no
|
||||
second bookkeeping layer; it owns exactly one channel's lifecycle)
|
||||
@@ -1,8 +1,8 @@
|
||||
# ADR-006: Access-Control Posture — the Open Gate Is the Security Boundary
|
||||
# ADR-006: Access-Control Posture: the Open Gate Is the Security Boundary
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (2026-09-07; records the OQ-TN-08 resolution as a decision —
|
||||
Accepted (2026-09-07; records the OQ-TN-08 resolution as a decision;
|
||||
the posture was resolved in Phase 0 but the consumer-visible constants
|
||||
had no ADR backing); amended 2026-09-16 (scope separation for the
|
||||
direct/forwarded ops + the hub re-produce policy sentence, see
|
||||
@@ -11,7 +11,7 @@ Amendment 1)
|
||||
## Context
|
||||
|
||||
Tunnels reach local networks: opening a tunnel channel instructs the
|
||||
producer to dial (or accept for) a target — potentially anything the
|
||||
producer to dial (or accept for) a target, potentially anything the
|
||||
producer's host can reach. The open gate is therefore the security
|
||||
boundary of this crate, in the same sense that SSH's
|
||||
`AllowTcpForwarding`/`PermitOpen` govern `direct-tcpip` opens. The
|
||||
@@ -20,13 +20,13 @@ this crate vs alkcall's op-level ACL vs the assembly layer.
|
||||
|
||||
Phase 0 resolved the conceptual tangle (2026-09-05, the OQ-TN-08
|
||||
resolution): at protocol-crate level, a produced resource belongs to
|
||||
the far side of the connection — the protocol works under that
|
||||
the far side of the connection, and the protocol works under that
|
||||
assumption, so the ACL story is exactly alkcall's existing op-level
|
||||
ACL. No new policy layer. The hub/overlay mechanism (workers
|
||||
connecting in, the hub re-exposing resources per ITS policy) is a
|
||||
downstream, assembly-layer concern (OQ-TN-12's scope).
|
||||
|
||||
What was left implicit — and needs pinning, because it is
|
||||
What was left implicit, and needs pinning because it is
|
||||
consumer-visible and stable-once-published:
|
||||
|
||||
- The scope constant governing tunnel opens (`tunnel:open`) is
|
||||
@@ -39,10 +39,10 @@ consumer-visible and stable-once-published:
|
||||
## Decision
|
||||
|
||||
1. **The open gate is the boundary, and it is alkcall's ACL.** The
|
||||
open op's `AccessControl` rides `ChannelCore::register_openable` —
|
||||
open op's `AccessControl` rides `ChannelCore::register_openable`;
|
||||
the registry runs the ACL before the establisher. The base gate is
|
||||
the scope constant **`TUNNEL_OPEN_SCOPE = "tunnel:open"`**
|
||||
(required scope, exact string — stable once published; consumers
|
||||
(required scope, exact string; stable once published, and consumers
|
||||
will request grants by it). An identity without the scope is
|
||||
`FORBIDDEN` before any establisher code runs.
|
||||
2. **The scope string is wire/ACL-stable** from the first consumer:
|
||||
@@ -51,7 +51,7 @@ consumer-visible and stable-once-published:
|
||||
3. **No target allowlists and no tunnel-specific policy machinery in
|
||||
v1.** The resource is owned by the far side (the Phase 0
|
||||
resolution); a producer that wants finer granularity wires
|
||||
`OwnershipProvider` checks into ITS establisher — the protocol
|
||||
`OwnershipProvider` checks into ITS establisher; the protocol
|
||||
provides the seam (the establisher sees the per-call opener
|
||||
identity, alkcall 0.7.0 CF-006), not the policy. This mirrors
|
||||
alktty's posture (`TTY_OPEN_SCOPE` + op-level ACL, no
|
||||
@@ -80,17 +80,17 @@ consumer-visible and stable-once-published:
|
||||
## Consequences
|
||||
|
||||
- **One security knob per producer deployment** (the op-level ACL +
|
||||
scope grants) instead of a tunnel-specific policy language — the
|
||||
accepted simplicity; per-resource granularity is an establisher
|
||||
scope grants) instead of a tunnel-specific policy language, which is
|
||||
the accepted simplicity; per-resource granularity is an establisher
|
||||
customization (the seam), not a v1 surface.
|
||||
- **The scope constant is a compatibility commitment:** treat
|
||||
`tunnel:open` like the ALPN string. Changing it after consumers
|
||||
request grants is a breaking ACL change.
|
||||
- **No allowlist concept** — a wildcard-egress producer
|
||||
- **No allowlist concept**: a wildcard-egress producer
|
||||
(`0.0.0.0/0`-style egress) is expressed by registering a resource
|
||||
whose backing is that egress; gating it is the assembly layer's
|
||||
ownership check at the seam. The protocol stays out of the business.
|
||||
- **Tunnels reach local networks** — producers MUST treat the gate as
|
||||
- **Tunnels reach local networks**, so producers MUST treat the gate as
|
||||
the security boundary; the spec docs state this posture (producer.md).
|
||||
|
||||
## References
|
||||
@@ -99,7 +99,7 @@ consumer-visible and stable-once-published:
|
||||
- alkcall ADR-047 (open ops + ACL wiring), ADR-017 §7 (the
|
||||
`auth_token` path), ledger CF-005/CF-006 (identity precedence,
|
||||
per-call opener); alknet ADR-024/ADR-050 (registry layering,
|
||||
ownership — the alkcall ports ADR-019/ADR-011)
|
||||
ownership; the alkcall ports ADR-019/ADR-011)
|
||||
- alktty's `TTY_OPEN_SCOPE` posture (the sibling precedent)
|
||||
- AGENTS.md convention 13 (access control)
|
||||
|
||||
@@ -109,19 +109,19 @@ The posture stands; the scope space grows with the ops (ADR-007,
|
||||
ADR-008):
|
||||
|
||||
- **`TUNNEL_DIRECT_SCOPE = "tunnel:direct"`** gates
|
||||
`channels/tunnel/direct` — dynamic-target egress. **Never implied by
|
||||
`channels/tunnel/direct` for dynamic-target egress. **Never implied by
|
||||
`tunnel:open`**: "dial `postgres-primary`" and "dial anything from
|
||||
my network" are different capabilities, and the separation is
|
||||
structural (scopes attach to op specs, not params). A deployment
|
||||
granting both grants both explicitly.
|
||||
- **`TUNNEL_FORWARDED_SCOPE = "tunnel:forwarded"`** gates
|
||||
`channels/tunnel/forwarded` on the opener side — the grant for
|
||||
`channels/tunnel/forwarded` on the opener side; it is the grant for
|
||||
RECEIVING producer-initiated forwarded connections (ingress).
|
||||
Ingress and egress are different grants (the alksocks POC #5
|
||||
`socks5:bind` gate proved the distinction matters in practice).
|
||||
- **Hub re-produce policy sentence (the security model):** 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
|
||||
re-produce hop; the hub's ACL, not the original opener's, governs
|
||||
the final dial. The hub is the network boundary; the original
|
||||
opener's grant gets it to the hub, the hub's own grant decides what
|
||||
it will dial on the far side. Correct-by-default; pinned here
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# ADR-007: The Direct Open Op — Dynamic-Target Egress
|
||||
# ADR-007: The Direct Open Op (Dynamic-Target Egress)
|
||||
|
||||
## Status
|
||||
|
||||
@@ -11,7 +11,7 @@ the backing; rich addressing rides the `-D`/dynamic composition path,
|
||||
never the wire. That decision stands. The use case that justifies an
|
||||
*additive* revisit is the one the alksocks graduation research
|
||||
(`docs/research/tunnels-graduation.md`) named: a consumer asking the
|
||||
producer to dial an arbitrary target chosen at session time — ssh's
|
||||
producer to dial an arbitrary target chosen at session time, i.e. ssh's
|
||||
`direct-tcpip` shape, SOCKS5 CONNECT's egress, and the vpn-like
|
||||
dial-callback translation all need it. A dynamic target is a different
|
||||
capability from opening a named resource, and ssh carries it as its own
|
||||
@@ -19,7 +19,7 @@ channel type precisely because the two capabilities must not share one
|
||||
grant.
|
||||
|
||||
The capability is dangerous in exactly the way ADR-001's "resource, not
|
||||
address" framing protects against — arbitrary egress from the
|
||||
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" must not be the same grant.
|
||||
@@ -27,7 +27,7 @@ The alksocks POC #5 scope gate (`socks5:bind`) proved the pattern
|
||||
matters in practice.
|
||||
|
||||
Timing: alksocks is the first real consumer and nothing is published
|
||||
against the direct shape yet — this is the correct moment to pin the
|
||||
against the direct shape yet, so this is the correct moment to pin the
|
||||
wire-stable surface.
|
||||
|
||||
## Decision
|
||||
@@ -35,19 +35,19 @@ wire-stable surface.
|
||||
### 1. One ALPN, a second open op
|
||||
|
||||
The direct op rides the same `alk/tunnel` ALPN (ADR-002 stands: one
|
||||
ALPN, one protocol identity — SSH's one-transport-many-channel-types
|
||||
shape). The op id is **`channels/tunnel/direct`** — the flavor-form
|
||||
ALPN, one protocol identity, i.e. SSH's one-transport-many-channel-types
|
||||
shape). The op id is **`channels/tunnel/direct`**, the flavor-form
|
||||
op-id convention (ADR-002 Amendment 1): `channels/<alpn>/<flavor>`,
|
||||
`Sub`-typed, same establishment phase and wrapper as `…/sub`. An older
|
||||
producer rejects the unknown op loudly (registry lookup fails — the
|
||||
producer rejects the unknown op loudly (registry lookup fails, which is the
|
||||
SSH "unknown channel type" posture, `NOT_FOUND`).
|
||||
|
||||
The op-id convention itself needed an upstream alkcall amendment
|
||||
(ADR-047 amendment: discovery's ALPN derivation and the
|
||||
`rebuild_spec_for` marker path recognize flavor-form op ids; hub relay
|
||||
reconstructs them). Runtime was never blocked — registration sets the
|
||||
reconstructs them). Runtime was never blocked, since registration sets the
|
||||
ALPN explicitly and `open_channel(op_id, params, alpn)` takes the ALPN
|
||||
as an argument — but discovery + hub relay are the re-produce path
|
||||
as an argument; discovery + hub relay are the re-produce path
|
||||
(alknodes, OQ-TN-12), so the convention is pinned upstream before the
|
||||
first consumer. **Landed in alkcall 0.8.0** (review 008 U-1, ADR-047
|
||||
amendment 3).
|
||||
@@ -61,27 +61,27 @@ amendment 3).
|
||||
}
|
||||
```
|
||||
|
||||
- **`substrate`** (string, required) — same discriminator semantics as
|
||||
- **`substrate`** (string, required): same discriminator semantics as
|
||||
ADR-001: `"tcp"` | `"udp"` | `"unix"` in v1; selects the data-plane
|
||||
framing (ADR-003). Enum-validated; unknown values fail schema
|
||||
validation loudly.
|
||||
- **`target`** (object, required) — a self-describing JSON object keyed
|
||||
- **`target`** (object, required): a self-describing JSON object keyed
|
||||
by the sibling `substrate` field:
|
||||
- `tcp` → `{ "host": <string>, "port": <u16> }` — `host` is a
|
||||
- `tcp` → `{ "host": <string>, "port": <u16> }`, where `host` is a
|
||||
hostname, IPv4/IPv6 literal, or the JSON form of
|
||||
`SocketAddr`-shaped values; **domain-form targets stay unresolved
|
||||
on the wire** (producer-side resolution — the ssh `-D` semantic;
|
||||
on the wire** (producer-side resolution, the ssh `-D` semantic;
|
||||
both POCs validated egress-side resolution). No DNS dependency on
|
||||
the consumer.
|
||||
- `udp` → `{ "host": <string>, "port": <u16> }` — the connected-UDP
|
||||
- `udp` → `{ "host": <string>, "port": <u16> }`: the connected-UDP
|
||||
destination (endpoint-at-open; ADR-003 framing applies as for base
|
||||
udp).
|
||||
- `unix` → `{ "path": <string> }` — the socket path on the
|
||||
- `unix` → `{ "path": <string> }`: the socket path on the
|
||||
producer's filesystem.
|
||||
- The schema pins both fields required; the target sub-schema is
|
||||
per-substrate (an `allOf` of two `if`/`then` branches keyed on the
|
||||
sibling `substrate` value, validated **registry-side** — the schema
|
||||
gate runs before the establisher; the establisher's serde parse
|
||||
sibling `substrate` value, validated **registry-side**: the schema
|
||||
gate runs before the establisher; the establisher's serde parse
|
||||
re-checks as the backstop; see the Erratum below and Consequences).
|
||||
Malformed targets are the typed `invalid_input`-class failure;
|
||||
**`unknown_resource` can never fire on the direct op** (there is no
|
||||
@@ -97,16 +97,16 @@ amendment 3).
|
||||
*wire* encodes.
|
||||
- **Dial-only: no listen/direct-BIND form.** The reverse shape
|
||||
(`-R`-style ingress) is the listen-tunnel + the forwarded op
|
||||
(ADR-008) — a direct-BIND form would duplicate its capability with
|
||||
(ADR-008); a direct-BIND form would duplicate its capability with
|
||||
none of its metadata fidelity.
|
||||
|
||||
### 3. Scope: `TUNNEL_DIRECT_SCOPE = "tunnel:direct"`
|
||||
|
||||
The op's `AccessControl.required_scopes` is `["tunnel:direct"]` — a
|
||||
The op's `AccessControl.required_scopes` is `["tunnel:direct"]`, a
|
||||
separate grant from `tunnel:open`, never implied by it. "Dial
|
||||
`postgres-primary`" and "dial anything from my network" are different
|
||||
capabilities; the ACL separation is structural (scopes attach to op
|
||||
specs, not params — ADR-006 §2's additivity rule). Scope strings are
|
||||
specs, not params, per ADR-006 §2's additivity rule). Scope strings are
|
||||
ACL-stable once published (ADR-006 §2): renames are breaking; new
|
||||
gates are new scopes on new ops.
|
||||
|
||||
@@ -115,13 +115,13 @@ gates are new scopes on new ops.
|
||||
Same establisher contract as the dial shape (producer.md), minus the
|
||||
lookup:
|
||||
|
||||
1. Parse and validate `target` against `substrate` (a mismatch —
|
||||
`unix` without `path`, `tcp` with a bad port — is `invalid_input`
|
||||
1. Parse and validate `target` against `substrate` (a mismatch, such as
|
||||
`unix` without `path` or `tcp` with a bad port, is `invalid_input`
|
||||
class).
|
||||
2. Inject the parsed `TunnelTarget` into the assembly-provided
|
||||
`DialFn` (the same halves function the base op's registry backings
|
||||
resolve to; ADR-004 — the protocol never sees a socket type).
|
||||
3. Return the dialed handle via `Establishment::new(plan)` — unchanged
|
||||
resolve to; ADR-004, which is why the protocol never sees a socket type).
|
||||
3. Return the dialed handle via `Establishment::new(plan)`: unchanged
|
||||
plan-flow.
|
||||
|
||||
Error mapping (the dial table, minus the registry row):
|
||||
@@ -163,12 +163,12 @@ sentence (also ADR-006 amendment).
|
||||
a target object must be optional-with-default.
|
||||
- **`SubstrateAddr`'s untagged leniency is schema-gated** (review 002
|
||||
N-4): untagged serde variants cannot `deny_unknown_fields`, so a
|
||||
cross-shape target (e.g. `{path, host}`) parses at the serde layer —
|
||||
the SCHEMA's `additionalProperties: false` per branch is the real
|
||||
cross-shape target (e.g. `{path, host}`) parses at the serde layer;
|
||||
the SCHEMA's `additionalProperties: false` per branch is the real
|
||||
shape-vs-substrate gate. One caution for future registry variants: a
|
||||
schema-less registry would leave the establisher's serde backstop as
|
||||
the only gate, and it keys `render_target` off the sibling
|
||||
`substrate` — a cross-shape target that sneaked past would render
|
||||
`substrate`; a cross-shape target that sneaked past would render
|
||||
*something* (`/p` for the unix case) rather than fail; the serde
|
||||
backstop renders the sibling-keyed target, so cross-shape garbage is
|
||||
the schema's absence, not a serde failure. If this crate is ever
|
||||
@@ -177,15 +177,15 @@ sentence (also ADR-006 amendment).
|
||||
- **`SubstrateAddr` must not grow overlapping untagged variants.** If
|
||||
a future variant's shapes overlap (e.g. a `{host, port, flowlabel}`
|
||||
form), untagged disambiguation starts ordering (first-match wins
|
||||
becomes load-bearing and silent) — switch to an internally-tagged
|
||||
or untagged-with-content form at that point, deliberately (an ADR
|
||||
line), never by accident. Pre-consumer and additive until then.
|
||||
- **The BAST question is moot** — params is JSON inside the channels
|
||||
becomes load-bearing and silent), which means switching to an internally-tagged
|
||||
or untagged-with-content form at that point, deliberately (an ADR
|
||||
line), never by accident. Pre-consumer and additive until then.
|
||||
- **The BAST question is moot**: params is JSON inside the channels
|
||||
open op; the data plane is unchanged (raw pass-through for tcp/unix,
|
||||
the `[len: u16 BE]` codec for udp — ADR-003).
|
||||
the `[len: u16 BE]` codec for udp per ADR-003).
|
||||
- **Discovery exposure**: the direct op's `input_schema` carries
|
||||
`{substrate, target}`, so a discovery listing reveals that dynamic
|
||||
egress exists and of which substrates — acceptable; the target
|
||||
egress exists and of which substrates; that is acceptable, since the target
|
||||
values (what was dialed) never enter discovery (they ride per-call
|
||||
params). A producer wanting to hide the capability entirely simply
|
||||
does not register the op (and old producers reject it loudly anyway).
|
||||
@@ -196,7 +196,7 @@ sentence (also ADR-006 amendment).
|
||||
direct op itself is protocol-layer (default crate, wasm-clean).
|
||||
- **SOCKS5 CONNECT composes directly:** the alksocks adapter's
|
||||
`DialFn` seam maps the SOCKS5 CONNECT target (ATYP-decoded) onto
|
||||
`open_direct` — the graduation's §4 table, now with the stand-in
|
||||
`open_direct`, which is the graduation's §4 table with the stand-in
|
||||
dissolved.
|
||||
|
||||
## Erratum (2026-09-20, review 002 D-2)
|
||||
@@ -204,7 +204,7 @@ sentence (also ADR-006 amendment).
|
||||
§2's original wording said the target sub-schema is "`oneOf` on the
|
||||
sibling `substrate` value, validated at establisher time". Both
|
||||
halves of that sentence were stale against the shipped schema
|
||||
(the decision itself was not — the code is the wire-stable side and
|
||||
(the decision itself was not; the code is the wire-stable side and
|
||||
is correct):
|
||||
|
||||
1. **The shipped structure is an `allOf` of two `if`/`then`
|
||||
@@ -225,14 +225,14 @@ is correct):
|
||||
## References
|
||||
|
||||
- `docs/research/tunnels-graduation.md` §2 (the ask, options, and the
|
||||
lean) — the design discussion this ADR records
|
||||
lean): the design discussion this ADR records
|
||||
- alksocks `poc-bind-findings.md` (the scope-gate precedent) and
|
||||
`poc-connect-wasm-findings.md` (the dial-at-command-read layering)
|
||||
- alkcall ADR-047 (open ops + `input_schema` + discovery marker),
|
||||
ADR-049 (establishment/typed errors), Gap F (op-name → ALPN
|
||||
derivation — the flavor-form amendment target)
|
||||
derivation, the flavor-form amendment target)
|
||||
- alktunnels ADR-001 (the resource-params invariant the direct op
|
||||
extends), ADR-002 Amendment 1 (flavor-form op ids), ADR-006
|
||||
(scope posture + the hub-policy amendment)
|
||||
- AGENTS.md conventions 9 (no forced binding — dial-only, no listen
|
||||
- AGENTS.md conventions 9 (no forced binding; dial-only, no listen
|
||||
form), 13 (access control), 17 (ALPN naming)
|
||||
@@ -1,21 +1,23 @@
|
||||
# ADR-008: Listen Metadata — Bind-First Establisher and the Forwarded Op
|
||||
# ADR-008: Listen Metadata: Bind-First Establisher and the Forwarded Op
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (2026-09-16); amended 2026-09-18 (the correlation token —
|
||||
`listen` reply field + forwarded-params key, see Amendment 1)
|
||||
Accepted (2026-09-16); amended 2026-09-18 (the correlation token,
|
||||
the `listen` reply field + forwarded-params key, see Amendment 1); amended
|
||||
2026-11-03 (the forwarded op's serving-side establishment phase + the
|
||||
correlation-miss vocabulary, see Amendment 2)
|
||||
|
||||
## Context
|
||||
|
||||
alksocks' BIND POC (`poc-bind-findings.md` in the alksocks repo) pinned
|
||||
two upstream asks for composed (`-R`-style) BIND fidelity:
|
||||
|
||||
1. **Bound-addr discovery without consuming an accept** — reply#1's
|
||||
1. **Bound-addr discovery without consuming an accept**: reply#1's
|
||||
`BND.ADDR` must precede the app server's dial, but the listen
|
||||
establisher is accept-first: its establishment phase pops an
|
||||
already-accepted connection, so the open cannot complete (and
|
||||
cannot report a bound address) until the app server dials.
|
||||
2. **Peer metadata on accepted handles** — reply#2's peer address is
|
||||
2. **Peer metadata on accepted handles**: reply#2's peer address is
|
||||
lost through the composed path; `TargetHandle` carries boxed halves
|
||||
only, so the composed variant reports the sentinel.
|
||||
|
||||
@@ -23,8 +25,8 @@ The graduation research (`docs/research/tunnels-graduation.md` §3)
|
||||
framed the option space: per-ask carriers (a reply-metadata projection
|
||||
plus one of three addr-discovery shapes) versus ssh's
|
||||
`forwarded-tcpip` shape as the combined alternative. Decision: the
|
||||
`forwarded-tcpip` shape — **bind-first establisher + accept-as-open**
|
||||
— because it resolves both asks *and* two structural problems the POC
|
||||
`forwarded-tcpip` shape (**bind-first establisher + accept-as-open**),
|
||||
because it resolves both asks *and* two structural problems the POC
|
||||
recorded, with zero special carriers:
|
||||
|
||||
- The address-first-vs-accept-first **deadlock dissolves** instead of
|
||||
@@ -33,9 +35,9 @@ recorded, with zero special carriers:
|
||||
OS-chosen bound address (ask #1 solved natively).
|
||||
- The **10s establishment-bound problem dissolves**: the accept wait
|
||||
moves out of the establishment phase into the pump-handler phase
|
||||
(whose lifetime is teardown-governed, not establishment-bounded) —
|
||||
FTP-style long BIND waits stop resolving as spurious refusals.
|
||||
- Ask #2 (peer metadata) rides the reverse channel's open-op params —
|
||||
(whose lifetime is teardown-governed, not establishment-bounded), so
|
||||
FTP-style long BIND waits stop resolving as spurious refusals.
|
||||
- Ask #2 (peer metadata) rides the reverse channel's open-op params:
|
||||
the wire's native "this connection is for X" moment.
|
||||
|
||||
All the machinery is POC-validated: `open_reverse_channel` +
|
||||
@@ -48,18 +50,18 @@ over a serving connection (the reverse POC's hub shape).
|
||||
### 1. Third establisher shape: bind-first
|
||||
|
||||
The listen establisher gains a bind-first variant alongside the
|
||||
accept-first shape (which stays — see §5):
|
||||
accept-first shape (which stays; see §5):
|
||||
|
||||
- The establisher **binds the listener** (via the assembly-provided
|
||||
listen function) and returns `Establishment::new(plan)` carrying the
|
||||
listener handle. Establishment is fast — bounded by the bind, not by
|
||||
listener handle. Establishment is fast, bounded by the bind, not by
|
||||
any app-server dial.
|
||||
- **The establisher mints a correlation token** (Amendment 1): a fresh
|
||||
u32 unique per open on this producer, carried twice — inside the
|
||||
u32 unique per open on this producer, carried twice: inside the
|
||||
plan (the pump handler needs it to correlate per-accept forwarded
|
||||
opens) and as the additive `listen` reply field (§2). The plan also
|
||||
carries the listen tunnel's substrate, stamped at establisher time
|
||||
from the one successful params parse — the accept dispatcher reads
|
||||
from the one successful params parse; the accept dispatcher reads
|
||||
it from the plan and stamps it into each forwarded open's
|
||||
`substrate` param; it never re-parses wire input (review 002 M-1:
|
||||
the removed silent `Tcp` fallback). The original
|
||||
@@ -69,24 +71,25 @@ accept-first shape (which stays — see §5):
|
||||
a generic hub relay forwards params untouched (it strips/rewrites
|
||||
only the reply's `channel_id`), so a spoke's forwarded open would
|
||||
carry an id the end opener never allocated. A producer-minted token
|
||||
rides both hops untouched — reply fields pass through relays
|
||||
rides both hops untouched, since reply fields pass through relays
|
||||
per-hop-truthful (alkcall ADR-051), and the same minted token
|
||||
appears in every hop's reply.
|
||||
- The establisher MAY contribute the observed bound address to the
|
||||
open-op reply (§2): `"bound": { ... }` — the real OS-chosen address
|
||||
open-op reply (§2): `"bound": { ... }`, which is the real OS-chosen address
|
||||
(port `0` resolved), substrate-shaped like the direct op's target
|
||||
(`{host, port}` for tcp/udp, `{path}` for unix).
|
||||
- The pump handler for this shape is an **accept dispatcher**, not
|
||||
`pump_bidi`: it accepts the channel's `BiStream` (the R-02 contract)
|
||||
and runs the assembly-provided accept loop concurrently — each
|
||||
accepted connection produces a forwarded channel (§3); the channel's
|
||||
own `BiStream` stays idle and its EOF (the opener dropping the
|
||||
listener session) cancels the loop and tears the listener down. The
|
||||
idle-read check is deliberately WIDER than a bare EOF comparison
|
||||
(review 002 N-2): the watch treats ANY read as teardown — `Ok(0)`
|
||||
(the EOF sentinel), an error, **and** any non-empty read (a peer
|
||||
that writes to the idle listen channel is misbehaving; the channel
|
||||
is not a data plane — fail closed). An implementer reading only
|
||||
and runs the assembly-provided accept loop concurrently; each
|
||||
accepted connection produces a forwarded channel (§3); the channel's
|
||||
own `BiStream` stays idle and its EOF (the opener dropping the
|
||||
listener session) cancels the loop and tears the listener down. The
|
||||
idle-read check is deliberately WIDER than a bare EOF comparison
|
||||
(review 002 N-2): the watch treats ANY read as teardown, whether
|
||||
`Ok(0)`
|
||||
(the EOF sentinel), an error, **or** any non-empty read (a peer
|
||||
that writes to the idle listen channel is misbehaving; the channel
|
||||
is not a data plane, so fail closed). An implementer reading only
|
||||
"its EOF cancels the loop" and checking `Ok(0)` alone would hang on
|
||||
a write-happy peer; the shipped posture is the any-byte one,
|
||||
pinned by the write-happy-peer test.
|
||||
@@ -100,42 +103,42 @@ The open-op reply gains an **optional, additive** field:
|
||||
```
|
||||
|
||||
- Present only when the establisher supplies it (bind-first listeners);
|
||||
absent for dial establishers — old consumers ignore unknown reply
|
||||
absent for dial establishers; old consumers ignore unknown reply
|
||||
fields (plain JSON, alkcall `ResponseEnvelope`), so the change is
|
||||
wire-additive.
|
||||
- The carrier is an upstream alkcall ask (ADR-049 amendment 3:
|
||||
establisher → reply projection — `Establishment` gains optional
|
||||
establisher → reply projection, with `Establishment` gaining optional
|
||||
reply fields the wrapper merges into the success output; the
|
||||
`channel_id` key is wrapper-reserved). Filed in
|
||||
`alkcall/docs/reviews/008-tunnels-graduation-upstream-review.md`.
|
||||
**Landed in alkcall 0.8.0** (review 008 U-2, ADR-049 amendment 3:
|
||||
`Establishment::with_reply_field` + the wrapper merge with the
|
||||
reserved-key guard).
|
||||
- **`listen` — the correlation token (Amendment 1).** A bind-first
|
||||
- **`listen`: the correlation token (Amendment 1).** A bind-first
|
||||
establisher also contributes `"listen": <u32>`: the token the
|
||||
forwarded op's params key on (§3, as amended). Same projection
|
||||
carrier, same additive posture — a second reply field on the same
|
||||
opens that carry `bound`. `bound` and `listen` are independent
|
||||
fields (an accept-first establisher could supply `listen`-shaped
|
||||
correlation too — the accept queue's establisher mints its own —
|
||||
but v1 pins the pair on bind-first only).
|
||||
carrier, same additive posture: a second reply field on the
|
||||
same opens that carry `bound`. `bound` and `listen` are independent
|
||||
fields (an accept-first establisher could supply `listen`-shaped
|
||||
correlation too, since the accept queue's establisher mints its
|
||||
own, but v1 pins the pair on bind-first only).
|
||||
- `bound` is informational surface, not addressing: a consumer MUST
|
||||
NOT treat it as a dial target (the producer owns the backing —
|
||||
ADR-001's invariant). It exists for RFC-1928-reply-class fidelity
|
||||
NOT treat it as a dial target (the producer owns the backing,
|
||||
per ADR-001's invariant). It exists for RFC-1928-reply-class fidelity
|
||||
(BND.ADDR) and observability.
|
||||
|
||||
### 3. The forwarded op: `channels/tunnel/forwarded`
|
||||
|
||||
When the listener accepts, the **producer opens a channel back toward
|
||||
the listen-opener** — accept-as-open, the `forwarded-tcpip` shape:
|
||||
the listen-opener**: accept-as-open, the `forwarded-tcpip` shape:
|
||||
|
||||
- Op id **`channels/tunnel/forwarded`** (flavor-form, ADR-002
|
||||
Amendment 1), `Sub`-typed, ALPN marker `alk/tunnel`. It is served by
|
||||
the **listen-opener side** (the initiator of the listen-tunnel
|
||||
open): the producer *calls* it per accept
|
||||
(`open_reverse_channel`-shaped — the call plane on channel 0), the
|
||||
(`open_reverse_channel`-shaped; the call plane on channel 0), the
|
||||
opener's registry allocates the `channel_id`, the producer adopts it
|
||||
(the ADR-047 §5 connection-owner rule — the serving side of the
|
||||
(the ADR-047 §5 connection-owner rule, i.e. the serving side of the
|
||||
forwarded call allocates; the producer-side initiator adopts) and
|
||||
pumps the accepted connection's halves against the adopted channel.
|
||||
- Params:
|
||||
@@ -148,19 +151,19 @@ the listen-opener** — accept-as-open, the `forwarded-tcpip` shape:
|
||||
}
|
||||
```
|
||||
|
||||
- **`listen`** (u32, required) — the correlation token the
|
||||
- **`listen`** (u32, required): the correlation token the
|
||||
bind-first establisher minted and returned in the listen open's
|
||||
reply (§2, as amended). The opener matches it against the
|
||||
`listen` field of its open listener sessions — a `HashMap<u32, ..>`
|
||||
on the opener side; no registry op, no extra wire field. The
|
||||
token space is the producer's: fresh per open, unique on that
|
||||
producer (the opener-side map is keyed by the producer's token,
|
||||
and one opener's map may hold tokens from several producers — a
|
||||
hash collision across producers is the opener's bookkeeping
|
||||
`listen` field of its open listener sessions, a `HashMap<u32, ..>`
|
||||
on the opener side; no registry op, no extra wire field. The
|
||||
token space is the producer's: fresh per open, unique on that
|
||||
producer (the opener-side map is keyed by the producer's token,
|
||||
and one opener's map may hold tokens from several producers; a
|
||||
hash collision across producers is the opener's bookkeeping
|
||||
choice, e.g. keying `(token)` uniquely or `(producer, token)`;
|
||||
v1 leaves it assembly-side). **Amends the original `channel`
|
||||
key** (the listen channel's ID): that semantic is unimplementable
|
||||
as pinned — the serving-side wrapper never exposes the allocated
|
||||
key** (the listen channel's ID): that semantic is unimplementable
|
||||
as pinned, since the serving-side wrapper never exposes the allocated
|
||||
channel ID to the establisher or handler (alkcall 0.8.0's
|
||||
`OpenHandler` surface), and generic hub relays forward params
|
||||
untouched, so a spoke's forwarded open would carry an id the end
|
||||
@@ -169,31 +172,35 @@ the listen-opener** — accept-as-open, the `forwarded-tcpip` shape:
|
||||
the reply field passes through per-hop truthful (alkcall ADR-051),
|
||||
and the forwarded open carries the same minted value at every
|
||||
hop.
|
||||
- **`substrate`** (string, required) — the listen tunnel's
|
||||
- **`substrate`** (string, required): the listen tunnel's
|
||||
substrate; shapes the forwarded channel's data-plane framing
|
||||
(ADR-003), same discriminator semantics as everywhere else.
|
||||
- **`peer`** (object, required) — the accepted connection's remote
|
||||
- **`peer`** (object, required): the accepted connection's remote
|
||||
address, substrate-shaped (`{host, port}` / `{path}`). This is ask
|
||||
#2's carrier: reply#2-class fidelity rides the open op, the wire's
|
||||
native metadata moment. Optional in kind, required in v1 — a
|
||||
native metadata moment. Optional in kind, required in v1: a
|
||||
producer that cannot know the peer (rare; all POC'd listeners
|
||||
can) omits... no: **required**; a producer that cannot determine
|
||||
the peer does not serve a bind-first listener over that substrate.
|
||||
- Scope: **`TUNNEL_FORWARDED_SCOPE = "tunnel:forwarded"`** — the
|
||||
- Scope: **`TUNNEL_FORWARDED_SCOPE = "tunnel:forwarded"`**, the
|
||||
opener-side grant for RECEIVING forwarded connections (ingress).
|
||||
Separate from `tunnel:open` and `tunnel:direct` (ADR-006 amendment):
|
||||
exposing a listener and dialing egress are different capabilities.
|
||||
- The opener-side serving shape: `register_openable_with_establisher`
|
||||
as-is (no establisher — the forwarded channel needs no
|
||||
establishment; its data plane starts immediately), ACL-gated by the
|
||||
scope, handler = correlate the open (via `listen`, the correlation
|
||||
token) to the waiting
|
||||
as-is (no establisher, because the forwarded channel needs no
|
||||
establishment; its data plane starts immediately; this clause was
|
||||
**amended by Amendment 2**: the served shape gains an establisher
|
||||
variant, the injected correlation gate; this sentence records the
|
||||
pre-amendment posture, which stays a valid shape per Amendment 2),
|
||||
ACL-gated by
|
||||
the scope, handler = correlate the open (via `listen`, the
|
||||
correlation token) to the waiting
|
||||
session, accept the `BiStream`, pump against the local BIND client's
|
||||
halves. The correlation registry is assembly-side bookkeeping —
|
||||
halves. The correlation registry is assembly-side bookkeeping;
|
||||
the protocol provides the op and the params, not the
|
||||
wait/quiet-state machine.
|
||||
- An older opener that does not serve the forwarded op rejects the
|
||||
producer's per-accept call loudly (`NOT_FOUND`) — the SSH posture.
|
||||
producer's per-accept call loudly (`NOT_FOUND`), the SSH posture.
|
||||
A producer serving bind-first SHOULD prefer openers that advertise
|
||||
the forwarded op (discovery, alkcall ADR-047).
|
||||
|
||||
@@ -205,7 +212,7 @@ let bound = listener.bound(); // the resolved ad
|
||||
// ... per forwarded accept (opener side, served op):
|
||||
// correlate by `listen`, accept the BiStream, pump against the
|
||||
// local session's halves
|
||||
listener.close(self); // or Drop — tears the listen channel + far listener down
|
||||
listener.close(self); // or Drop; tears the listen channel + far listener down
|
||||
```
|
||||
|
||||
`TunnelListener` is the listen-flow sibling of `TunnelSession`: it owns
|
||||
@@ -213,7 +220,7 @@ the listen channel's teardown (ADR-005's ownership posture extended to
|
||||
the listen flow); it presents `bound()` and the teardown API, and the
|
||||
forwarded channels themselves arrive as separate adopted-side
|
||||
`TunnelSession`s on the opener's manager. `TunnelSession::adopt` gains
|
||||
nothing — forwarded channels are ordinary adopted channels. The
|
||||
nothing; forwarded channels are ordinary adopted channels. The
|
||||
opener-side correlation registry (token → waiting listener session)
|
||||
is assembly-side bookkeeping (consumer.md's posture); the crate's
|
||||
`TunnelListener` type provides the `listen` token it needs to key it.
|
||||
@@ -225,7 +232,7 @@ remains a valid establisher shape for producers whose resource backing
|
||||
IS an accept queue (an externally-fed or pre-bound listener shared
|
||||
across opens). Bind-first is the shape for producers that own the
|
||||
bind decision (the common hub case; alksocks' composed BIND). The two
|
||||
shapes are per-registration assembly choices over the same op — no
|
||||
shapes are per-registration assembly choices over the same op; there is no
|
||||
wire distinction beyond `bound`'s presence.
|
||||
|
||||
## Consequences
|
||||
@@ -238,7 +245,7 @@ wire distinction beyond `bound`'s presence.
|
||||
(ADR-049 amendment 3) and the flavor-form discovery derivation
|
||||
(ADR-047 amendment) **landed in alkcall 0.8.0** (review 008, Units
|
||||
1–2); the in-tree `ChannelRelay` + `HubLegTemplate` (ADR-051) came
|
||||
with them — the hub re-produce machinery §6 assumed assembly-layer
|
||||
with them, so the hub re-produce machinery §6 assumed assembly-layer
|
||||
is now upstream, and the relay passes reply fields through per-hop
|
||||
truthful (which is what makes the Amendment 1 token compose through
|
||||
hubs with zero tunnel-specific relay behavior). The bind-first
|
||||
@@ -248,21 +255,21 @@ wire distinction beyond `bound`'s presence.
|
||||
carrier from the graduation doc is superseded: with accept-as-open,
|
||||
the peer rides the forwarded op's params; no process-local metadata
|
||||
field, no sentinel. (If a future non-forwarded accept shape needs
|
||||
metadata, revisit then — additive.)
|
||||
metadata, revisit then, and the change is additive.)
|
||||
- **The BIND reply#2 fidelity is end-to-end:** the opener-side
|
||||
correlation writes reply#2 with the REAL peer from the forwarded
|
||||
op's params. reply#1's `BND.ADDR` is the `bound` reply field. Both
|
||||
POC stand-ins (carried `bind_addr`; sentinel `0.0.0.0:0`) retire.
|
||||
- **Hub re-produce:** a hub re-producing a listen resource binds its
|
||||
own listener (bind-first, its own `bound` + minted `listen` token)
|
||||
and re-opens forwarded channels with the same token — the spoke's
|
||||
and re-opens forwarded channels with the same token, so the spoke's
|
||||
forwarded opens carry the hub's token untouched (params are not
|
||||
rewritten by the generic relay), and the metadata is per-hop
|
||||
truthful, matching alkcall ADR-042's terminate-and-re-produce
|
||||
posture. The end opener correlates on the hub's minted token — the
|
||||
posture. The end opener correlates on the hub's minted token, the
|
||||
value it read from its own open's reply.
|
||||
- **Local-variant BIND needs none of this** (the listener and the
|
||||
accept are in-process — POC #5 variant 1, unchanged); the asks and
|
||||
accept are in-process; POC #5 variant 1, unchanged); the asks and
|
||||
this ADR are scoped to the composed variant.
|
||||
|
||||
## References
|
||||
@@ -273,18 +280,18 @@ wire distinction beyond `bound`'s presence.
|
||||
deadlock and establishment-bound findings this shape dissolves)
|
||||
- alkcall ADR-047 (open ops; connection-owner allocation; the
|
||||
flavor-form amendment), ADR-049 (establishment; the reply-projection
|
||||
amendment), ADR-042 (hub relay — re-produce id semantics)
|
||||
amendment), ADR-042 (hub relay; re-produce id semantics)
|
||||
- alktunnels ADR-001 (params per-op amendment), ADR-002 Amendment 1
|
||||
(flavor-form op ids), ADR-005 (teardown ownership — extended to
|
||||
(flavor-form op ids), ADR-005 (teardown ownership, extended to
|
||||
`TunnelListener`), ADR-006 (scope posture amendment)
|
||||
- AGENTS.md conventions 9 (no forced binding — the bind decision is
|
||||
the producer's), 13 (access control), 8 (two-pump contract — the
|
||||
- AGENTS.md conventions 9 (no forced binding, so the bind decision is
|
||||
the producer's), 13 (access control), 8 (two-pump contract, where the
|
||||
accept dispatcher is a per-accept `pump_bidi`, not a hand-rolled
|
||||
loop over one stream)
|
||||
|
||||
## Amendment 1 (the correlation token, 2026-09-18)
|
||||
|
||||
The decision stands — bind-first establisher, accept-as-open, the
|
||||
The decision stands: bind-first establisher, accept-as-open, the
|
||||
`bound` reply field, the forwarded op. What changes: the forwarded
|
||||
op's correlation key (§3's `channel` param) and the bind-first
|
||||
establisher's reply surface (§2 gains `listen`).
|
||||
@@ -296,18 +303,18 @@ establisher's reply surface (§2 gains `listen`).
|
||||
1. **The serving side never learns its own channel ID.** The
|
||||
open-op wrapper allocates the channel and replies with its ID,
|
||||
but the `OpenHandler`/establisher surface is
|
||||
`Fn(Value, Option<ChannelPlan>, Connection, AuthContext)` — no
|
||||
ID anywhere. The §3 semantic ("the opener knows … without a
|
||||
`Fn(Value, Option<ChannelPlan>, Connection, AuthContext)`, with no
|
||||
ID anywhere. The §3 semantic ("the opener knows … without a
|
||||
registry op") required the producer's accept dispatcher to know
|
||||
its own listen channel's ID to stamp into forwarded opens;
|
||||
unimplementable without a new upstream surface. (Filing one —
|
||||
exposing the allocated ID to handlers — was considered and
|
||||
unimplementable without a new upstream surface. (Filing one,
|
||||
which would expose the allocated ID to handlers, was considered and
|
||||
rejected: it is an alkcall-surface change for a correlation
|
||||
need this crate can solve in-protocol, and it would still not
|
||||
fix (2).)
|
||||
2. **Generic hub relays do not rewrite params.** The in-tree
|
||||
`ChannelRelay` (ADR-051) forwards the open-op input untouched —
|
||||
it strips/rewrites only the reply's `channel_id`. A spoke's
|
||||
`ChannelRelay` (ADR-051) forwards the open-op input untouched;
|
||||
it strips/rewrites only the reply's `channel_id`. A spoke's
|
||||
forwarded opens would carry the spoke-side listen channel's ID,
|
||||
which the end opener never allocated and cannot correlate ("hubs
|
||||
re-map the id naturally" assumed tunnel-specific relay behavior
|
||||
@@ -318,24 +325,135 @@ establisher's reply surface (§2 gains `listen`).
|
||||
reply field via the same `Establishment::with_reply_field` carrier
|
||||
`bound` rides. Both fields pass through relays untouched
|
||||
(reply fields ride per-hop-truthful, ADR-051), and the forwarded
|
||||
open's `listen` param carries the minted token at every hop — the
|
||||
open's `listen` param carries the minted token at every hop; the
|
||||
end opener correlates on the value from its own open's reply.
|
||||
- **Why not a consumer-minted token:** the opener choosing the token
|
||||
and passing it in the listen open's params would work on the direct
|
||||
path, but fails hub re-produce the same way (the hub's own
|
||||
bind-first open is a NEW open — it cannot adopt the end opener's
|
||||
bind-first open is a NEW open, and it cannot adopt the end opener's
|
||||
token without a pass-through mechanism, which is exactly the
|
||||
params-rewriting relays don't do). Producer-minted is the direction
|
||||
information already flows (producer → opener via reply fields);
|
||||
no new flow is needed.
|
||||
- **Token lifecycle:** minted at establisher time (one per open —
|
||||
- **Token lifecycle:** minted at establisher time (one per open;
|
||||
two opens never share a token, so the opener's correlation map
|
||||
needs no session disambiguation beyond the token); retired with
|
||||
the listener session (the token's only consumer is the per-accept
|
||||
forwarded opens of that listener). Uniqueness is per-producer;
|
||||
the opener-side map keys on the token as read from its own reply.
|
||||
- **Scope names, op ids, `bound`, `peer` — unchanged.** The wire
|
||||
- **Scope names, op ids, `bound`, and `peer` are unchanged.** The wire
|
||||
delta vs the pre-amendment ADR is exactly: the reply gains the
|
||||
optional `listen` field, the forwarded params rename `channel` →
|
||||
`listen` with token semantics. Nothing else shipped against either
|
||||
(no consumer exists), so the change is pre-consumer and legal.
|
||||
(no consumer exists), so the change is pre-consumer and legal.
|
||||
|
||||
## Amendment 2 (the serving-side establishment phase + the correlation-miss vocabulary, 2026-11-03)
|
||||
|
||||
Resolves review 003 (`docs/reviews/003-correlation-miss-arrival-class-alkssh-input.md`)
|
||||
via `docs/plans/003-correlation-miss-resolve.md` §Decisions 1–4. The
|
||||
decision stands: bind-first establisher, accept-as-open, the
|
||||
`bound`/`listen` reply fields, the forwarded op, `tunnel:forwarded`.
|
||||
What changes: **the serving side of the forwarded op (§3) gains an
|
||||
establishment phase**: §3's sentence, "no establisher; the forwarded
|
||||
channel needs no establishment; its data plane starts immediately"
|
||||
(marked inline above) is the text this amendment amends.
|
||||
|
||||
### The miss class this closes (the tri-vocabulary history)
|
||||
|
||||
A forwarded open whose `listen` token no longer routes, i.e. the
|
||||
stale/missing-token arrival class, is currently served
|
||||
establisher-less everywhere (alkssh's door, alksocks' composed BIND,
|
||||
and this crate's built-in shape). That is the one posture exception
|
||||
to ADR-049's "a refused open is a typed call error, never a phantom
|
||||
channel": the open succeeds at the call plane, declines post-open in
|
||||
the assembly, and the producer-side pump EOFs with no signal. The
|
||||
family has named the class three ways:
|
||||
|
||||
- **alkssh** uses `correlation-miss` at `debug`:
|
||||
`observe::decline_arrival(row, "correlation-miss", listen)` +
|
||||
teardown-at-birth (`src/tunnel_map.rs`; tokens removed at session
|
||||
teardown by the shared per-fabric-leg `ListenRouter`).
|
||||
- **alksocks** uses prose at `warn`: "forwarded open with no waiting
|
||||
session" (`src/composed/mod.rs:497`).
|
||||
- **alktunnels** has the `handler_error`-class generic arm at `warn`
|
||||
(the bind-first accept loop's failed-open branch,
|
||||
`src/producer.rs:838-854`), which fires only for the
|
||||
connection-gone/`NOT_FOUND`/ACL-refused subclass; an in-session
|
||||
miss (route removed while the fabric connection lives) succeeds at
|
||||
the call plane and logs nothing at all.
|
||||
|
||||
Amendment 2 retires the prose variant and the fabric-side silence.
|
||||
|
||||
### The decisions
|
||||
|
||||
1. **The serving side gains an establisher variant, and the gate is
|
||||
injected.** The protocol crate cannot hold the assembly's
|
||||
correlation map, so the establisher's check is an
|
||||
assembly-provided gate closure over the forwarded params
|
||||
(`TunnelForwardedParams`); the crate wraps that closure into the
|
||||
alkcall `OpenEstablisher` dance and registers via
|
||||
`register_openable_with_establisher` (alkcall 0.10.x, verified
|
||||
present). Assemblies must NOT hand-roll the alkcall establisher
|
||||
plumbing; the crate-owned registration surface is the only
|
||||
supported path. The gate is a lookup, not a dial: establishment
|
||||
stays fast, bounded by the usual establisher rules only.
|
||||
2. **A stale/missing token fails the open as a typed
|
||||
`channel:open_failed`.** The crate maps the gate's failure to
|
||||
alkcall's `EstablishmentError::UnknownResource`, so the reply is
|
||||
`channel:open_failed` with `details.reason = "unknown_resource"`,
|
||||
and no phantom channel is ever born (ADR-049's posture, extended here
|
||||
to the forwarded op's serving side). The waiting session IS a
|
||||
resource that is gone; **`unknown_resource` is reused as-is and
|
||||
the wire vocabulary is NOT extended**: `wire.md`'s open-op sections
|
||||
(the pinned `details.reason ∈ dial_failed / unknown_resource /
|
||||
resource_shortage / handler_error / timeout` list and its
|
||||
§The Forwarded Op) are untouched by this amendment; there is no
|
||||
reason-class ADR, no new wire value. (The one serving-shape prose
|
||||
line in `wire.md`, "no establisher" under §The Forwarded Op,
|
||||
syncs with the API task's in-code doc updates, plan 003 W1; this
|
||||
ADR does not touch `wire.md`.)
|
||||
3. **Gate order is pinned: the scope gate precedes the establisher.**
|
||||
The registry runs `tunnel:forwarded` ACL before the open op's
|
||||
establishment phase (alkcall's open-op wrapper order: registry
|
||||
gates (schema, then ACL), then the wrapper's allocate →
|
||||
establisher → handler). A scope-less producer is `FORBIDDEN`
|
||||
before any gate lookup; the gate never sees an unauthorized open.
|
||||
4. **The residual post-open race keeps teardown-at-birth, named
|
||||
`correlation-miss` at `debug`.** A token that vanishes between
|
||||
establisher pass and handler run, the narrow race the gate cannot
|
||||
close, keeps §3's opener-half posture, unchanged: the assembly
|
||||
handler declines, its early return reaps the channel
|
||||
teardown-at-birth (R-02). Its event name is **`correlation-miss`**
|
||||
(alkssh's shipped vocabulary wins; it is published, and alksocks'
|
||||
prose variant retires), at `debug` **assembly-side**:
|
||||
the
|
||||
protocol crate's built-in handler holds no map, so this is the
|
||||
assemblies' convention this ADR records, not code this crate runs.
|
||||
The producer-side generic arm (the accept loop's failed-open
|
||||
branch) stays at `warn`, now carrying the typed reason from the
|
||||
open failure instead of the prose one.
|
||||
|
||||
### Additive-only surface
|
||||
|
||||
The base `register_tunnel_forwarded_openable` (establisher-less, the
|
||||
post-open shape) stays for compatibility; the establisher variant
|
||||
(`register_tunnel_forwarded_openable_with_establisher`) is the
|
||||
recommended posture, and new assemblies take it by default. Both ride
|
||||
the same op id, params, scope, and reply surface; the wire delta of
|
||||
this amendment is empty.
|
||||
|
||||
### References
|
||||
|
||||
- `docs/reviews/003-correlation-miss-arrival-class-alkssh-input.md`
|
||||
(the ask; situation + framing this amendment resolves)
|
||||
- `docs/plans/003-correlation-miss-resolve.md` §Decisions, §Verified
|
||||
findings (the tri-vocabulary survey; W1 is this amendment + the API
|
||||
task), §Ordering (W2 alkssh / W3 alksocks adoption waves)
|
||||
- alkcall ADR-049 (the establishment phase and typed-error posture
|
||||
extended here); alkcall 0.10.x
|
||||
`register_openable_with_establisher` and
|
||||
`EstablishmentError::UnknownResource → unknown_resource` (verified)
|
||||
- alkssh `src/tunnel_map.rs` (`ListenRouter`, `decline_arrival`);
|
||||
alksocks `src/composed/mod.rs:497`; alktunnels
|
||||
`src/producer.rs:838-854`: the three miss sites this amendment
|
||||
converges (source for §The miss class above)
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-09-18 (OQ-TN-16 updated — ADR-008 Amendment 1: the correlation token)
|
||||
last_updated: 2026-09-18 (OQ-TN-16 updated per ADR-008 Amendment 1: the correlation token)
|
||||
---
|
||||
|
||||
# alktunnels — Open Questions
|
||||
# alktunnels: Open Questions
|
||||
|
||||
Centralized tracker. The Phase 0 OQ ledger (`docs/research/
|
||||
phase-0-findings.md`, OQ-TN-01..10) is promoted here with statuses
|
||||
@@ -15,7 +15,7 @@ OQ-TN numbering. Format per `docs/sdd_process.md`.
|
||||
### OQ-TN-01: Target addressing format (open-op `params` shape)
|
||||
|
||||
- **Origin**: [phase-0-findings.md](../research/phase-0-findings.md) (OQ-TN-01)
|
||||
- **Status**: resolved — [ADR-001](decisions/001-open-params-layout.md)
|
||||
- **Status**: resolved (see [ADR-001](decisions/001-open-params-layout.md))
|
||||
- **Priority**: high
|
||||
- **Resolution**: 2026-09-07 (Phase 1). `params` = self-contained
|
||||
JSON object: `{ "resource": <string>, "substrate": <string> }`.
|
||||
@@ -26,17 +26,17 @@ OQ-TN numbering. Format per `docs/sdd_process.md`.
|
||||
+ survey input (ssh-socks5-survey §OQ-TN-01 residue) + both POCs.
|
||||
- **Cross-references**: OQ-TN-02, OQ-TN-03, ADR-001, ADR-002
|
||||
|
||||
### OQ-TN-02: Datagram substrates (UDP) — boundary preservation
|
||||
### OQ-TN-02: Datagram substrates (UDP), boundary preservation
|
||||
|
||||
- **Origin**: [phase-0-findings.md](../research/phase-0-findings.md) (OQ-TN-02)
|
||||
- **Status**: resolved — [ADR-003](decisions/003-codec-and-udp-framing.md)
|
||||
- **Status**: resolved; see [ADR-003](decisions/003-codec-and-udp-framing.md)
|
||||
- **Priority**: high
|
||||
- **Resolution**: split by path (2026-09-06 survey) + F-2 mandate
|
||||
(2026-09-07 reverse POC). Base open-op UDP resources:
|
||||
endpoint-at-open (one channel = one flow). Dynamic/`-D` UDP:
|
||||
per-datagram addressing inside the tunnel payload, assembly-layer.
|
||||
Boundary preservation: mandatory `[len: u16 BE]` per-datagram
|
||||
framing — mandatory for correctness, not cosmetics (F-2; the full
|
||||
framing; this is mandatory for correctness, not cosmetics (F-2; the full
|
||||
rationale and the executable pin live in ADR-003). Under the codec,
|
||||
`len=0` is a legal empty datagram (forward POC validated); EOF is
|
||||
the channels-level sentinel; the layers never collide.
|
||||
@@ -46,7 +46,7 @@ OQ-TN numbering. Format per `docs/sdd_process.md`.
|
||||
|
||||
- **Origin**: [phase-0-findings.md](../research/phase-0-findings.md) (OQ-TN-03)
|
||||
- **Priority**: high
|
||||
- **Status**: resolved — hub-owns-the-connection model (2026-09-05) +
|
||||
- **Status**: resolved: the hub-owns-the-connection model (2026-09-05) +
|
||||
the reverse-flow POC (2026-09-07, `reverse-poc-summary.md`).
|
||||
No protocol-level direction: role follows the resource. `-L`/`-R`
|
||||
are the same open op with the entry point on different machines;
|
||||
@@ -60,37 +60,37 @@ OQ-TN numbering. Format per `docs/sdd_process.md`.
|
||||
|
||||
- **Origin**: [phase-0-findings.md](../research/phase-0-findings.md) (OQ-TN-04)
|
||||
- **Priority**: medium
|
||||
- **Status**: resolved — binding is always assembly-layer and
|
||||
- **Status**: resolved: binding is always assembly-layer and
|
||||
optional, on either side; the protocol never binds. API surface:
|
||||
dial flows with no local bind (both POCs), listen flows where the
|
||||
binding is the producer's establisher (listen variant, ADR-004),
|
||||
unbound/abstract flows (in-process pipes — the pump is generic over
|
||||
unbound/abstract flows (in-process pipes; the pump is generic over
|
||||
boxed halves). The concrete produce/consume surface is spec'd in
|
||||
producer.md / consumer.md (ADR-005).
|
||||
- **Cross-references**: OQ-TN-03, ADR-004, ADR-005
|
||||
|
||||
## Theme 2: Crate architecture
|
||||
|
||||
### OQ-TN-05: Backend inversion point — `TunnelBackend` trait?
|
||||
### OQ-TN-05: Backend inversion point (`TunnelBackend` trait?)
|
||||
|
||||
- **Origin**: [phase-0-findings.md](../research/phase-0-findings.md) (OQ-TN-05)
|
||||
- **Status**: resolved — [ADR-004](decisions/004-no-backend-trait.md)
|
||||
- **Status**: resolved: [ADR-004](decisions/004-no-backend-trait.md)
|
||||
- **Priority**: medium
|
||||
- **Resolution**: 2026-09-07 (Phase 1). No trait for substrate access:
|
||||
"produce boxed halves for a resource" is a function (UDP POC,
|
||||
OQ-TN-05 executable input). The only remaining thread — the hub
|
||||
re-produce composition — resolves the same way: a hub proxy is a
|
||||
OQ-TN-05 executable input). The only remaining thread, the hub
|
||||
re-produce composition, resolves the same way: a hub proxy is a
|
||||
consumer + producer composed at the assembly layer (the reverse
|
||||
POC's hub is exactly this shape minus re-export); a composition
|
||||
helper is deferred(scope) until a concrete hub-proxy consumer
|
||||
exists (OQ-TN-12).
|
||||
- **Cross-references**: OQ-TN-12, ADR-004
|
||||
|
||||
### OQ-TN-06: The two-pump helper — extract now?
|
||||
### OQ-TN-06: The two-pump helper (extract now?)
|
||||
|
||||
- **Origin**: [phase-0-findings.md](../research/phase-0-findings.md) (OQ-TN-06)
|
||||
- **Priority**: medium
|
||||
- **Status**: resolved (upstream) — `alkcall::channels::pump_bidi`
|
||||
- **Status**: resolved (upstream): `alkcall::channels::pump_bidi`
|
||||
landed in alkcall 0.6.0 (ADR-050, review 007 R-03), shape as this
|
||||
OQ sketched it; this crate is cited as convergence input. alktunnels
|
||||
consumes the helper; hand-rolling is out.
|
||||
@@ -101,7 +101,7 @@ OQ-TN numbering. Format per `docs/sdd_process.md`.
|
||||
### OQ-TN-07: ALPN strategy
|
||||
|
||||
- **Origin**: [phase-0-findings.md](../research/phase-0-findings.md) (OQ-TN-07)
|
||||
- **Status**: resolved — [ADR-002](decisions/002-alpn-strategy.md)
|
||||
- **Status**: resolved: [ADR-002](decisions/002-alpn-strategy.md)
|
||||
- **Priority**: high
|
||||
- **Resolution**: 2026-09-07 (Phase 1). Option A: single `alk/tunnel`
|
||||
ALPN; the substrate discriminator in `params` selects the framing.
|
||||
@@ -113,8 +113,8 @@ OQ-TN numbering. Format per `docs/sdd_process.md`.
|
||||
### OQ-TN-08: Access control and ownership scope
|
||||
|
||||
- **Origin**: [phase-0-findings.md](../research/phase-0-findings.md) (OQ-TN-08)
|
||||
- **Status**: resolved — [ADR-006](decisions/006-access-control-posture.md)
|
||||
(2026-09-07 — the posture promoted to a decision). The resource is
|
||||
- **Status**: resolved: [ADR-006](decisions/006-access-control-posture.md)
|
||||
(2026-09-07; the posture promoted to a decision). The resource is
|
||||
owned by the far side (alkcall op-level ACL applies as-is; no
|
||||
tunnel-specific policy layer, no allowlists in v1; the ownership
|
||||
seam is the establisher seeing the per-call opener identity).
|
||||
@@ -134,7 +134,7 @@ OQ-TN numbering. Format per `docs/sdd_process.md`.
|
||||
- **Origin**: [phase-0-findings.md](../research/phase-0-findings.md) (OQ-TN-09)
|
||||
- **Priority**: high
|
||||
- **Status**: resolved (superseded by alkcall ADR-049, 0.5.0; narrowed
|
||||
through 0.6.0/0.7.0) — establishment is the open op's awaited phase;
|
||||
through 0.6.0/0.7.0): establishment is the open op's awaited phase;
|
||||
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
|
||||
@@ -148,35 +148,35 @@ OQ-TN numbering. Format per `docs/sdd_process.md`.
|
||||
|
||||
- **Origin**: [phase-0-findings.md](../research/phase-0-findings.md) (OQ-TN-10)
|
||||
- **Priority**: low
|
||||
- **Status**: resolved — UDP POC DONE (2026-09-06, 17 tests),
|
||||
- **Status**: resolved: the UDP POC is DONE (2026-09-06, 17 tests),
|
||||
reverse-flow POC DONE (2026-09-07, 14 tests; re-validated over
|
||||
alkcall 0.7.0, 16 tests). #3 split (OQ-TN-14): unix ships with the
|
||||
`local` feature; stdio is OUT — alktty's pipe mode owns process
|
||||
`local` feature; stdio is OUT, since alktty's pipe mode owns process
|
||||
stdio (exit codes + signals are tty semantics). #4 (helper spike)
|
||||
resolved by alkcall ADR-050. Findings F-1 (plan payloads
|
||||
`Send + Sync` — documented upstream) and F-2 (UDP codec mandatory)
|
||||
`Send + Sync`, documented upstream) and F-2 (UDP codec mandatory)
|
||||
recorded; W1 filed upstream and RESOLVED (CF-005/CF-006).
|
||||
- **Cross-references**: ADR-003, OQ-TN-05, OQ-TN-06
|
||||
|
||||
## Theme 4: Phase 1 residues (new)
|
||||
|
||||
### OQ-TN-11: Resource naming — collision domain and lifecycle of a resource id
|
||||
### OQ-TN-11: Resource naming (collision domain and lifecycle of a resource id)
|
||||
|
||||
- **Origin**: OQ-TN-01's residue (stable resource name vs target
|
||||
address), carried into Phase 1
|
||||
- **Status**: partially resolved — the collision-domain half is
|
||||
- **Status**: partially resolved: the collision-domain half is
|
||||
**resolved by [ADR-001](decisions/001-open-params-layout.md)**: the
|
||||
domain is the producer's registry (per-producer; two producers on
|
||||
the same connection may expose the same resource name independently
|
||||
— the open op resolves against the SERVING side's registry). The
|
||||
the same connection may expose the same resource name independently;
|
||||
the open op resolves against the SERVING side's registry). The
|
||||
lifecycle half stays open: is a resource registry a producer-side
|
||||
assembly construct (the POCs' `HashMap` — the leading candidate),
|
||||
assembly construct (the POCs' `HashMap`, the leading candidate),
|
||||
or does it need protocol-level register/rename/retire ops?
|
||||
- **Priority**: medium
|
||||
- **Impacts**: producer.md's `ResourceRegistry` shape and the
|
||||
discovery story (OQ-TN-08); a protocol-level registry op would be a
|
||||
new wire surface (one-way door).
|
||||
- **Resolution**: lifecycle half pending — no prior-art pressure
|
||||
- **Resolution**: lifecycle half pending; no prior-art pressure
|
||||
(SSH lists no forwards in-band; discovery is the ops listing);
|
||||
decide when a consumer needs shared/lifecycle-managed resources.
|
||||
- **Cross-references**: ADR-001, OQ-TN-08
|
||||
@@ -184,7 +184,7 @@ OQ-TN numbering. Format per `docs/sdd_process.md`.
|
||||
### OQ-TN-12: Hub re-produce composition helper
|
||||
|
||||
- **Origin**: OQ-TN-05's remaining thread (2026-09-05 hub model)
|
||||
- **Status**: resolved (upstream, 2026-09-18) — alkcall 0.8.0 ships
|
||||
- **Status**: resolved (upstream, 2026-09-18): alkcall 0.8.0 ships
|
||||
the in-tree `ChannelRelay` + `HubLegTemplate`/`HubLegImports`
|
||||
(alkcall ADR-051, review 008 Unit 3): the relay openable wraps
|
||||
from_call-imported marked specs on the consumer-leg registry via
|
||||
@@ -192,10 +192,10 @@ OQ-TN numbering. Format per `docs/sdd_process.md`.
|
||||
teardown), the translate hop calls the producer leg with
|
||||
`forwarded_for` from the consumer's per-call identity, the
|
||||
byte-forward hop is `pump_bidi` awaited inline, the spoke reply's
|
||||
`channel_id` is stripped/replaced (other fields ride through —
|
||||
`channel_id` is stripped/replaced (other fields ride through, so
|
||||
`bound` flows end-to-end per-hop truthful), and the adopted
|
||||
producer-leg entry reclaims via a `Drop` guard on every plan path.
|
||||
The composition helper a hub proxy needed is upstream — this crate
|
||||
The composition helper a hub proxy needed is upstream, and this crate
|
||||
composes it (OQ-TN-05's resolution stands: no tunnel-side trait).
|
||||
Tracker task: `architecture/oq-tn-12-tracker` (tasks/architecture/).
|
||||
- **Priority**: low
|
||||
@@ -205,9 +205,9 @@ OQ-TN numbering. Format per `docs/sdd_process.md`.
|
||||
### OQ-TN-13: UDP truncation semantics at the codec boundary
|
||||
|
||||
- **Origin**: forward POC Issues Surfaced #3
|
||||
- **Status**: resolved — [ADR-003](decisions/003-codec-and-udp-framing.md)
|
||||
- **Status**: resolved: [ADR-003](decisions/003-codec-and-udp-framing.md)
|
||||
(2026-09-07): **fail-loud at the adapter level.** A receive buffer
|
||||
smaller than the datagram is an error, never a silent truncation —
|
||||
smaller than the datagram is an error, never a silent truncation;
|
||||
a truncated datagram would corrupt the length-framed stream's
|
||||
framing invariants (the reverse POC's F-2 rationale: the codec's
|
||||
wire discipline is what keeps empty datagrams unambiguous; silent
|
||||
@@ -226,20 +226,20 @@ OQ-TN numbering. Format per `docs/sdd_process.md`.
|
||||
- **Origin**: OQ-TN-10 #3 (the cheap un-POC'd item)
|
||||
- **Priority**: low
|
||||
- **Status**: **resolved 2026-09-07 (split by substrate):**
|
||||
- **Unix: in scope for the `local` feature v1** — same halves shape
|
||||
- **Unix: in scope for the `local` feature v1**: same halves shape
|
||||
as TCP (`dial_unix`), cheap, wire enum already carries `unix`
|
||||
(ADR-001). Implemented by `tunnels/local-socket-halves`.
|
||||
- **Stdio: OUT of scope — alktty owns it.** A spawned process's
|
||||
- **Stdio: OUT of scope; alktty owns it.** A spawned process's
|
||||
stdin/stdout/stderr IS a terminal session in alktty's model:
|
||||
`LocalTtyBackend`'s pipe mode (`tokio::process::Command` +
|
||||
`Stdio::piped()`, alktty `tty-local.md`) is exactly the stdio
|
||||
case — three multiplexed logical streams + the exit-code
|
||||
case, with three multiplexed logical streams + the exit-code
|
||||
control chunk (alktty ADR-004) + signal forwarding (REQ-TTY-02).
|
||||
A "stdio bridge" here would be alktty's runner mode with the
|
||||
terminal stripped out: a strictly worse duplicate that also
|
||||
drops the semantics that matter (exit code, signals — a byte
|
||||
drops the semantics that matter (exit code, signals; a byte
|
||||
tunnel has neither). Consumers needing to run commands remotely
|
||||
compose: alkcall dial → alktty session (pipe mode) — the two
|
||||
compose: alkcall dial → alktty session (pipe mode); the two
|
||||
crates are siblings on the same channels substrate, not
|
||||
competitors. (The OQ-TN-10 #3 "cheap item" was scoped before the
|
||||
sibling relationship was this clear.)
|
||||
@@ -256,16 +256,16 @@ OQ-TN numbering. Format per `docs/sdd_process.md`.
|
||||
- **Origin**: `docs/research/tunnels-graduation.md` §2 (the alksocks
|
||||
graduation discussion, 2026-09-15)
|
||||
- **Priority**: high
|
||||
- **Status**: resolved — [ADR-007](decisions/007-direct-open-op.md)
|
||||
- **Status**: resolved: [ADR-007](decisions/007-direct-open-op.md)
|
||||
(2026-09-16). Option (a): a second `Sub`-typed open op
|
||||
`channels/tunnel/direct` on the same `alk/tunnel` ALPN, params
|
||||
`{substrate, target}` (self-describing JSON target keyed by the
|
||||
sibling substrate — ATYP bytes rejected: not schema-describable, no
|
||||
sibling substrate; ATYP bytes rejected: not schema-describable, no
|
||||
unix path form), scope `tunnel:direct` (never implied by
|
||||
`tunnel:open` — structural ACL separation), dial-only (no
|
||||
listen/direct-BIND form — ingress is the listen + forwarded
|
||||
`tunnel:open`, which is the structural ACL separation), dial-only (no
|
||||
listen/direct-BIND form; ingress is the listen + forwarded
|
||||
surface). The op-id convention needed an upstream alkcall amendment
|
||||
(ADR-047's discovery derivation was `/sub`-shaped) — filed in
|
||||
(ADR-047's discovery derivation was `/sub`-shaped); it was filed in
|
||||
`alkcall/docs/reviews/008-tunnels-graduation-upstream-review.md`
|
||||
(ask #1) and **landed in alkcall 0.8.0** (ADR-047 amendment 3:
|
||||
flavor-form op ids + the explicit `channel_open_alpn` discovery
|
||||
@@ -279,23 +279,23 @@ OQ-TN numbering. Format per `docs/sdd_process.md`.
|
||||
BIND POC's two asks: bound-addr without consuming an accept; peer
|
||||
metadata on accepted handles)
|
||||
- **Priority**: high
|
||||
- **Status**: resolved — [ADR-008](decisions/008-listen-metadata-forwarded-op.md)
|
||||
(2026-09-16; **amended 2026-09-18** — Amendment 1). The
|
||||
- **Status**: resolved: [ADR-008](decisions/008-listen-metadata-forwarded-op.md)
|
||||
(2026-09-16; **amended 2026-09-18**, Amendment 1). The
|
||||
`forwarded-tcpip` shape adopted over per-ask carriers: (1) a
|
||||
bind-first listen establisher — establishment ends at bind time, so
|
||||
bind-first listen establisher: establishment ends at bind time, so
|
||||
the open-op reply carries the real OS-chosen address via an
|
||||
additive `bound` field (ask #1; the carrier is the establisher →
|
||||
reply projection — filed as the review's ask #2 and **landed in
|
||||
reply projection, filed as the review's ask #2 and **landed in
|
||||
alkcall 0.8.0** as ADR-049 amendment 3,
|
||||
`Establishment::with_reply_field` + `open_channel_with_reply`),
|
||||
and the address-first-vs-accept-first BIND deadlock + the 10s
|
||||
establishment-bound problem dissolve with the shape; (2) the
|
||||
forwarded op `channels/tunnel/forwarded` (accept-as-open) — the
|
||||
forwarded op `channels/tunnel/forwarded` (accept-as-open): the
|
||||
producer opens a channel per accept with `{listen, substrate,
|
||||
peer}` in params (ask #2's carrier; the `TargetHandle.peer` option
|
||||
is superseded). Amendment 1: the correlation key is a
|
||||
**producer-minted token** (`listen` reply field + forwarded
|
||||
param), not the listen channel's ID — the serving-side handler
|
||||
param), not the listen channel's ID; the serving-side handler
|
||||
never sees its own channel ID (alkcall 0.8.0's `OpenHandler`
|
||||
surface) and generic relays don't rewrite params, so a
|
||||
producer-minted token (which rides reply fields and params through
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-09-18 (the graduation: the direct op, listen metadata, the forwarded op — ADR-007/008 as amended: the correlation token)
|
||||
last_updated: 2026-09-18 (the graduation: the direct op, listen metadata, the forwarded op; ADR-007/008 as amended: the correlation token)
|
||||
---
|
||||
|
||||
# alktunnels — Overview
|
||||
# alktunnels: Overview
|
||||
|
||||
Arbitrary bidirectional tunnels over alkcall channels: TCP, UDP, unix
|
||||
sockets, and other stream or datagram substrates — in the `ssh -L` /
|
||||
sockets, and other stream or datagram substrates, all in the `ssh -L` /
|
||||
`ssh -D` / `ssh -R` sense. A producer/consumer protocol crate riding
|
||||
alkcall channels the same way alktty does (`alk/tty` is the sibling
|
||||
precedent). This document covers the crate's purpose, the resource
|
||||
@@ -28,8 +28,8 @@ channels and provides the typed consumer sessions:
|
||||
halves (ADR-050, R-02's lifetime contract). Three ops on the one
|
||||
ALPN (ADR-002 Amendment 1): `channels/tunnel/sub` (the resource
|
||||
op), `channels/tunnel/direct` (dynamic-target egress, ADR-007), and
|
||||
— served by the listen-opener side — `channels/tunnel/forwarded`
|
||||
(accept-as-open, ADR-008). Three establisher shapes: dial, listen
|
||||
`channels/tunnel/forwarded` (served by the listen-opener side;
|
||||
accept-as-open, ADR-008). Three establisher shapes: dial, listen
|
||||
(accept-first), and bind-first listen.
|
||||
- **The consumer half** (`TunnelSession` / `TunnelListener`,
|
||||
ADR-005/008): opens tunnel channels (`ChannelClient::open_channel`
|
||||
@@ -44,11 +44,11 @@ The guiding insight (the OQ-TN-01 reframe):
|
||||
> A tunnel is a resource, not an address. Base params identify a
|
||||
> produced resource + substrate; the producer owns the backing. The
|
||||
> one additive exception is the direct op (ADR-007): a consumer may
|
||||
> ask the producer to dial an arbitrary target — a different
|
||||
> capability, its own op and its own scope (`tunnel:direct`), never
|
||||
> ask the producer to dial an arbitrary target, and that is a
|
||||
> different capability, its own op and its own scope (`tunnel:direct`), never
|
||||
> implied by `tunnel:open`. Rich per-datagram addressing (SOCKS5 ATYP,
|
||||
> udpgw-style remotes) still enters only through the `-D`/dynamic
|
||||
> composition path — inside the tunnel payload, never in the wire.
|
||||
> composition path, i.e. inside the tunnel payload, never in the wire.
|
||||
|
||||
## Why
|
||||
|
||||
@@ -57,15 +57,15 @@ consumers, the same role alktty plays for terminal sessions:
|
||||
|
||||
- A hub that runs agent workspaces exposes workers' services
|
||||
(postgres, redis, gitea HTTP) as named resources; a coordinator
|
||||
opens tunnels to them over alkcall channels — the VPN/SSH-tunnel
|
||||
service pattern without SSH.
|
||||
opens tunnels to them over alkcall channels (the VPN/SSH-tunnel
|
||||
service pattern without SSH).
|
||||
- A worker behind NAT dials the hub and serves its own open ops
|
||||
(the reverse flow, `ssh -R`); the hub opens tunnel channels toward
|
||||
it per local accept. Both POCs validated this end-to-end.
|
||||
- A `-D`-style consumer tunnels a SOCKS5 connection; the socks5
|
||||
server (assembly layer) does dynamic target selection inside the
|
||||
tunnel payload.
|
||||
- The protocol layer is substrate-agnostic by construction — the
|
||||
- The protocol layer is substrate-agnostic by construction: the
|
||||
bookkeeping (target addressing, direction, lifecycle) never
|
||||
hardcodes a substrate (AGENTS.md convention 7). TCP, UDP, unix
|
||||
sockets, and in-process pipes ride the same open op, pump shape,
|
||||
@@ -77,10 +77,10 @@ A tunnel channel's life (full detail in [wire.md](wire.md)):
|
||||
|
||||
1. **Open (the establishment phase).** The consumer calls
|
||||
`channels/tunnel/sub` with `params = {resource, substrate}`
|
||||
(ADR-001) — or `channels/tunnel/direct` with
|
||||
(ADR-001), or `channels/tunnel/direct` with
|
||||
`params = {substrate, target}` (ADR-007, dynamic egress). The
|
||||
producer's establisher — the open op's awaited phase (alkcall
|
||||
ADR-049) — resolves the resource (or parses the target), dials the
|
||||
producer's establisher (the open op's awaited phase, alkcall
|
||||
ADR-049) resolves the resource (or parses the target), dials the
|
||||
substrate, and returns the handle in the plan. Failure is a typed
|
||||
`channel:open_failed` (`dial_failed` / `unknown_resource` /
|
||||
`resource_shortage` / `handler_error` / `timeout`); the channel
|
||||
@@ -108,52 +108,52 @@ establisher + the forwarded op (ADR-008).
|
||||
## Dependencies
|
||||
|
||||
```
|
||||
alktunnels (default — wasm-clean)
|
||||
├── alkcall 0.10.0 (core types, channels: ChannelCore/ChannelClient/manager,
|
||||
│ pump_bidi, ServingConfig identity seam — CF-005/006,
|
||||
alktunnels (default: wasm-clean)
|
||||
├── alkcall 0.10.1 (core types, channels: ChannelCore/ChannelClient/manager,
|
||||
│ 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
|
||||
wasm-clean (AGENTS.md convention 4); substrate I/O is feature-gated
|
||||
(ADR-004). The wire format carries no alkcall-internal types
|
||||
(alktty ADR-006's self-containment rule applies to the tunnel codec —
|
||||
trivial here, since the codec is a 2-byte length prefix).
|
||||
(alktty ADR-006's self-containment rule applies to the tunnel codec,
|
||||
though it is trivial here, since the codec is a 2-byte length prefix).
|
||||
|
||||
## Feature Gates
|
||||
|
||||
| Feature | Contents | Wasm |
|
||||
|---------|----------|------|
|
||||
| *(default)* | params, wire codec, open-op spec, establisher shapes, `TunnelSession` — protocol only | yes |
|
||||
| `local` | TCP/UDP/unix dial + listen helpers — halves-producing dial/listen functions (ADR-004). Unix ships (OQ-TN-14); stdio does NOT — a spawned process's stdio is alktty's pipe mode (exit codes, signals), not a tunnel | no |
|
||||
| *(default)* | params, wire codec, open-op spec, establisher shapes, `TunnelSession` (protocol only) | yes |
|
||||
| `local` | TCP/UDP/unix dial + listen helpers, i.e. halves-producing dial/listen functions (ADR-004). Unix ships (OQ-TN-14); stdio does NOT: a spawned process's stdio is alktty's pipe mode (exit codes, signals), not a tunnel | no |
|
||||
|
||||
## Module Map
|
||||
|
||||
```
|
||||
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).
|
||||
@@ -164,14 +164,14 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
|
||||
|
||||
| ADR | Decision | Summary |
|
||||
|-----|----------|---------|
|
||||
| [001](decisions/001-open-params-layout.md) | Open-op params: resource + substrate | `{resource, substrate}` — the producer's stable name, not an address; wire-stable from the first consumer |
|
||||
| [001](decisions/001-open-params-layout.md) | Open-op params: resource + substrate | `{resource, substrate}`, the producer's stable name (not an address); wire-stable from the first consumer |
|
||||
| [002](decisions/002-alpn-strategy.md) | Single `alk/tunnel` ALPN | Substrate in params selects the framing; no ALPN split, no API bifurcation |
|
||||
| [003](decisions/003-codec-and-udp-framing.md) | Data-plane codec | Raw pass-through (stream) / mandatory `[len: u16 BE]` (UDP — F-2 mandate); `len=0` is a legal empty datagram |
|
||||
| [003](decisions/003-codec-and-udp-framing.md) | Data-plane codec | Raw pass-through (stream) / mandatory `[len: u16 BE]` (UDP; F-2 mandate); `len=0` is a legal empty datagram |
|
||||
| [004](decisions/004-no-backend-trait.md) | No TunnelBackend trait | Halves functions at the assembly layer; listen is an establisher shape; trait re-evaluated at the 3-consumer threshold |
|
||||
| [005](decisions/005-consumer-session-owns-teardown.md) | Consumer session owns teardown | `TunnelSession` with close/join/Drop — the W3 gap closes structurally |
|
||||
| [006](decisions/006-access-control-posture.md) | Access-control posture | The open gate is the boundary: `tunnel:open` scope (stable once published), op-level ACL, ownership seam, no allowlists in v1; amended — `tunnel:direct`/`tunnel:forwarded` scopes + the hub re-produce policy sentence |
|
||||
| [007](decisions/007-direct-open-op.md) | The direct open op | `channels/tunnel/direct`: dynamic-target egress, `{substrate, target}` params, `tunnel:direct` scope, dial-only — the ssh `direct-tcpip` capability with structural ACL separation |
|
||||
| [008](decisions/008-listen-metadata-forwarded-op.md) | Listen metadata + the forwarded op | Bind-first establisher (reply `bound` field = BND.ADDR fidelity; `listen` = the correlation token), `channels/tunnel/forwarded` accept-as-open (params carry `{listen, substrate, peer}` = reply#2 fidelity) — the `forwarded-tcpip` shape; amended — producer-minted correlation token supersedes the channel-ID key |
|
||||
| [005](decisions/005-consumer-session-owns-teardown.md) | Consumer session owns teardown | `TunnelSession` with close/join/Drop; the W3 gap closes structurally |
|
||||
| [006](decisions/006-access-control-posture.md) | Access-control posture | The open gate is the boundary: `tunnel:open` scope (stable once published), op-level ACL, ownership seam, no allowlists in v1; amended with the `tunnel:direct`/`tunnel:forwarded` scopes + the hub re-produce policy sentence |
|
||||
| [007](decisions/007-direct-open-op.md) | The direct open op | `channels/tunnel/direct`: dynamic-target egress, `{substrate, target}` params, `tunnel:direct` scope, dial-only (the ssh `direct-tcpip` capability with structural ACL separation) |
|
||||
| [008](decisions/008-listen-metadata-forwarded-op.md) | Listen metadata + the forwarded op | Bind-first establisher (reply `bound` field = BND.ADDR fidelity; `listen` = the correlation token), `channels/tunnel/forwarded` accept-as-open (params carry `{listen, substrate, peer}` = reply#2 fidelity); this is the `forwarded-tcpip` shape; amended so that a producer-minted correlation token supersedes the channel-ID key |
|
||||
|
||||
## Open Questions
|
||||
|
||||
@@ -179,13 +179,13 @@ Open questions are tracked in [open-questions.md](open-questions.md).
|
||||
Key questions affecting this document:
|
||||
|
||||
- **OQ-TN-11**: resource naming collision domain + lifecycle (partially resolved)
|
||||
- **OQ-TN-12**: hub re-produce composition helper (deferred(scope) —
|
||||
- **OQ-TN-12**: hub re-produce composition helper (deferred(scope);
|
||||
the policy sentence is pinned: the hub's ACL governs the final dial)
|
||||
- **OQ-TN-13**: UDP truncation semantics (resolved — fail-loud, ADR-003)
|
||||
- **OQ-TN-14**: unix/stdio placement (resolved — unix in via
|
||||
- **OQ-TN-13**: UDP truncation semantics (resolved, fail-loud, ADR-003)
|
||||
- **OQ-TN-14**: unix/stdio placement (resolved; unix in via
|
||||
`local`, stdio out: alktty's pipe mode owns process stdio)
|
||||
- **OQ-TN-15**: the direct op (resolved — ADR-007)
|
||||
- **OQ-TN-16**: listen metadata (resolved — ADR-008)
|
||||
- **OQ-TN-15**: the direct op (resolved; ADR-007)
|
||||
- **OQ-TN-16**: listen metadata (resolved; ADR-008)
|
||||
|
||||
## References
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-09-18 (the direct op, the bind-first establisher, the forwarded op — ADR-007/008 as amended: the correlation token)
|
||||
last_updated: 2026-11-03 (the forwarded op's serving-side establishment
|
||||
phase + the correlation-miss vocabulary, ADR-008 Amendment 2)
|
||||
---
|
||||
|
||||
# alktunnels — Producer Half
|
||||
# alktunnels: Producer Half
|
||||
|
||||
The producer half: the `channels/tunnel/sub` open op — its spec, its
|
||||
The producer half: the `channels/tunnel/sub` open op, its spec, its
|
||||
establishers (dial + listen shapes), its pump handler, and its
|
||||
registration — plus the sibling ops on the same ALPN: the direct op
|
||||
registration, plus the sibling ops on the same ALPN: the direct op
|
||||
(`channels/tunnel/direct`, dynamic-target egress) and the forwarded op
|
||||
(`channels/tunnel/forwarded`, accept-as-open). The producer is whoever
|
||||
can reach the tunnel target; connection direction is irrelevant
|
||||
@@ -43,16 +44,16 @@ ACL, allocates the channel (connection-owner rule, ADR-047 §5), runs
|
||||
the establisher as the awaited bounded establishment phase, then
|
||||
spawns the pump handler. The generic channel ops
|
||||
(`channel/close`, `channel/control`, `channel/resources/subscribe`)
|
||||
register separately via `ChannelOperations::register_on` — the
|
||||
register separately via `ChannelOperations::register_on`; the
|
||||
producer (or its assembly layer) MUST register them on the serving
|
||||
registry so peers can manage channels out-of-band (the recipe alkcall
|
||||
review 007 §Part B verified — `alkcall/docs/reviews/
|
||||
review 007 §Part B verified; see `alkcall/docs/reviews/
|
||||
007-establishment-follow-ups-review.md`; R-01 = the `Establishment`
|
||||
plan payload, R-02 = the `OpenHandler` lifetime contract).
|
||||
|
||||
**Registration timing:** the openable may register AFTER
|
||||
`from_connection_with_serving` returns (the connect-side serving
|
||||
posture — the reverse POC's W2): the dispatcher reads through the
|
||||
posture; the reverse POC's W2): the dispatcher reads through the
|
||||
shared `Arc<OperationRegistry>` per dispatch. Late registration is a
|
||||
supported assembly shape, not a race.
|
||||
|
||||
@@ -60,7 +61,7 @@ supported assembly shape, not a race.
|
||||
|
||||
Dynamic-target egress (ADR-007): the same ALPN, a second `Sub`-typed
|
||||
open op whose establisher dials a per-call target instead of resolving
|
||||
a resource. One ALPN, two ops, two scopes — dial-what-the-producer-
|
||||
a resource. One ALPN, two ops, two scopes: dial-what-the-producer-
|
||||
registered (`tunnel:open`) and dial-anything (`tunnel:direct`) are
|
||||
different capabilities.
|
||||
|
||||
@@ -76,11 +77,11 @@ access control: required_scopes: ["tunnel:direct"]
|
||||
description: SHOULD carry a human-readable hint (dynamic-target egress)
|
||||
```
|
||||
|
||||
- **No registry lookup** — the establisher parses `target` against
|
||||
- **No registry lookup**: the establisher parses `target` against
|
||||
`substrate` (malformed → `invalid_input`-class; `unknown_resource`
|
||||
can never fire) and injects the parsed `TunnelTarget` into the same
|
||||
assembly-provided `DialFn` the base op's backings resolve to.
|
||||
- **Dial-only** — no listen/direct-BIND form; the ingress capability
|
||||
- **Dial-only**: no listen/direct-BIND form; the ingress capability
|
||||
is the listen + forwarded surface (ADR-008).
|
||||
- Registration: `register_tunnel_direct_openable` (same wrapper, same
|
||||
plan-flow; the spec builder is public for assembly variants).
|
||||
@@ -90,9 +91,9 @@ description: SHOULD carry a human-readable hint (dynamic-target egress)
|
||||
The establisher is the open op's awaited, bounded establishment phase
|
||||
(alkcall ADR-049 §1/§2): validate params semantically, consult
|
||||
ownership if wired, dial/accept the substrate, and return
|
||||
`Establishment::new(plan)` — the dialed handle rides the typed-opaque
|
||||
`Establishment::new(plan)`; the dialed handle rides the typed-opaque
|
||||
plan (`ChannelPlan = Arc<dyn Any + Send + Sync>`; the `+ Sync` bound
|
||||
constrains plan payloads — socket-backed handles carry it naturally,
|
||||
constrains plan payloads; socket-backed handles carry it naturally,
|
||||
reverse POC F-1, documented upstream). On failure:
|
||||
`EstablishmentError` (the typed reason codes) → the wrapper tears the
|
||||
channel down and replies `channel:open_failed`.
|
||||
@@ -125,15 +126,15 @@ listener; a hub exposing its own port to a third party): the same
|
||||
open op, the same params; the establisher pops the next accepted
|
||||
connection from an assembly-owned listener queue and returns it as
|
||||
the plan payload. The listener lifecycle (bind, accept loop, cancel)
|
||||
is assembly code — the protocol never binds (OQ-TN-04). Error
|
||||
is assembly code; the protocol never binds (OQ-TN-04). Error
|
||||
mapping: an empty queue during establishment is `resource_shortage`;
|
||||
a closed listener is `dial_failed`-class. No new wire surface — the
|
||||
a closed listener is `dial_failed`-class. No new wire surface: the
|
||||
listen variant is plan-flow with a different halves source.
|
||||
|
||||
**The accept-first shape stays** (ADR-008 §5): for producers whose
|
||||
resource backing IS an accept queue (an externally-fed or pre-bound
|
||||
listener shared across opens), this remains the correct shape — the
|
||||
POC #5 composed-BIND shape. Its limits (no `bound` in the reply — the
|
||||
listener shared across opens), this remains the correct shape: the
|
||||
POC #5 composed-BIND shape. Its limits (no `bound` in the reply: the
|
||||
accept hasn't happened at establishment; the POC's
|
||||
establishment-bound-vs-wait problem) are inherent to accepting-first.
|
||||
|
||||
@@ -141,32 +142,32 @@ establishment-bound-vs-wait problem) are inherent to accepting-first.
|
||||
|
||||
For a producer that OWNS the bind decision (the common hub case;
|
||||
alksocks' composed BIND): the establisher **binds the listener** (via
|
||||
the assembly-provided listen function — the protocol never binds,
|
||||
the assembly-provided listen function; the protocol never binds,
|
||||
OQ-TN-04) and returns the listener handle as the plan payload.
|
||||
Establishment is fast — bounded by the bind, not by any app-server
|
||||
dial:
|
||||
Establishment is fast (bounded by the bind, not by any app-server
|
||||
dial):
|
||||
|
||||
- **The establishment phase ends at bind time**, so the open-op reply
|
||||
can carry the REAL OS-chosen address: the establisher contributes
|
||||
`"bound": {host, port}` (or `{path}`) to the reply — the
|
||||
`"bound": {host, port}` (or `{path}`) to the reply: the
|
||||
establisher → reply projection (ADR-049 amendment 3,
|
||||
`Establishment::with_reply_field`; the `channel_id` key is
|
||||
wrapper-reserved). **Landed in alkcall 0.8.0** (review 008 U-2);
|
||||
consumers read it via `ChannelClient::open_channel_with_reply`.
|
||||
- **The establisher mints the correlation token** (ADR-008
|
||||
Amendment 1): a fresh u32 per open, contributed as the `"listen"`
|
||||
reply field (the same projection) and carried in the plan — the
|
||||
accept dispatcher reads it from the plan and stamps it into each
|
||||
forwarded open's `listen` param. The plan carries
|
||||
`{listener, token, substrate}` (a bind-first plan type; the
|
||||
substrate is stamped at establisher time — the dispatcher reads it
|
||||
from the plan, never re-parses wire input); the token is
|
||||
relay-transparent by construction (reply fields and params both
|
||||
pass hubs untouched — alkcall ADR-051; ADR-008 Amendment 1 has the
|
||||
reply field (the same projection) and carried in the plan; the
|
||||
accept dispatcher reads it from the plan and stamps it into each
|
||||
forwarded open's `listen` param. The plan carries
|
||||
`{listener, token, substrate}` (a bind-first plan type; the
|
||||
substrate is stamped at establisher time, and the dispatcher reads it
|
||||
from the plan, never re-parses wire input); the token is
|
||||
relay-transparent by construction (reply fields and params both
|
||||
pass hubs untouched per alkcall ADR-051; ADR-008 Amendment 1 has the
|
||||
why-superseded of the channel-ID key).
|
||||
- **The pump handler for this shape is an accept dispatcher**, not
|
||||
`pump_bidi`: it accepts the channel's `BiStream` (the R-02
|
||||
contract), then runs the assembly-provided accept loop — each
|
||||
contract), then runs the assembly-provided accept loop; each
|
||||
accepted connection produces a forwarded-channel open toward the
|
||||
listen opener (§The Forwarded Op below); the accepted halves pump
|
||||
against the adopted channel. The listen channel's own `BiStream`
|
||||
@@ -178,22 +179,22 @@ dial:
|
||||
- The address-first-vs-accept-first BIND deadlock (the alksocks POC's
|
||||
one real discovery) DISSOLVES in this shape: the open completes at
|
||||
bind time, reply#1-class metadata (the `bound` field) precedes any
|
||||
app-server dial, and the accept wait lives in the pump phase —
|
||||
teardown-governed, not establishment-bounded (the 10s bound problem
|
||||
app-server dial, and the accept wait lives in the pump phase:
|
||||
teardown-governed, not establishment-bounded (the 10s bound problem
|
||||
dissolves with it).
|
||||
|
||||
**Identity:** the establisher receives the **per-call opener
|
||||
identity** (the dispatch-resolved identity overlaid onto the
|
||||
install-time context — alkcall 0.7.0 CF-006). On hub-forwarded opens
|
||||
install-time context, alkcall 0.7.0 CF-006). On hub-forwarded opens
|
||||
it sees the end client, not the hub. `auth` carries the opener's
|
||||
identity plus the transport-truthful fields (`alpn`, `remote_addr`,
|
||||
`tls_client_fingerprint`) — never rewritten. Consult it for
|
||||
`tls_client_fingerprint`); these are never rewritten. Consult it for
|
||||
ownership-scoped resources if the assembly layer wants
|
||||
`OwnershipProvider` checks here (OQ-TN-08's optional half).
|
||||
|
||||
**Concurrency:** each open's establisher result flows to ITS pump
|
||||
handler — concurrent opens of the same resource are independent
|
||||
(the R-01 plan flow — alkcall review 007 R-01, the
|
||||
handler; concurrent opens of the same resource are independent
|
||||
(the R-01 plan flow per alkcall review 007 R-01, the
|
||||
`Establishment::new(plan)` payload; the forward POC's resource-keyed
|
||||
handoff race is structurally gone, confirmed under concurrency by the
|
||||
reverse POC).
|
||||
@@ -205,45 +206,49 @@ The `OpenHandler` (alkcall ADR-049/050 shape:
|
||||
JoinHandle<()>`):
|
||||
|
||||
1. Downcast the plan to the concrete handle type
|
||||
(`Arc::downcast::<TargetHandle>` — establisher and handler agree
|
||||
(`Arc::downcast::<TargetHandle>`, so establisher and handler agree
|
||||
on the type; alkcall never inspects it).
|
||||
2. `conn.accept_bi()` — the channel's yield-once `BiStream`.
|
||||
3. `pump_bidi(bidi, handle.read, handle.write)` — **awaited inline**
|
||||
2. `conn.accept_bi()`: the channel's yield-once `BiStream`.
|
||||
3. `pump_bidi(bidi, handle.read, handle.write)`: **awaited inline**
|
||||
inside the handler's task. The returned `JoinHandle` MUST track
|
||||
the data-plane lifetime (R-02): the wrapper awaits it and its
|
||||
completion triggers channel teardown. **Early return =
|
||||
teardown-at-birth** — both pumps see instant EOF with zero bytes;
|
||||
teardown-at-birth**: both pumps see instant EOF with zero bytes;
|
||||
the "spawn-and-forget" shape is the one bug class the R-02
|
||||
telemetry (yield-once flag + birth-teardown debug hint) diagnoses
|
||||
at runtime. Hand-rolling the two-pump shape is out (ADR-050).
|
||||
4. UDP: the plan payload is the FRAMED adapter (ADR-003) — the pump
|
||||
4. UDP: the plan payload is the FRAMED adapter (ADR-003), so the pump
|
||||
is unchanged; the codec lives at the substrate/pump boundary.
|
||||
5. **Bind-first listen handler (the accept dispatcher, ADR-008 §1):**
|
||||
the plan payload is the listener; after `accept_bi()`, the handler
|
||||
runs the accept loop (assembly-provided) concurrently with holding
|
||||
the channel's `BiStream` — each accepted connection produces a
|
||||
the channel's `BiStream`; each accepted connection produces a
|
||||
forwarded-channel open toward the listen opener. The listen
|
||||
channel's own stream stays idle (no pumps on it); its EOF cancels
|
||||
the loop (the listener tears down with the session). Per-accept
|
||||
data planes are still `pump_bidi` (convention 8 — per-channel,
|
||||
data planes are still `pump_bidi` (convention 8, per-channel,
|
||||
never a hand-rolled loop over one stream).
|
||||
|
||||
**The handler is substrate-agnostic by construction:** it cannot know
|
||||
whether the halves came from TCP, UDP, a unix socket, or an
|
||||
in-process pipe, and must not (AGENTS.md convention 7).
|
||||
|
||||
## The Forwarded Op (opener side, ADR-008 §3)
|
||||
## The Forwarded Op (opener side, ADR-008 §3 as amended by Amendment 2)
|
||||
|
||||
Accept-as-open: when the bind-first listener accepts, the PRODUCER
|
||||
opens a channel back toward the listen opener. The opener SERVES the
|
||||
op (`channels/tunnel/forwarded`, scope `tunnel:forwarded` — ingress;
|
||||
no establisher, the data plane starts immediately):
|
||||
op (`channels/tunnel/forwarded`, scope `tunnel:forwarded`, ingress;
|
||||
Amendment 2's two serving shapes: the recommended correlation-gate
|
||||
establisher variant, where a stale/missing token fails the open as a
|
||||
typed `channel:open_failed`/`unknown_resource` before the channel
|
||||
is born, or the base establisher-less shape, where the data plane starts
|
||||
immediately and the assembly's post-open decline owns the miss):
|
||||
|
||||
1. The producer's per-accept open carries `{listen, substrate, peer}`
|
||||
— the correlation token from the plan (the value the establisher
|
||||
1. The producer's per-accept open carries `{listen, substrate, peer}`:
|
||||
the correlation token from the plan (the value the establisher
|
||||
minted; the opener matches it against the `listen` field of its
|
||||
open listener sessions — ADR-008 Amendment 1), the substrate
|
||||
(also from the plan — stamped at establisher time from the one
|
||||
open listener sessions per ADR-008 Amendment 1), the substrate
|
||||
(also from the plan, stamped at establisher time from the one
|
||||
successful params parse; the dispatcher never re-parses wire
|
||||
input), the accepted connection's remote address (reply#2-class
|
||||
fidelity's carrier).
|
||||
@@ -251,14 +256,19 @@ no establisher, the data plane starts immediately):
|
||||
adopts it (`TunnelSession::adopt`) and pumps the accepted halves
|
||||
against it (`pump_against`).
|
||||
3. The opener-side handler correlates the open (via `listen`) to the
|
||||
waiting session (assembly-side bookkeeping — the protocol provides
|
||||
waiting session (assembly-side bookkeeping; the protocol provides
|
||||
the op and params, not the wait state machine), accepts the
|
||||
`BiStream`, and pumps against the local session's halves.
|
||||
`BiStream`, and pumps against the local session's halves. Under
|
||||
the establisher variant (Amendment 2) that correlation lookup
|
||||
rides the gate at establisher time instead; the handler-side
|
||||
correlation remains only for the narrow residual race (a token
|
||||
vanishing between gate pass and handler run; the miss class is
|
||||
named `correlation-miss` at `debug`, assembly-side).
|
||||
4. Identity: the forwarded op's opener-side gate sees the PRODUCER's
|
||||
per-call identity (CF-005 precedence — the producer may carry an
|
||||
per-call identity (CF-005 precedence; the producer may carry an
|
||||
`auth_token` when it is itself forwarding); the peer address in
|
||||
params is producer-asserted data, not transport truth — the ACL
|
||||
trusts the producer's grant, the application treats `peer` as
|
||||
params is producer-asserted data, not transport truth; the ACL
|
||||
trusts the producer's grant, and the application treats `peer` as
|
||||
metadata.
|
||||
|
||||
## Registration API (the spec surface)
|
||||
@@ -269,7 +279,7 @@ pub fn register_tunnel_openable(
|
||||
registry: &ResourceRegistry, // assembly-owned: (resource, substrate) -> backing
|
||||
on_registry: &Arc<OperationRegistry>, // the session's dispatch registry
|
||||
// (the per-session fork per alkcall ADR-047 §4
|
||||
// amendment, 2026-09-03 — NOT the connection
|
||||
// amendment, 2026-09-03, NOT the connection
|
||||
// overlay; Layer-2-registered open ops resolve
|
||||
// NOT_FOUND, review 004 F-01)
|
||||
auth: AuthContext, // the install-time context (per-call overlay is upstream's)
|
||||
@@ -300,7 +310,7 @@ pub fn register_tunnel_bindfirst_openable( // bind-first (shape 3, ADR-008)
|
||||
- `ResourceRegistry` (assembly construct, OQ-TN-11): maps
|
||||
`(resource, substrate)` → backing. The POCs' `HashMap` shape is the
|
||||
candidate; the collision domain is the producer's registry
|
||||
(ADR-001 — two producers on one connection may expose the same
|
||||
(per ADR-001, two producers on one connection may expose the same
|
||||
resource name independently).
|
||||
- The scope constants `TUNNEL_OPEN_SCOPE = "tunnel:open"`,
|
||||
`TUNNEL_DIRECT_SCOPE = "tunnel:direct"`, and
|
||||
@@ -311,7 +321,7 @@ pub fn register_tunnel_bindfirst_openable( // bind-first (shape 3, ADR-008)
|
||||
|
||||
## Access control posture
|
||||
|
||||
Tunnels reach local networks — the open gate is the security boundary
|
||||
Tunnels reach local networks; the open gate is the security boundary
|
||||
(OQ-TN-08, ADR-006 + Amendment 1):
|
||||
|
||||
- The registry runs `AccessControl` before the establisher (the ACL
|
||||
@@ -321,26 +331,26 @@ Tunnels reach local networks — the open gate is the security boundary
|
||||
`TUNNEL_FORWARDED_SCOPE` gates ingress (receiving forwarded
|
||||
connections) on the opener side. Three scopes, three capabilities.
|
||||
- The assembly layer MAY add resource-scoped checks via the
|
||||
establisher (ownership on the resource name) — the protocol
|
||||
establisher (ownership on the resource name); the protocol
|
||||
provides the seam, not the policy. For the direct op the seam is
|
||||
the parsed `TunnelTarget` (a deployment MAY enforce target
|
||||
allowlists in its establisher — policy, not protocol).
|
||||
allowlists in its establisher; policy, not protocol).
|
||||
- No target allowlists in the PROTOCOL, no tunnel-specific policy
|
||||
machinery in v1 — the far side owns the resource (OQ-TN-08
|
||||
machinery in v1, as the far side owns the resource (OQ-TN-08
|
||||
resolution); discovery is the ACL-filtered ops listing.
|
||||
- **Hub re-produce policy (the security model sentence, ADR-006
|
||||
Amendment 1):** a hub re-producing the direct op applies ITS OWN
|
||||
target policy at the re-produce hop — the hub's ACL governs the
|
||||
target policy at the re-produce hop; the hub's ACL governs the
|
||||
final dial. The hub is the network boundary.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- **OQ-TN-11**: resource naming collision domain + lifecycle
|
||||
(partially resolved — the per-producer registry is the leading
|
||||
(partially resolved; the per-producer registry is the leading
|
||||
candidate)
|
||||
- **OQ-TN-14**: resolved (split: unix in, stdio out — alktty's pipe
|
||||
mode owns process stdio; see open-questions.md)
|
||||
- **OQ-TN-15/16**: resolved (ADR-007/008 — the direct op, listen
|
||||
- **OQ-TN-14**: resolved (split: unix in, stdio out, with alktty's pipe
|
||||
mode owning process stdio; see open-questions.md)
|
||||
- **OQ-TN-15/16**: resolved (ADR-007/008: the direct op, listen
|
||||
metadata, the forwarded op)
|
||||
|
||||
## References
|
||||
@@ -354,7 +364,7 @@ Tunnels reach local networks — the open gate is the security boundary
|
||||
- alkcall ADR-047/049 (open ops + establishment), ADR-050 (`pump_bidi`),
|
||||
ledger CF-005/CF-006 (identity precedence + per-call opener)
|
||||
- Upstream asks (review 008): `alkcall/docs/reviews/
|
||||
008-tunnels-graduation-upstream-review.md` — both landed in
|
||||
008-tunnels-graduation-upstream-review.md`; both landed in
|
||||
alkcall 0.8.0 (flavor-form discovery derivation, ADR-047
|
||||
amendment 3; the establisher → reply `bound` projection, ADR-049
|
||||
amendment 3), plus the in-tree `ChannelRelay`/`HubLegTemplate`
|
||||
|
||||
+67
-56
@@ -3,12 +3,12 @@ status: draft
|
||||
last_updated: 2026-09-20 (review 002 D-1/D-2 sync: the params schemas' port bounds, `additionalProperties: false` gates, `listen` `minimum: 1`, and the registry-side gate ordering recorded; ADR-007's oneOf wording corrected to the shipped allOf/if-then)
|
||||
---
|
||||
|
||||
# alktunnels — Wire Format
|
||||
# alktunnels: Wire Format
|
||||
|
||||
The `alk/tunnel` wire surface. Two layers: the **open op** (JSON
|
||||
params on the channels call plane — structured, schema-validated,
|
||||
params on the channels call plane: structured, schema-validated,
|
||||
typed errors) and the **data plane** (bytes/datagrams inside the
|
||||
channel `BiStream` — the codec, ADR-003). The channels layer strips
|
||||
channel `BiStream`, the codec, ADR-003). The channels layer strips
|
||||
its own 8-byte header transparently (alknet ADR-093); the tunnel
|
||||
protocol owns everything inside the `BiStream`.
|
||||
|
||||
@@ -21,7 +21,7 @@ this document is the normative WHAT.
|
||||
|
||||
`Sub`-typed operation (alkcall ADR-047): the reply carries
|
||||
`{ "channel_id": <u32> }` once; the data plane flows on the channel's
|
||||
`BiStream` — never in the call response stream.
|
||||
`BiStream`, never in the call response stream.
|
||||
|
||||
### Input (params)
|
||||
|
||||
@@ -32,33 +32,33 @@ this document is the normative WHAT.
|
||||
}
|
||||
```
|
||||
|
||||
- `resource`: string, required — the producer's stable resource name
|
||||
- `resource`: string, required: the producer's stable resource name
|
||||
(NOT an address; the producer's registry maps it to its backing).
|
||||
- `substrate`: string, required, enum `["tcp", "udp", "unix"]` — the
|
||||
- `substrate`: string, required, enum `["tcp", "udp", "unix"]`: the
|
||||
extensible discriminator (ADR-001); selects the data-plane framing.
|
||||
The schema ships with all three values in v1 (unix has an
|
||||
implementation path — OQ-TN-14). An older producer rejecting a
|
||||
newer substrate value is the SSH "unknown channel type" posture —
|
||||
implementation path, OQ-TN-14). An older producer rejecting a
|
||||
newer substrate value is the SSH "unknown channel type" posture:
|
||||
loud, not silent.
|
||||
- `input_schema` pins both as required strings; unknown substrate
|
||||
values fail schema validation (typed error — loud, not silent).
|
||||
values fail schema validation (typed error, loud, not silent).
|
||||
- Extensibility: a new substrate is a new enum value, not a format
|
||||
change. An older producer rejecting a newer substrate is the SSH
|
||||
"unknown channel type" posture.
|
||||
|
||||
### Reply / errors
|
||||
|
||||
- Success: `{ "channel_id": <u32> }` — the channel is open, the
|
||||
- Success: `{ "channel_id": <u32> }`: the channel is open, the
|
||||
establishment phase (dial) succeeded, the pump handler is running.
|
||||
Bind-first listen opens MAY add `"bound"` (§The Listen Open Op's
|
||||
Reply below).
|
||||
- Failure: a typed `channel:open_failed` call error with
|
||||
`details: { reason, message }` — `reason ∈ dial_failed` /
|
||||
`details: { reason, message }` with `reason ∈ dial_failed` /
|
||||
`unknown_resource` / `resource_shortage` / `handler_error` /
|
||||
`timeout` (alkcall ADR-049 §3). The registry's ACL gate runs before
|
||||
the wrapper (`FORBIDDEN` for a scope-less identity); the 256-channel
|
||||
cap is `channel:too_many_channels`. **A failed open never returns a
|
||||
`channel_id`** — no phantom channel (the SSH contract, POC-verified
|
||||
`channel_id`**; no phantom channel (the SSH contract, POC-verified
|
||||
from the consumer side).
|
||||
- The scope gate: the open op's `AccessControl.required_scopes` is
|
||||
`["tunnel:open"]` (the `TUNNEL_OPEN_SCOPE` constant). Caller
|
||||
@@ -69,10 +69,10 @@ this document is the normative WHAT.
|
||||
|
||||
## The Direct Op (`channels/tunnel/direct`)
|
||||
|
||||
Dynamic-target egress (ADR-007) — "dial what I name," not "dial what
|
||||
Dynamic-target egress (ADR-007): "dial what I name," not "dial what
|
||||
the producer registered." `Sub`-typed, ALPN marker `alk/tunnel`; the
|
||||
same establishment phase and wrapper as `…/sub`. Scope gate:
|
||||
`["tunnel:direct"]` (`TUNNEL_DIRECT_SCOPE`) — **never implied by
|
||||
`["tunnel:direct"]` (`TUNNEL_DIRECT_SCOPE`), **never implied by
|
||||
`tunnel:open`** (ADR-006 Amendment 1).
|
||||
|
||||
### Input (params)
|
||||
@@ -84,42 +84,42 @@ same establishment phase and wrapper as `…/sub`. Scope gate:
|
||||
}
|
||||
```
|
||||
|
||||
- `substrate`: string, required, enum `["tcp", "udp", "unix"]` — the
|
||||
- `substrate`: string, required, enum `["tcp", "udp", "unix"]`: the
|
||||
same discriminator semantics as `…/sub` (ADR-001); selects the
|
||||
data-plane framing.
|
||||
- `target`: object, required, keyed by the sibling `substrate` —
|
||||
- `target`: object, required, keyed by the sibling `substrate`:
|
||||
- `tcp`/`udp` → `{ "host": <string>, "port": <u16> }` (domain-form
|
||||
hosts stay unresolved on the wire — producer-side resolution, the
|
||||
hosts stay unresolved on the wire; producer-side resolution, the
|
||||
ssh `-D` semantic);
|
||||
- `unix` → `{ "path": <string> }`.
|
||||
- `input_schema` pins both required; the target sub-schema is
|
||||
per-substrate — an `allOf` of two `if`/`then` branches keyed on the
|
||||
per-substrate: an `allOf` of two `if`/`then` branches keyed on the
|
||||
sibling `substrate` value, validated **registry-side** (the schema
|
||||
gate runs before the establisher — the behavior
|
||||
gate runs before the establisher, as the behavior
|
||||
`malformed_target_is_schema_class_and_leaves_no_channel` pins; the
|
||||
establisher's serde parse re-checks as the backstop). Malformed
|
||||
targets are the `invalid_input`-class failure; **`unknown_resource`
|
||||
can never fire on the direct op** (no registry lookup).
|
||||
- Schema bounds (wire-stable — the target sub-schema is part of the
|
||||
- Schema bounds (wire-stable; the target sub-schema is part of the
|
||||
one-way door, ADR-007 §"Wire-stable"): each branch's target object
|
||||
carries `"type": "object"` and `"additionalProperties": false` —
|
||||
carries `"type": "object"` and `"additionalProperties": false`:
|
||||
- `tcp`/`udp` → `{ "host": {"type": "string"}, "port": {"type":
|
||||
"integer", "minimum": 0, "maximum": 65535} }`, both required;
|
||||
- `unix` → `{ "path": {"type": "string"} }`, required.
|
||||
|
||||
The `additionalProperties: false` gate is what makes a cross-shape
|
||||
target (a `unix` open carrying `host`) fail the registry's
|
||||
`INVALID_INPUT` class *before* the establisher — not a serde-level
|
||||
`INVALID_INPUT` class *before* the establisher, not a serde-level
|
||||
quirk.
|
||||
|
||||
### Reply / errors
|
||||
|
||||
- Success: `{ "channel_id": <u32> }` — identical semantics to
|
||||
- Success: `{ "channel_id": <u32> }`: identical semantics to
|
||||
`…/sub`; no `bound` field (dial establishers do not bind).
|
||||
- Failure: the same typed surface as `…/sub` minus `unknown_resource`,
|
||||
plus the schema/`invalid_input` class for malformed targets.
|
||||
|
||||
## The Listen Open Op's Reply — the `bound` and `listen` Fields
|
||||
## The Listen Open Op's Reply: the `bound` and `listen` Fields
|
||||
|
||||
A bind-first listen open (ADR-008 §1, served by `…/sub` with the
|
||||
bind-first establisher) MAY add optional, additive reply fields:
|
||||
@@ -133,16 +133,16 @@ bind-first establisher) MAY add optional, additive reply fields:
|
||||
(`{host, port}` / `{path}`); the REAL OS-chosen address (port `0`
|
||||
resolved).
|
||||
- `listen` is the correlation token (ADR-008 Amendment 1): a u32 the
|
||||
establisher minted, unique per open on that producer — the key the
|
||||
establisher minted, unique per open on that producer; the key the
|
||||
forwarded op's params carry (§The Forwarded Op below). Riding the
|
||||
reply is what makes it relay-transparent: reply fields pass through
|
||||
hub re-produce untouched (alkcall ADR-051), and the same minted
|
||||
token reappears in every hop's reply.
|
||||
- Present only when the establisher supplies them; absent for dial
|
||||
establishers and accept-first listen opens. Old consumers ignore
|
||||
unknown reply fields (plain JSON) — wire-additive.
|
||||
unknown reply fields (plain JSON), which is wire-additive.
|
||||
- `bound` is informational surface, not addressing: a consumer MUST
|
||||
NOT treat `bound` as a dial target (the producer owns the backing —
|
||||
NOT treat `bound` as a dial target (the producer owns the backing,
|
||||
ADR-001). It exists for RFC-1928-reply-class fidelity (BND.ADDR)
|
||||
and observability.
|
||||
|
||||
@@ -150,12 +150,17 @@ bind-first establisher) MAY add optional, additive reply fields:
|
||||
|
||||
Accept-as-open (ADR-008 §3): when a bind-first listener accepts, the
|
||||
PRODUCER opens a channel back toward the listen-opener side. The
|
||||
producer *calls* the op (the opener SERVES it — the ADR-047 §5
|
||||
producer *calls* the op (the opener SERVES it, per the ADR-047 §5
|
||||
connection-owner rule: the serving side allocates the `channel_id`,
|
||||
the producer-side initiator adopts). `Sub`-typed, ALPN marker
|
||||
`alk/tunnel`; no establisher (the data plane starts immediately).
|
||||
`alk/tunnel`; the serving side registers establisher-less by base
|
||||
shape (the data plane starts immediately) or (the recommended shape,
|
||||
ADR-008 Amendment 2) with the correlation-gate establisher variant
|
||||
(a stale/missing token then fails the op as a typed
|
||||
`channel:open_failed` with `details.reason = "unknown_resource"`; the
|
||||
wire vocabulary is unchanged by the amendment).
|
||||
Scope gate (opener side): `["tunnel:forwarded"]`
|
||||
(`TUNNEL_FORWARDED_SCOPE`) — ingress, a separate grant from
|
||||
(`TUNNEL_FORWARDED_SCOPE`): ingress, a separate grant from
|
||||
`tunnel:open`/`tunnel:direct` (ADR-006 Amendment 1).
|
||||
|
||||
### Input (params)
|
||||
@@ -168,23 +173,23 @@ Scope gate (opener side): `["tunnel:forwarded"]`
|
||||
}
|
||||
```
|
||||
|
||||
- `listen`: integer > 0, required — the correlation token from the
|
||||
- `listen`: integer > 0, required: the correlation token from the
|
||||
listen open's `listen` reply field (ADR-008 Amendment 1; the
|
||||
producer-minted token, relay-transparent). The opener correlates
|
||||
the accept to its waiting listener session by matching it. The
|
||||
schema pins `"minimum": 1` and the serde type pins it as the
|
||||
backstop (a `listen: 0` on the wire is a malformed open, rejected
|
||||
at parse time — never silently accepted).
|
||||
- `substrate`: string, required, enum — the listen tunnel's
|
||||
at parse time, never silently accepted).
|
||||
- `substrate`: string, required, enum: the listen tunnel's
|
||||
substrate; shapes the forwarded channel's data-plane framing.
|
||||
- `peer`: object, required — the accepted connection's remote address,
|
||||
- `peer`: object, required: the accepted connection's remote address,
|
||||
substrate-shaped (`{host, port}` / `{path}`). Reply#2-class
|
||||
fidelity's carrier (ask #2).
|
||||
- Schema bounds (wire-stable, mirroring the direct op's target
|
||||
sub-schema): the per-substrate `peer` sub-schema is an `allOf` of
|
||||
two `if`/`then` branches keyed on the sibling `substrate`, each
|
||||
branch's object carrying `"type": "object"` and
|
||||
`"additionalProperties": false` —
|
||||
branch's object carrying `"type": "object"` and
|
||||
`"additionalProperties": false`:
|
||||
- `tcp`/`udp` → `{ "host": {"type": "string"}, "port": {"type":
|
||||
"integer", "minimum": 0, "maximum": 65535} }`, both required;
|
||||
- `unix` → `{ "path": {"type": "string"} }`, required.
|
||||
@@ -194,16 +199,22 @@ Scope gate (opener side): `["tunnel:forwarded"]`
|
||||
|
||||
### Reply / errors
|
||||
|
||||
- Success: `{ "channel_id": <u32> }` — the opener-allocated ID the
|
||||
- Success: `{ "channel_id": <u32> }`: the opener-allocated ID the
|
||||
producer adopts, then pumps the accepted halves against.
|
||||
- Failure: a producer-side accept correlation miss is the typed
|
||||
`channel:open_failed`/`handler_error`-class error; an opener without
|
||||
- Failure: a stale/missing correlation token is the typed
|
||||
`channel:open_failed` with `details.reason = "unknown_resource"`
|
||||
(the establisher variant, ADR-008 Amendment 2, which is the
|
||||
recommended serving shape; the miss never births a channel). An opener without
|
||||
the scope is `FORBIDDEN`; an old opener that does not serve the op
|
||||
rejects loudly (`NOT_FOUND` — the SSH posture).
|
||||
rejects loudly (`NOT_FOUND`: the SSH posture). Other open failures
|
||||
ride the existing typed vocabulary. Under the base (establisher-
|
||||
less) serving shape an in-session miss produces no call error; it
|
||||
is the assembly-side post-open decline (the channel reaps at
|
||||
birth, `correlation-miss` at `debug`).
|
||||
|
||||
## The Data Plane
|
||||
|
||||
One data stream per direction on one channel — the channel ID is the
|
||||
One data stream per direction on one channel: the channel ID is the
|
||||
flow key (no conn-id, no sub-demux). Two codecs by substrate
|
||||
(ADR-003):
|
||||
|
||||
@@ -214,7 +225,7 @@ overhead is the channels 8-byte chunk header. A zero-length read is
|
||||
genuinely EOF (stream semantics).
|
||||
|
||||
Both pumps run `alkcall::channels::pump_bidi(channel, peer_read,
|
||||
peer_write)` — the two-pump contract (alknet ADR-078, pinned upstream
|
||||
peer_write)`, the two-pump contract (alknet ADR-078, pinned upstream
|
||||
ADR-050): when one direction's source EOFs, the pump shuts the
|
||||
OPPOSITE sink down (the EOF sentinel crosses the mux; the peer sees a
|
||||
clean half-close), and the helper completes when both pumps finish,
|
||||
@@ -223,15 +234,15 @@ returning `(u64, u64)` copy counts. Copy errors are EOF-shaped
|
||||
|
||||
### Datagram substrate (`udp`): mandatory length framing
|
||||
|
||||
Every datagram rides `[len: u16 BE][datagram]` on BOTH directions —
|
||||
mandatory for correctness (the F-2 rationale lives in ADR-003; the
|
||||
Every datagram rides `[len: u16 BE][datagram]` on BOTH directions;
|
||||
this is mandatory for correctness (the F-2 rationale lives in ADR-003; the
|
||||
short form: raw pass-through cannot carry an empty datagram). Under
|
||||
the codec:
|
||||
|
||||
- **Framing (`frame_datagram`):** `len: u16 BE` + payload. Datagrams
|
||||
> 65535 bytes are rejected at frame time (`Oversize`) — never a
|
||||
> 65535 bytes are rejected at frame time (`Oversize`), never a
|
||||
wire overflow (the u16 length field would wrap).
|
||||
- **Decoding (`DatagramReader`):** incremental — datagrams split
|
||||
- **Decoding (`DatagramReader`):** incremental; datagrams split
|
||||
across channel chunks, batch into single chunks, and survive
|
||||
partial headers. The decoder buffers state across reads; it is the
|
||||
only consumer-visible decode path.
|
||||
@@ -242,7 +253,7 @@ the codec:
|
||||
returns `Some(bytes)` per datagram (possibly empty), `None` only on
|
||||
stream EOF.
|
||||
- **Chunk-size independence:** datagram boundaries survive chunk
|
||||
splitting, batching, and any channels-layer re-chunking — verified
|
||||
splitting, batching, and any channels-layer re-chunking, verified
|
||||
end-to-end at 7-byte chunk splits (forward POC).
|
||||
- **MTU discipline:** 1400-byte datagrams (a conservative MTU-safe
|
||||
payload, not the theoretical 1472 max) ride the bounded-buffer path;
|
||||
@@ -251,10 +262,10 @@ the codec:
|
||||
- **Truncation:** silent truncation never happens (OQ-TN-13, resolved
|
||||
fail-loud per ADR-003): the `local` adapter recvs on a full
|
||||
65535-byte scratch (UDP `try_recv` truncates silently at the buffer
|
||||
size — a smaller scratch would corrupt the framing) and a
|
||||
size; a smaller scratch would corrupt the framing) and a
|
||||
>caller-buffer frame is staged across reads, the length prefix
|
||||
intact, so the far-side de-framer reassembles the true length. A
|
||||
wire-level over-bound datagram (>65535 — impossible for a
|
||||
wire-level over-bound datagram (>65535, impossible for a
|
||||
well-formed peer) is a loud error at the frame path.
|
||||
|
||||
### Sentinels and lifecycle (both codecs)
|
||||
@@ -271,10 +282,10 @@ the codec:
|
||||
idle-expiry mapping is a producer-registry concern, not wire).
|
||||
- **No establishment frame.** The open op's reply IS the
|
||||
establishment result (ADR-049); the pre-ADR-049
|
||||
"JSON ack before data" hunch is superseded — the wire carries zero
|
||||
"JSON ack before data" hunch is superseded; the wire carries zero
|
||||
tunnel-level control framing in v1.
|
||||
- **BAST:** the binary framing (the UDP codec) carries its BAST
|
||||
document — [bast.md](bast.md) (AGENTS.md convention 12; the
|
||||
document in [bast.md](bast.md) (AGENTS.md convention 12; the
|
||||
stream pass-through has no binary framing to describe).
|
||||
|
||||
## Normative byte diagrams
|
||||
@@ -296,11 +307,11 @@ UDP substrate, one direction's wire view:
|
||||
|
||||
## Open Questions
|
||||
|
||||
- **OQ-TN-13**: resolved (fail-loud, ADR-003) — see the Truncation
|
||||
- **OQ-TN-13**: resolved (fail-loud, ADR-003); see the Truncation
|
||||
bullet
|
||||
- **OQ-TN-02**: resolved — see the ADR-003 mandate
|
||||
- **OQ-TN-15**: resolved — see ADR-007 (the direct op)
|
||||
- **OQ-TN-16**: resolved — see ADR-008 (the `bound`/`listen` fields +
|
||||
- **OQ-TN-02**: resolved; see the ADR-003 mandate
|
||||
- **OQ-TN-15**: resolved; see ADR-007 (the direct op)
|
||||
- **OQ-TN-16**: resolved; see ADR-008 (the `bound`/`listen` fields +
|
||||
the forwarded op)
|
||||
|
||||
## References
|
||||
@@ -322,5 +333,5 @@ UDP substrate, one direction's wire view:
|
||||
ADR-050 (`pump_bidi`); alknet ADR-071/093 (channels wire)
|
||||
- `docs/research/tunnels-graduation.md` (the graduation research the
|
||||
new ops implement); the upstream asks (review 008) landed in
|
||||
alkcall 0.8.0 — see
|
||||
alkcall 0.8.0; see
|
||||
`alkcall/docs/reviews/008-tunnels-graduation-upstream-review.md`
|
||||
@@ -0,0 +1,142 @@
|
||||
# Plan: resolve review 003 (the correlation-miss arrival class)
|
||||
|
||||
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, which extend the review and do not contradict it).
|
||||
|
||||
## Verified findings (the ground the plan stands on)
|
||||
|
||||
The review's claims held under code verification. Two additions sharpen
|
||||
the resolution scope:
|
||||
|
||||
- **A third vocabulary variant.** alksocks (`src/composed/mod.rs:497`)
|
||||
logs the same miss class as prose at `warn` ("forwarded open with no
|
||||
waiting session"). So the family names the stale-token miss three
|
||||
ways: alkssh `correlation-miss` (debug), alksocks prose (warn),
|
||||
alktunnels `handler_error`-class generic arm (warn, call-plane
|
||||
failures only).
|
||||
- **The in-session miss is fully silent on the fabric side.** The
|
||||
forwarded op is served establisher-less everywhere (alkssh,
|
||||
alksocks, and this crate's own built-in shape), so an in-session
|
||||
correlation miss (route removed while the fabric connection lives)
|
||||
succeeds at the call plane, declines post-open in the assembly, and
|
||||
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, 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
|
||||
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
|
||||
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
|
||||
vocabulary (`wire.md` §The Open Ops, `details.reason ∈`)
|
||||
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,
|
||||
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
|
||||
registration surface for the forwarded op. The base
|
||||
`register_tunnel_forwarded_openable` shape stays (compatibility +
|
||||
assemblies that prefer the post-open shape); the establisher
|
||||
variant is the recommended shape and new assemblies take it by
|
||||
default posture.
|
||||
|
||||
## Work items
|
||||
|
||||
### 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
|
||||
(decision 1-4 above), the residual post-open decline keeps the
|
||||
`correlation-miss` name. Also record the tri-vocabulary history the
|
||||
decision retires (review 003 + this plan).
|
||||
- API: an establisher variant for the forwarded-op serving half. The
|
||||
protocol cannot hold the assembly's correlation map, so the
|
||||
establisher's check is injected. Candidate shape (a W1 design
|
||||
decision to finalize): a small assembly gate closure
|
||||
(`(TunnelForwardedParams) -> Result<(), TunnelEstablishError>`) that
|
||||
the crate wraps into the alkcall `OpenEstablisher` dance, plus
|
||||
`register_tunnel_forwarded_openable_with_establisher(core,
|
||||
on_registry, auth, gate, handler)`. Assemblies must not hand-roll
|
||||
the alkcall establisher plumbing.
|
||||
- Tests: miss at the gate → `channel:open_failed` with reason
|
||||
`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).
|
||||
- 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)
|
||||
|
||||
- Move `pump_forwarded_arrival`'s route lookup into the new gate
|
||||
(the `ListenRouter` lookup becomes the establisher's check); the
|
||||
residual handler keeps `decline_arrival("correlation-miss", …)` 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; producer-side generic arm 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
|
||||
("cleanup rides the code-update release"; the user-owned task, out
|
||||
of this plan).
|
||||
|
||||
### 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
|
||||
logging aligns to `correlation-miss` at `debug`).
|
||||
- Fold alksocks' em-dash doc cleanup into its release for this change
|
||||
(user-owned, out of this plan).
|
||||
|
||||
## Ordering
|
||||
|
||||
W1 → {W2, W3} (the dependent waves can run in parallel; each is its
|
||||
own session). Em-dash cleanup rides each repo's release for its wave
|
||||
and is otherwise out of scope here.
|
||||
|
||||
## Acceptance criteria (plan level)
|
||||
|
||||
- [x] W1: ADR landed; `…_with_establisher` surface landed; tests green
|
||||
(`cargo test`, clippy `--all-targets -D warnings`, fmt, wasm
|
||||
check; `--all-features` as far as features exist today); review
|
||||
003 status updated to resolved (W1 absorbed; W2/W3 pointed).
|
||||
- [ ] W2: alkssh serves the gate shape; stale-token arrival produces a
|
||||
typed call-plane failure; residual class named
|
||||
`correlation-miss` at `debug`; alkssh release includes its doc
|
||||
cleanup.
|
||||
- [ ] W3: alksocks serves the gate shape; prose variant retired;
|
||||
residual logging aligned; release includes its doc cleanup.
|
||||
- [ ] The family names the class one way in its event surface;
|
||||
composed `-R` traces read coherently end to end.
|
||||
|
||||
## References
|
||||
|
||||
- `docs/reviews/003-correlation-miss-arrival-class-alkssh-input.md`
|
||||
(the ask; this plan resolves it)
|
||||
- `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)
|
||||
- `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)
|
||||
+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.
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
# Review 003 — The correlation-miss arrival class: alkssh consumer input on the forwarded-arrival event vocabulary
|
||||
|
||||
## Status
|
||||
|
||||
**Resolved — W1 absorbed in alktunnels (2026-11-03); W2/W3 pointed at
|
||||
the sibling repos.** The ask rode
|
||||
`docs/plans/003-correlation-miss-resolve.md` §Decisions 1–4 and ADR-008
|
||||
Amendment 2 (`docs/architecture/decisions/008-listen-metadata-forwarded-op.md`):
|
||||
|
||||
- The forwarded op's serving side gained a correlation-gate establisher
|
||||
variant (`register_tunnel_forwarded_openable_with_establisher`): a
|
||||
stale/missing token fails the open as a typed
|
||||
`channel:open_failed` with `details.reason = "unknown_resource"`
|
||||
before the channel is born — framing question 2 answered (the class
|
||||
rides the existing typed vocabulary; `unknown_resource` reused, no
|
||||
wire extension).
|
||||
- Framing question 1 answered: `correlation-miss` (alkssh's shipped
|
||||
name — published wins) is the canonical event name for the residual
|
||||
post-open decline class; framing question 3's posture is recorded:
|
||||
declines at `debug`, assembly-side; the producer-side generic arm
|
||||
stays `warn`, now carrying the typed reason.
|
||||
- The base (establisher-less) serving shape stays byte-for-byte for
|
||||
compatibility + as the residual-race fallback.
|
||||
|
||||
**W2 (alkssh) and W3 (alksocks) are the sibling repos' adoption waves
|
||||
and remain open** — pointed at
|
||||
`docs/plans/003-correlation-miss-resolve.md` §W2/§W3; the plan's
|
||||
plan-level AC records them unticked until those repos land.
|
||||
|
||||
Doc-only in code impact. No wire contract, no API shape, no ACL
|
||||
behavior rides it — the ask is observability vocabulary alignment at
|
||||
the arrival boundary, so a composed deployment's traces name the same
|
||||
failure the same way in both crates.
|
||||
|
||||
## The situation
|
||||
|
||||
The `-R` leg composes across this crate's fabric and the door's
|
||||
arrival handling. A `forwarded-tcpip` / `forwarded-streamlocal`
|
||||
arrival can outlive its correlation partner on either side, and each
|
||||
crate currently names the resulting class differently:
|
||||
|
||||
- **alkssh's door side** (verified at `src/tunnel_map.rs`): the shared
|
||||
per-fabric-leg `ListenRouter` removes a session's tokens at
|
||||
teardown (the handler's `Drop`), so a fabric arrival for a token
|
||||
that no longer routes — or an echo-shape mismatch against the held
|
||||
route — is the **`correlation-miss`** class:
|
||||
`observe::decline_arrival(row, "correlation-miss", listen)` +
|
||||
teardown-at-birth, never a half-open SSH channel (also the
|
||||
`cap-exceeded` sibling class on a routed-but-full arrival).
|
||||
- **alktunnels' fabric side** (`src/producer.rs`, the bind-first
|
||||
accept loop): a forwarded open that fails against a stale/expired
|
||||
token lands in the generic failure arm — warn
|
||||
`"tunnel bind-first: forwarded open failed; dropping the accepted
|
||||
connection"`, drop, keep serving. The comment records the class
|
||||
distinctions ("scope-less opener → FORBIDDEN; old opener →
|
||||
NOT_FOUND; correlation miss → handler_error-class") — i.e. the
|
||||
distinction exists as a code comment and flows into the generic
|
||||
`handler_error` typed class (the producer error-mapping tables use
|
||||
`dial_failed` / `resource_shortage` / `unknown_resource` /
|
||||
`handler_error`).
|
||||
|
||||
The mismatch arms are the "unreachable" ones in a healthy run — a
|
||||
vanilla client never initiates `forwarded-tcpip` (an inbound open is
|
||||
the unvanilla class, declined), and a correlation miss requires a
|
||||
fabric arrival racing its session's teardown — which is exactly why
|
||||
the naming was left unsettled during implementation. But when it does
|
||||
happen (teardown races, retries, a hub relaying arrivals after a
|
||||
door restart), it is an operator-visible event and the one place a
|
||||
composed `-R` deployment's logs mix vocabularies: alkssh-side the
|
||||
trace says `correlation-miss`, fabric-side it says
|
||||
`handler_error` at warn level with the distinction recoverable only
|
||||
from prose in a comment.
|
||||
|
||||
## What a resolution decides (framing, not prescription)
|
||||
|
||||
1. The shared name and semantics for the stale-token arrival class —
|
||||
alkssh's `correlation-miss` is a candidate already shipped in a
|
||||
published consumer's events; adopting it (or ruling a different
|
||||
canonical name) is this repo's call.
|
||||
2. Whether the class distinction rides the typed error vocabulary
|
||||
(e.g. a `unknown_resource`-adjacent class for the stale-token
|
||||
forwarded open, which the bind-first arm could surface without
|
||||
behavior change) or stays a logging convention.
|
||||
3. Whether the bind-first failure arm's level/posture aligns with the
|
||||
door's (alkssh logs these declines at `debug` under its decline
|
||||
classes; alktunnels logs the generic arm at `warn`). Either answer
|
||||
is fine; the value is that it becomes recorded posture rather than
|
||||
two undocumented choices.
|
||||
|
||||
## References
|
||||
|
||||
- alkssh `src/tunnel_map.rs` — the `ListenRouter` doc (tokens removed
|
||||
at session teardown; "a fabric arrival after the session is gone is
|
||||
the correlation-miss class") and the `decline_arrival` sites
|
||||
(`correlation-miss`, `cap-exceeded`)
|
||||
- alkssh `src/observe.rs` — the door's field/level vocabulary (rows,
|
||||
`decline_class`, `reason`; declines at `debug`)
|
||||
- alkssh `docs/architecture/mapping.md` — the `forwarded-tcpip` row
|
||||
("correlation-miss → teardown-at-birth") and the request-level `signal`
|
||||
row's fidelity notes (OQ-10 lineage)
|
||||
- alkssh `tasks/implementation/review-wave-3.md` item 5 and
|
||||
`docs/plans/implementation.md` ("Still booked with their owners") —
|
||||
the original booking
|
||||
- `src/producer.rs` — the bind-first accept-loop failure arm (the
|
||||
in-comment class distinction) and the establisher error-mapping
|
||||
tables (`docs/architecture/producer.md` shape 3, ADR-008)
|
||||
- ADR-008 (bind-first + forwarded op; the mint discipline whose
|
||||
staleness races this class guards)
|
||||
+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).
|
||||
|
||||
+47
-47
@@ -1,4 +1,4 @@
|
||||
//! The consumer half: `TunnelSession` — the typed client for tunnel
|
||||
//! The consumer half: `TunnelSession`, the typed client for tunnel
|
||||
//! channels (ADR-005). Forward path (`open`), reverse path (`adopt`
|
||||
//! and `open_reverse_channel`), substrate-shaped data planes
|
||||
//! (`stream_halves`, `take_halves`, `send_datagram`, `recv_datagram`),
|
||||
@@ -6,7 +6,7 @@
|
||||
//! (`close`, `join`, `Drop`).
|
||||
//!
|
||||
//! One session = one channel = one tunnel (the channel ID is the flow
|
||||
//! key, OQ-TN-02). The session owns the adopted channel entry —
|
||||
//! key, OQ-TN-02). The session owns the adopted channel entry;
|
||||
//! nothing upstream awaits the adopter's pump (the W3 gap); the
|
||||
//! session closes it structurally. Role follows the resource, not the
|
||||
//! connection (OQ-TN-03): `-L` and `-R` consumers share this one API.
|
||||
@@ -44,7 +44,7 @@ use crate::wire::{frame_datagram, DatagramReader};
|
||||
///
|
||||
/// Fields are `Option` so `take_halves`/`pump_against` can move the
|
||||
/// data plane and pump out of the `Drop`-impl type; an emptied session
|
||||
/// drops as a no-op (teardown is idempotent — an already-reaped entry
|
||||
/// drops as a no-op (teardown is idempotent; an already-reaped entry
|
||||
/// reaps `Err(UnknownChannel)`, ignored).
|
||||
pub struct TunnelSession {
|
||||
pub channel_id: u32,
|
||||
@@ -54,7 +54,7 @@ pub struct TunnelSession {
|
||||
}
|
||||
|
||||
enum DataPlane {
|
||||
/// Raw pass-through (stream substrates: `tcp`, `unix`) — the
|
||||
/// Raw pass-through (stream substrates: `tcp`, `unix`): the
|
||||
/// halves ARE the tunnel (ADR-003); zero tunnel-level framing.
|
||||
Stream(StreamHalves),
|
||||
/// The mandatory `[len: u16 BE]` codec over the halves (`udp`,
|
||||
@@ -71,7 +71,7 @@ struct DatagramHalves {
|
||||
read: ReadHalf<BiStream>,
|
||||
write: WriteHalf<BiStream>,
|
||||
reader: DatagramReader,
|
||||
/// Datagrams decoded from a chunk beyond the one returned — a
|
||||
/// Datagrams decoded from a chunk beyond the one returned; a
|
||||
/// single chunk may batch several frames (the codec decodes all
|
||||
/// completions per feed); they queue here for the next
|
||||
/// `recv_datagram`.
|
||||
@@ -84,7 +84,7 @@ impl TunnelSession {
|
||||
/// (`ChannelClient::open_channel(OP_TUNNEL_OPEN, params,
|
||||
/// TUNNEL_ALPN)`), adopts the returned channel ID, splits the
|
||||
/// channel `BiStream`, and presents the substrate-shaped data
|
||||
/// plane. A failed open never yields a session (typed errors —
|
||||
/// plane. A failed open never yields a session (typed errors,
|
||||
/// ADR-049 §4; no phantom session).
|
||||
pub async fn open(
|
||||
client: &ChannelClient,
|
||||
@@ -108,22 +108,22 @@ impl TunnelSession {
|
||||
}
|
||||
|
||||
/// Open a tunnel channel toward a CALLER-NAMED target (the direct
|
||||
/// op — dynamic-target egress, ADR-007; the ssh `direct-tcpip` /
|
||||
/// op: dynamic-target egress, ADR-007; the ssh `direct-tcpip` /
|
||||
/// SOCKS5 CONNECT shape). A separate constructor from [`open`](Self::open),
|
||||
/// not a params enum: `tunnel:direct` and `tunnel:open` are two
|
||||
/// scopes — two capabilities — and the caller states which one it
|
||||
/// scopes (two capabilities), and the caller states which one it
|
||||
/// holds.
|
||||
///
|
||||
/// Serializes `{substrate, target}`, calls
|
||||
/// `open_channel(OP_TUNNEL_DIRECT, …, TUNNEL_ALPN)` (plain
|
||||
/// `open_channel` — dial establishers never bind, so no reply
|
||||
/// `open_channel`; dial establishers never bind, so no reply
|
||||
/// fields are expected), and presents the identical session type,
|
||||
/// data planes, and teardown API as [`open`](Self::open).
|
||||
///
|
||||
/// Error surface: the base open errors minus `unknown_resource`
|
||||
/// (no registry lookup); a malformed target is the schema /
|
||||
/// `invalid_input`-class failure (registry-side); an old producer
|
||||
/// rejects the unknown op loudly (`NOT_FOUND` — the SSH posture).
|
||||
/// rejects the unknown op loudly (`NOT_FOUND`; the SSH posture).
|
||||
pub async fn open_direct(
|
||||
client: &ChannelClient,
|
||||
target: SubstrateAddr,
|
||||
@@ -150,13 +150,13 @@ impl TunnelSession {
|
||||
/// Adopt a producer-allocated channel ID (the reverse path, `-R`):
|
||||
/// install the data plane from the adopted halves. Early arrivals
|
||||
/// are parked by the manager and drained on adopt (the adoption
|
||||
/// race — ADR-047 §5). An adopt failure is `AdoptFailed`-class on
|
||||
/// race, ADR-047 §5). An adopt failure is `AdoptFailed`-class on
|
||||
/// the typed error surface, not an establishment error.
|
||||
///
|
||||
/// The substrate shapes the data plane (raw halves vs the UDP
|
||||
/// codec) and rode the open call that produced the `channel_id`;
|
||||
/// `adopt` takes it so the session presents the right variant (the
|
||||
/// task sketch omitted it — a datagram session cannot exist without
|
||||
/// task sketch omitted it: a datagram session cannot exist without
|
||||
/// it; documented in the task notes).
|
||||
pub async fn adopt(
|
||||
manager: &ChannelManager,
|
||||
@@ -204,7 +204,7 @@ impl TunnelSession {
|
||||
}
|
||||
|
||||
/// Borrow the raw halves (stream substrates). The halves ARE the
|
||||
/// tunnel — raw pass-through (ADR-003). For owned access, use
|
||||
/// tunnel: raw pass-through (ADR-003). For owned access, use
|
||||
/// [`TunnelSession::take_halves`]. `None` for datagram sessions
|
||||
/// (drive those with `send_datagram`/`recv_datagram`) or after the
|
||||
/// halves were taken.
|
||||
@@ -226,11 +226,11 @@ impl TunnelSession {
|
||||
|
||||
/// Take the raw halves (either substrate): consumes the session's
|
||||
/// data plane into owned boxed halves. The session stays alive as
|
||||
/// the channel-entry owner — reap with `close`/`join`/`Drop`
|
||||
/// the channel-entry owner; reap with `close`/`join`/`Drop`
|
||||
/// (`join` on a pump-less session completes immediately, reaps
|
||||
/// only, `(0, 0, reaped)`). The halves may be taken once; later
|
||||
/// data-plane calls fail with [`TunnelIoError::ChannelTaken`].
|
||||
/// Datagram halves carry the codec framing (ADR-003) — raw bytes
|
||||
/// Datagram halves carry the codec framing (ADR-003): raw bytes
|
||||
/// on the wire are `[len: u16 BE][payload]` frames.
|
||||
pub fn take_halves(mut self) -> TakenHalves {
|
||||
let manager = self.manager.take();
|
||||
@@ -261,7 +261,7 @@ impl TunnelSession {
|
||||
|
||||
/// UDP: send one datagram (frame → write → flush; ADR-003's
|
||||
/// mandatory framing). `Oversize` above 65535 (the u16 length
|
||||
/// field would wrap). Empty payloads are legal (`len=0` — the
|
||||
/// field would wrap). Empty payloads are legal (`len=0`, the
|
||||
/// F-2 layering).
|
||||
pub async fn send_datagram(&mut self, payload: &[u8]) -> Result<(), TunnelIoError> {
|
||||
match self.data.as_mut() {
|
||||
@@ -278,7 +278,7 @@ impl TunnelSession {
|
||||
|
||||
/// UDP: datagrams decoded from a chunk beyond the one returned to
|
||||
/// the caller, queued for the next [`recv_datagram`](Self::recv_datagram)
|
||||
/// (the `DatagramHalves.pending` backlog — the diagnostic the
|
||||
/// (the `DatagramHalves.pending` backlog, the diagnostic the
|
||||
/// wave-2 fuzz harness asserts against, `docs/research/fuzzing.md`
|
||||
/// §3.3). `None` for stream sessions or after the halves were
|
||||
/// taken (the [`stream_halves`](Self::stream_halves) Option
|
||||
@@ -292,8 +292,8 @@ impl TunnelSession {
|
||||
}
|
||||
|
||||
/// UDP: receive the next datagram (boundary-preserving, the
|
||||
/// incremental decode path — the only consumer-visible decode
|
||||
/// path). `Some(bytes)` per datagram — possibly empty (`len=0`
|
||||
/// incremental decode path; the only consumer-visible decode
|
||||
/// path). `Some(bytes)` per datagram, possibly empty (`len=0`
|
||||
/// legal); `None` only on stream EOF (the channels-level sentinel;
|
||||
/// the codec never collides with it, F-2). A stream end
|
||||
/// mid-datagram is [`TunnelIoError::TruncatedDatagram`]
|
||||
@@ -321,14 +321,14 @@ impl TunnelSession {
|
||||
/// Spawn `pump_bidi(channel_halves, accepted_read, accepted_write)`
|
||||
/// against an accepted local connection and hold the returned
|
||||
/// handle (the reverse-flow use; ADR-005). Builder-style: returns
|
||||
/// the session, which now owns the pump — `close` aborts it,
|
||||
/// the session, which now owns the pump: `close` aborts it,
|
||||
/// `join` awaits it, `Drop` aborts it. Consumes the data plane
|
||||
/// (the pump owns the channel halves from here).
|
||||
///
|
||||
/// For UDP sessions the accepted halves must present the same
|
||||
/// framing as the channel side (ADR-003): hand the FRAMED adapter
|
||||
/// (the `local` feature's framing shape, or an assembly
|
||||
/// equivalent), never a raw UDP socket — the pump copies raw
|
||||
/// equivalent), never a raw UDP socket; the pump copies raw
|
||||
/// bytes and cannot know which side carries the `[len: u16 BE]`
|
||||
/// framing.
|
||||
pub async fn pump_against<S>(mut self, accepted: S) -> Self
|
||||
@@ -370,7 +370,7 @@ impl TunnelSession {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Await pump completion (both directions finished — half-close
|
||||
/// Await pump completion (both directions finished; half-close
|
||||
/// semantics fall out of `pump_bidi`, W4), THEN reap the adopted
|
||||
/// channel. Returns the `(u64, u64)` copy counts for
|
||||
/// observability plus the reap result. **Pump-less sessions**
|
||||
@@ -401,7 +401,7 @@ impl TunnelSession {
|
||||
|
||||
impl Drop for TunnelSession {
|
||||
/// Abort the pump and sync-reap the channel (`teardown_channel` is
|
||||
/// sync — best-effort; `Drop` cannot await). Dropping without
|
||||
/// sync, best-effort; `Drop` cannot await). Dropping without
|
||||
/// close/join never leaks the channel entry (ADR-005).
|
||||
fn drop(&mut self) {
|
||||
if let Some(pump) = self.pump.take() {
|
||||
@@ -422,29 +422,29 @@ pub struct TakenHalves {
|
||||
pub write: Box<dyn AsyncWrite + Send + Unpin>,
|
||||
}
|
||||
|
||||
/// The listen-flow consumer session (ADR-008 §4) — the sibling of
|
||||
/// [`TunnelSession`]: it OPENS a listen tunnel (`channels/tunnel/sub`
|
||||
/// — the establisher shape is the producer's per-registration choice,
|
||||
/// The listen-flow consumer session (ADR-008 §4), the sibling of
|
||||
/// [`TunnelSession`]: it OPENS a listen tunnel (`channels/tunnel/sub`:
|
||||
/// the establisher shape is the producer's per-registration choice,
|
||||
/// invisible here), reads the bind-first reply fields (`bound` /
|
||||
/// `listen`) via the lenient [`crate::params::listen_reply_fields`]
|
||||
/// projection, and owns the listen channel's teardown (ADR-005's
|
||||
/// ownership posture extended to the listen flow).
|
||||
///
|
||||
/// **The held stream (the one new thing):** a listen session's channel
|
||||
/// 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. The struct holds the channel's write half
|
||||
/// (`MpscSendStream`, the mux sender) IDLE and never pumps it; the
|
||||
/// drop → EOF ordering is structural (the mux sender drops → the mux
|
||||
/// pump writes the zero-length EOF sentinel → the far accept
|
||||
/// dispatcher's idle-stream watch fires and tears the far listener
|
||||
/// down — ADR-008 §1's contract). The stream is deliberately not
|
||||
/// down, per ADR-008 §1's contract). The stream is deliberately not
|
||||
/// exposed: pumping on a listen channel is meaningless (the far side's
|
||||
/// dispatcher ignores it too).
|
||||
///
|
||||
/// **Forwarded sessions are NOT owned by the listener.** They arrive
|
||||
/// as ordinary adopted-side [`TunnelSession`]s the assembly correlates
|
||||
/// via the [`listen`](Self::listen) token and owns (the correlation
|
||||
/// registry is assembly-side bookkeeping — consumer.md's posture).
|
||||
/// registry is assembly-side bookkeeping, consumer.md's posture).
|
||||
/// Listener teardown does NOT cascade to already-adopted forwarded
|
||||
/// sessions; the far listener dying stops NEW accepts only.
|
||||
///
|
||||
@@ -458,22 +458,22 @@ pub struct TakenHalves {
|
||||
/// ```
|
||||
///
|
||||
/// Fields are `Option` so `close` can move them out of the `Drop`-impl
|
||||
/// type; an emptied listener drops as a no-op (teardown is idempotent —
|
||||
/// type; an emptied listener drops as a no-op (teardown is idempotent:
|
||||
/// an already-reaped entry reaps `Err(UnknownChannel)`, ignored).
|
||||
pub struct TunnelListener {
|
||||
/// The adopted listen channel's ID (one session = one channel —
|
||||
/// The adopted listen channel's ID (one session = one channel;
|
||||
/// OQ-TN-02's resolution; observability + correlation).
|
||||
pub channel_id: u32,
|
||||
manager: Option<ChannelManager>,
|
||||
/// The `bound` reply field (the real OS-chosen address) — `None`
|
||||
/// The `bound` reply field (the real OS-chosen address); `None`
|
||||
/// for accept-first/dial establishers or old producers (the Option
|
||||
/// posture). Informational surface, NOT addressing.
|
||||
bound: Option<SubstrateAddr>,
|
||||
/// The `listen` reply field (the producer-minted correlation
|
||||
/// token) — `None` for establishers that mint nothing (the Option
|
||||
/// token); `None` for establishers that mint nothing (the Option
|
||||
/// posture).
|
||||
token: Option<u32>,
|
||||
/// The listen channel's write half, held IDLE — the structural
|
||||
/// The listen channel's write half, held IDLE, the structural
|
||||
/// teardown signal (see the type docs). Never pumped, never
|
||||
/// exposed.
|
||||
write: Option<MpscSendStream>,
|
||||
@@ -482,18 +482,18 @@ pub struct TunnelListener {
|
||||
impl TunnelListener {
|
||||
/// Open a listen tunnel (the listen flow, ADR-008 §4). Calls the
|
||||
/// open op through [`ChannelClient::open_channel_with_reply`] (the
|
||||
/// U-2 surface, alkcall 0.8.0) — the full reply arrives, the
|
||||
/// U-2 surface, alkcall 0.8.0); the full reply arrives, the
|
||||
/// bind-first reply fields are projected via
|
||||
/// [`crate::params::listen_reply_fields`], and the already-adopted
|
||||
/// channel entry is held (adopt-free construction — the client
|
||||
/// channel entry is held (adopt-free construction: the client
|
||||
/// adopted it). A failed open never yields a listener (typed
|
||||
/// errors — ADR-049 §4; no phantom session).
|
||||
/// errors, ADR-049 §4; no phantom session).
|
||||
///
|
||||
/// `bound()`/`listen()` surface `None` when the establisher
|
||||
/// supplied nothing (accept-first/dial producers, or old producers
|
||||
/// — the additive posture); `bound` is informational surface and
|
||||
/// supplied nothing (accept-first/dial producers, or old producers;
|
||||
/// the additive posture); `bound` is informational surface and
|
||||
/// MUST NOT be treated as a dial target (the producer owns the
|
||||
/// backing — ADR-001's invariant).
|
||||
/// backing, ADR-001's invariant).
|
||||
pub async fn open(
|
||||
client: &ChannelClient,
|
||||
params: TunnelParams,
|
||||
@@ -516,7 +516,7 @@ impl TunnelListener {
|
||||
})
|
||||
}
|
||||
|
||||
/// The `bound` reply field — the real OS-chosen bound address
|
||||
/// The `bound` reply field: the real OS-chosen bound address
|
||||
/// (port `0` resolved), present only when the establisher supplied
|
||||
/// it (bind-first producers). reply#1-class fidelity (BND.ADDR).
|
||||
/// Informational surface, never addressing.
|
||||
@@ -524,7 +524,7 @@ impl TunnelListener {
|
||||
self.bound.as_ref()
|
||||
}
|
||||
|
||||
/// The `listen` reply field — the correlation token (ADR-008
|
||||
/// The `listen` reply field: the correlation token (ADR-008
|
||||
/// Amendment 1) the producer minted for this listen tunnel. The
|
||||
/// assembly keys its forwarded-op correlation map on it; the
|
||||
/// listener does not own the receipt queue (forwarded sessions
|
||||
@@ -534,7 +534,7 @@ impl TunnelListener {
|
||||
}
|
||||
|
||||
/// Tear the listen tunnel down: shut the held write half (the
|
||||
/// graceful EOF sentinel — the far accept dispatcher's teardown
|
||||
/// graceful EOF sentinel, the far accept dispatcher's teardown
|
||||
/// signal), then reap the adopted channel entry
|
||||
/// (`teardown_channel`). Returns whether the entry existed. The
|
||||
/// far-side teardown cascades (EOF → accept-loop cancel → far
|
||||
@@ -554,8 +554,8 @@ impl TunnelListener {
|
||||
|
||||
impl Drop for TunnelListener {
|
||||
/// Drop the held write half (the mux pump writes the implicit-EOF
|
||||
/// chunk — the far dispatcher's teardown signal) and sync-reap the
|
||||
/// adopted channel entry (`teardown_channel` is sync —
|
||||
/// chunk, the far dispatcher's teardown signal) and sync-reap the
|
||||
/// adopted channel entry (`teardown_channel` is sync,
|
||||
/// best-effort; `Drop` cannot await). Dropping without close never
|
||||
/// leaks the channel entry (ADR-005's Drop posture, listen-
|
||||
/// flavored).
|
||||
@@ -568,7 +568,7 @@ impl Drop for TunnelListener {
|
||||
}
|
||||
|
||||
/// Read from the chunk stream until one complete datagram is
|
||||
/// assembled (the incremental decode path — ADR-003). `Ok(None)` on
|
||||
/// assembled (the incremental decode path, ADR-003). `Ok(None)` on
|
||||
/// stream EOF; mid-datagram EOF is `TruncatedDatagram` (fail-loud).
|
||||
async fn read_one_datagram<R: AsyncRead + Unpin>(
|
||||
reader: &mut DatagramReader,
|
||||
@@ -593,7 +593,7 @@ async fn read_one_datagram<R: AsyncRead + Unpin>(
|
||||
|
||||
/// Call the producer's open op on channel 0 and extract the allocated
|
||||
/// `channel_id` (the reverse path's first step; the POC's shape). The
|
||||
/// optional `auth_token` rides the payload — the hub-forwarding path
|
||||
/// optional `auth_token` rides the payload, the hub-forwarding path
|
||||
/// (precedence: token > `ServingConfig.identity` > transport identity,
|
||||
/// CF-005). The assembly layer then constructs the session with
|
||||
/// [`TunnelSession::adopt`].
|
||||
|
||||
+10
-9
@@ -20,7 +20,7 @@ pub enum TunnelError {
|
||||
/// An I/O failure on the session's data plane.
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
/// A reply field failed its typed projection — the wire carried a
|
||||
/// A reply field failed its typed projection: the wire carried a
|
||||
/// malformed shape (e.g. [`crate::params::parse_listen_reply`]
|
||||
/// found `bound` not substrate-shaped, a non-positive `listen`,
|
||||
/// or a reply with no `channel_id` at all). A misbehaving
|
||||
@@ -30,7 +30,7 @@ pub enum TunnelError {
|
||||
}
|
||||
|
||||
/// A failed tunnel open or adopt. A failure never yields a session
|
||||
/// (no phantom session, mirroring the no-phantom-channel property —
|
||||
/// (no phantom session, mirroring the no-phantom-channel property of
|
||||
/// ADR-005). Branch on [`ChannelOpenError::establishment_reason`]
|
||||
/// through [`TunnelOpenError::open_ref`] for `channel:open_failed`'s
|
||||
/// reason (`dial_failed` / `unknown_resource` / `resource_shortage` /
|
||||
@@ -38,7 +38,7 @@ pub enum TunnelError {
|
||||
/// (`FORBIDDEN`, `channel:too_many_channels`).
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum TunnelOpenError {
|
||||
/// The open op failed, or the reply/adopt failed — the typed
|
||||
/// The open op failed, or the reply/adopt failed. The typed
|
||||
/// [`ChannelOpenError`] carries the wire [`CallError`] verbatim
|
||||
/// (adopt failures surface as `AdoptFailed`).
|
||||
#[error("tunnel open failed: {0}")]
|
||||
@@ -61,11 +61,12 @@ pub enum TunnelIoError {
|
||||
/// The underlying stream failed.
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
/// The datagram codec rejected the operation (e.g. `Oversize` —
|
||||
/// The datagram codec rejected the operation (e.g. `Oversize`:
|
||||
/// a >65535-byte send, rejected at frame time per ADR-003).
|
||||
#[error("codec: {0}")]
|
||||
Codec(#[from] DatagramCodecError),
|
||||
/// The stream ended mid-datagram — declared length not satisfied
|
||||
/// The stream ended mid-datagram; the declared length was not
|
||||
/// satisfied
|
||||
/// (a clean peer never does this; the fail-loud posture, OQ-TN-13
|
||||
/// / ADR-003: never a silent partial datagram).
|
||||
#[error("stream ended mid-datagram (truncated)")]
|
||||
@@ -74,20 +75,20 @@ pub enum TunnelIoError {
|
||||
/// plane (e.g. `send_datagram` on a stream tunnel).
|
||||
#[error("wrong substrate for this operation")]
|
||||
WrongSubstrate,
|
||||
/// The session's channel halves were already taken — by
|
||||
/// The session's channel halves were already taken: by
|
||||
/// `take_halves` (caller-driven) or `pump_against` (pump-owned);
|
||||
/// no data plane remains on the session.
|
||||
#[error("channel halves already taken from this session")]
|
||||
ChannelTaken,
|
||||
}
|
||||
|
||||
/// The reverse-path open call failed (`open_reverse_channel` — the
|
||||
/// The reverse-path open call failed (`open_reverse_channel`; the
|
||||
/// assembly layer calls it before [`crate::consumer::TunnelSession::
|
||||
/// adopt`]; the hub's call surface is a `CallConnection`, so the
|
||||
/// adopt`], and the hub's call surface is a `CallConnection`, so the
|
||||
/// two-step is the honest API).
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ReverseOpenError {
|
||||
/// The open op failed — the wire [`CallError`]; branch on
|
||||
/// The open op failed (the wire [`CallError`]); branch on
|
||||
/// `details.reason` of `channel:open_failed` (ADR-049 §4).
|
||||
#[error("open call failed: {0:?}")]
|
||||
Call(CallError),
|
||||
|
||||
+4
-3
@@ -1,4 +1,4 @@
|
||||
//! alktunnels — arbitrary bidirectional tunnels over alkcall channels.
|
||||
//! alktunnels: arbitrary bidirectional tunnels over alkcall channels.
|
||||
//!
|
||||
//! TCP, UDP, unix-socket, and other stream/datagram substrates, in the
|
||||
//! `ssh -L` / `ssh -D` / `ssh -R` sense. A producer/consumer protocol
|
||||
@@ -6,14 +6,14 @@
|
||||
//! is the sibling precedent): the producer half registers the
|
||||
//! `alk/tunnel` open op and pumps bytes between the channel and the
|
||||
//! substrate; the consumer half is the typed session
|
||||
//! ([`TunnelSession`]) that opens tunnel channels and owns teardown —
|
||||
//! ([`TunnelSession`]) that opens tunnel channels and owns teardown,
|
||||
//! plus [`TunnelListener`] (the listen-flow session, ADR-008) for the
|
||||
//! bind-first listen shape.
|
||||
//!
|
||||
//! A tunnel is a resource, not an address: the open-op params identify
|
||||
//! a produced resource + substrate ([`TunnelParams`], ADR-001); the
|
||||
//! producer owns the backing. The protocol layer is substrate-agnostic
|
||||
//! by construction — socket/platform I/O is confined to the
|
||||
//! by construction: socket/platform I/O is confined to the
|
||||
//! feature-gated `local` backend (the default crate is wasm-clean).
|
||||
//!
|
||||
//! - Wire format: `docs/architecture/wire.md` (+ BAST at `bast.md`)
|
||||
@@ -42,6 +42,7 @@ pub use params::{TUNNEL_ALPN, TUNNEL_OPEN_SCOPE};
|
||||
pub use producer::{
|
||||
bindfirst_establisher, make_bindfirst_accept_handler, make_forwarded_op_handler,
|
||||
register_tunnel_bindfirst_openable, register_tunnel_forwarded_openable,
|
||||
register_tunnel_forwarded_openable_with_establisher, ForwardedGate, ForwardedPlan,
|
||||
};
|
||||
pub use producer::{
|
||||
direct_establisher, direct_establisher_with_witness, register_tunnel_direct_openable,
|
||||
|
||||
+60
-60
@@ -1,39 +1,39 @@
|
||||
//! The `local` feature module: real-socket halves functions the
|
||||
//! assembly layer injects as `DialFn`/`AcceptFn` closures (ADR-004 —
|
||||
//! assembly layer injects as `DialFn`/`AcceptFn` closures (ADR-004:
|
||||
//! "produce boxed halves for a resource" is a function, not a trait).
|
||||
//! Non-wasm by design: this module IS the platform boundary — the
|
||||
//! Non-wasm by design: this module IS the platform boundary; the
|
||||
//! default crate stays wasm-clean (convention 16: `local`-gated code
|
||||
//! is never imported from the shared/producer/consumer modules).
|
||||
//!
|
||||
//! Substrate coverage (overview.md §Feature Gates):
|
||||
//! - **TCP** — [`dial_tcp`] (5s connect timeout per the POCs) and the
|
||||
//! - **TCP**: [`dial_tcp`] (5s connect timeout per the POCs) and the
|
||||
//! [`TcpListenerHalves`] accept half for listen producers.
|
||||
//! - **UDP** — [`connect_udp`]: a connected `UdpSocket` wrapped in the
|
||||
//! FRAMED adapter ([`UdpHalf`], ADR-003 — the codec composes at this
|
||||
//! - **UDP**: [`connect_udp`]: a connected `UdpSocket` wrapped in the
|
||||
//! FRAMED adapter ([`UdpHalf`], ADR-003; the codec composes at this
|
||||
//! boundary: the channel side speaks the `[len: u16 BE]` frames, the
|
||||
//! target side sees payload-only datagrams; the pump never sees UDP
|
||||
//! specifics). Truncation fails loud (OQ-TN-13's resolved posture).
|
||||
//! - **Unix** — [`dial_unix`], in v1 per OQ-TN-14 (same halves shape
|
||||
//! as TCP). **Stdio is NOT here** — a spawned process's stdio is
|
||||
//! - **Unix**: [`dial_unix`], in v1 per OQ-TN-14 (same halves shape
|
||||
//! as TCP). **Stdio is NOT here**: a spawned process's stdio is
|
||||
//! alktty's pipe mode (exit codes + signals are tty semantics);
|
||||
//! remote command execution composes via alktty, not here.
|
||||
//! - **Bind-first** (shape 3, ADR-008) — [`bind_tcp_first`] /
|
||||
//! - **Bind-first** (shape 3, ADR-008): [`bind_tcp_first`] /
|
||||
//! [`bind_unix_first`] (the real-socket [`BindFn`] sources: the
|
||||
//! `bound` reply field's data — the REAL OS-chosen address, port 0
|
||||
//! `bound` reply field's data, the REAL OS-chosen address, port 0
|
||||
//! resolved), [`TcpListenerHalves::accept_pair`] /
|
||||
//! [`UnixListenerHalves::accept_pair`] (one accept per call yielding
|
||||
//! `(peer, halves)` — the [`BindFirstAcceptLoop`] contract), and the
|
||||
//! `(peer, halves)`; the [`BindFirstAcceptLoop`] contract), and the
|
||||
//! assembly convenience closures [`local_bind_first()`] +
|
||||
//! [`local_bindfirst_accept_loop()`]. UDP bind-first is NOT here —
|
||||
//! [`local_bindfirst_accept_loop()`]. UDP bind-first is NOT here:
|
||||
//! a UDP socket has no accept; the per-peer associate shape is a
|
||||
//! separate design decision (the bind closure fails loud for udp).
|
||||
//!
|
||||
//! Error mapping: [`TunnelEstablishError`] variants (the POC's
|
||||
//! `Into<EstablishmentError>` path) — dial refusal → `dial_failed`,
|
||||
//! `Into<EstablishmentError>` path); dial refusal → `dial_failed`,
|
||||
//! bind/resource exhaustion → `resource_shortage`, handler-internal
|
||||
//! → `handler_error`.
|
||||
//!
|
||||
//! `TargetHandle` re-used from producer.rs — the halves type lives
|
||||
//! `TargetHandle` re-used from producer.rs; the halves type lives
|
||||
//! there; `local/` depends on it, never vice versa (convention 16).
|
||||
|
||||
use std::sync::Arc;
|
||||
@@ -52,7 +52,7 @@ const DIAL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
// --- TCP ----------------------------------------------------------------
|
||||
|
||||
/// Dial a TCP target (`host:port`): `TcpStream::connect` bounded by
|
||||
/// [`DIAL_TIMEOUT`], `into_split` → boxed halves (raw pass-through —
|
||||
/// [`DIAL_TIMEOUT`], `into_split` → boxed halves (raw pass-through;
|
||||
/// the halves ARE the tunnel, ADR-003).
|
||||
pub async fn dial_tcp(target: &str) -> Result<TargetHandle, TunnelEstablishError> {
|
||||
let stream = tokio::time::timeout(DIAL_TIMEOUT, tokio::net::TcpStream::connect(target))
|
||||
@@ -67,11 +67,11 @@ pub async fn dial_tcp(target: &str) -> Result<TargetHandle, TunnelEstablishError
|
||||
}
|
||||
|
||||
/// Bind a TCP listener (the listen shape's assembly-side half).
|
||||
/// The binding decision belongs to the caller (OQ-TN-04) — this is a
|
||||
/// The binding decision belongs to the caller (OQ-TN-04); this is a
|
||||
/// `local`-feature convenience, not protocol behavior.
|
||||
///
|
||||
/// **Bind posture (review 001 U-2):** the caller-supplied address is
|
||||
/// deliberate — a wildcard bind (`0.0.0.0` or `::`; note `::1` is
|
||||
/// deliberate: a wildcard bind (`0.0.0.0` or `::`; note `::1` is
|
||||
/// loopback, not a wildcard) is the caller's explicit choice and
|
||||
/// exposes the listener per the host's firewall. There is no implicit
|
||||
/// wildcard default here.
|
||||
@@ -102,7 +102,7 @@ impl TcpListenerHalves {
|
||||
|
||||
/// The accept loop: accept connections until the listener fails,
|
||||
/// pushing each accepted handle into the queue. Returns when the
|
||||
/// listener is closed (accept errors end the loop — the assembly
|
||||
/// listener is closed (accept errors end the loop; the assembly
|
||||
/// layer aborts this task on teardown).
|
||||
pub async fn accept_loop(&self, queue: AcceptQueue) -> Result<(), TunnelEstablishError> {
|
||||
loop {
|
||||
@@ -124,16 +124,16 @@ impl TcpListenerHalves {
|
||||
}
|
||||
}
|
||||
|
||||
/// ONE accept as a `(peer, halves)` pair — the BIND-FIRST shape's
|
||||
/// ONE accept as a `(peer, halves)` pair, the BIND-FIRST shape's
|
||||
/// substrate half (ADR-008 §1): the accept dispatcher invokes the
|
||||
/// injected [`BindFirstAcceptLoop`] closure PER accept; this method
|
||||
/// is what the closure calls. The peer address rides `accept()`'s
|
||||
/// return value (the forwarded op's `peer` carrier — reply#2-class
|
||||
/// fidelity, ADR-008 §3).
|
||||
/// return value (the forwarded op's `peer` carrier,
|
||||
/// reply#2-class fidelity, ADR-008 §3).
|
||||
///
|
||||
/// This is deliberately NOT the [`AcceptQueue`]-feeding
|
||||
/// [`TcpListenerHalves::accept_loop`] (the accept-FIRST shape's
|
||||
/// contract — the establisher pops pre-accepted handles): the
|
||||
/// contract, the establisher pops pre-accepted handles): the
|
||||
/// bind-first dispatcher owns the accept pacing, so one accept per
|
||||
/// closure call with the peer attached is the honest shape.
|
||||
pub async fn accept_pair(&self) -> Result<(SubstrateAddr, TargetHandle), TunnelEstablishError> {
|
||||
@@ -160,16 +160,16 @@ impl TcpListenerHalves {
|
||||
|
||||
/// Connect a UDP "tunnel" to a target: resolve the target, bind an
|
||||
/// ephemeral socket on the MATCHING address family (never an implicit
|
||||
/// wildcard mismatch — a v6-only target dials from a v6-bound socket;
|
||||
/// wildcard mismatch: a v6-only target dials from a v6-bound socket;
|
||||
/// review 001 U-2's posture), `connect(target)` (the
|
||||
/// associated-socket posture — one peer), and wrap it in the FRAMED
|
||||
/// associated-socket posture, one peer), and wrap it in the FRAMED
|
||||
/// adapter (ADR-003): `UdpHalf` composes the `[len: u16 BE]` codec at
|
||||
/// this boundary — the channel side speaks framed bytes, the target
|
||||
/// this boundary: the channel side speaks framed bytes, the target
|
||||
/// side sees payload-only datagrams; the pump never sees UDP
|
||||
/// specifics.
|
||||
pub async fn connect_udp(target: &str) -> Result<TargetHandle, TunnelEstablishError> {
|
||||
use std::net::ToSocketAddrs;
|
||||
// Same posture as tokio's own `UdpSocket::connect(target: &str)` —
|
||||
// Same posture as tokio's own `UdpSocket::connect(target: &str)`;
|
||||
// it resolves inline too; numeric targets (the registry's shape)
|
||||
// make this a non-blocking parse.
|
||||
let resolved = target
|
||||
@@ -200,9 +200,9 @@ pub async fn connect_udp(target: &str) -> Result<TargetHandle, TunnelEstablishEr
|
||||
/// The FRAMED UDP adapter (ADR-003's substrate-boundary codec): the
|
||||
/// pump-facing face speaks the tunnel's `[len: u16 BE][payload]`
|
||||
/// channel framing; the socket face speaks whole raw datagrams.
|
||||
/// Orientation (review 001 §Re-validation — the fix sketch's caution):
|
||||
/// Orientation (review 001 §Re-validation, the fix sketch's caution):
|
||||
/// `UdpHalf`'s caller is the pump, and the CHANNEL side is the framed
|
||||
/// side —
|
||||
/// side:
|
||||
///
|
||||
/// - `poll_write` is fed CHANNEL bytes (already-framed frames, from
|
||||
/// the session's codec) → it **de-frames** (an incremental
|
||||
@@ -210,13 +210,13 @@ pub async fn connect_udp(target: &str) -> Result<TargetHandle, TunnelEstablishEr
|
||||
/// as one raw datagram. A frame may straddle successive poll_write
|
||||
/// buffers (the pump's 8 KiB copy chunks split large frames); the
|
||||
/// decoder carries the partial state until the frame completes.
|
||||
/// `len=0` decodes to a zero-payload datagram — a real empty
|
||||
/// `len=0` decodes to a zero-payload datagram: a real empty
|
||||
/// datagram, NOT EOF (the F-2 fix end-to-end).
|
||||
/// - `poll_read` yields bytes destined for the CHANNEL (the session's
|
||||
/// codec decodes there) → it **frames** ([`frame_datagram`]) each
|
||||
/// raw target datagram and hands the frame bytes to the pump. A
|
||||
/// frame larger than the caller's buffer is staged and handed out
|
||||
/// across successive reads — a partial frame is never misread as a
|
||||
/// across successive reads; a partial frame is never misread as a
|
||||
/// shorter one.
|
||||
///
|
||||
/// The target sees payload-only bytes; an empty datagram crosses the
|
||||
@@ -224,10 +224,10 @@ pub async fn connect_udp(target: &str) -> Result<TargetHandle, TunnelEstablishEr
|
||||
///
|
||||
/// **Truncation never happens silently** (OQ-TN-13's fail-loud
|
||||
/// posture, ADR-003): the socket-facing recv uses a full 65535-byte
|
||||
/// scratch (`try_recv` truncates at the buffer size — a smaller
|
||||
/// scratch (`try_recv` truncates at the buffer size; a smaller
|
||||
/// scratch would corrupt the framing before any size check runs), and
|
||||
/// a datagram beyond [`MAX_DATAGRAM_LEN`] (impossible for a
|
||||
/// well-formed peer — the u16 frame bound) errors loudly instead of
|
||||
/// well-formed peer, the u16 frame bound) errors loudly instead of
|
||||
/// truncating.
|
||||
///
|
||||
/// The scratch is allocated lazily on the first recv (review 001 U-3:
|
||||
@@ -237,7 +237,7 @@ pub struct UdpHalf {
|
||||
sock: Arc<tokio::net::UdpSocket>,
|
||||
kind: UdpHalfKind,
|
||||
/// Read-half state: the recv scratch (lazily grown to the full
|
||||
/// 65535 on first use — `try_recv` truncates silently at the
|
||||
/// 65535 on first use; `try_recv` truncates silently at the
|
||||
/// buffer size, so the full-size read is correctness-load-bearing)
|
||||
/// plus the frame-staging state for handing a >caller-buffer
|
||||
/// frame out across reads.
|
||||
@@ -270,13 +270,13 @@ struct UdpReadStage {
|
||||
struct UdpWriteDeFrame {
|
||||
reader: DatagramReader,
|
||||
/// Decoded datagrams awaiting send (FIFO). poll_write accepts all
|
||||
/// offered bytes into the de-framer — the pump's copy window
|
||||
/// offered bytes into the de-framer; the pump's copy window
|
||||
/// cannot be held closed for later top-up (a full window is
|
||||
/// re-offered unsatisfied until drained); backpressure is the
|
||||
/// accepted-byte high-water mark instead.
|
||||
queue: std::collections::VecDeque<bytes::Bytes>,
|
||||
/// The datagram mid-send (the socket returned WouldBlock under
|
||||
/// it — retried first on every poll).
|
||||
/// it, retried first on every poll).
|
||||
sending: Option<bytes::Bytes>,
|
||||
/// Accepted-byte accounting for the high-water mark (the bytes
|
||||
/// accepted but not yet sent on the socket).
|
||||
@@ -341,14 +341,14 @@ impl AsyncRead for UdpHalf {
|
||||
// Hand out staged frame bytes first (a frame larger than the
|
||||
// caller's buffer was split across reads; the frame's length
|
||||
// prefix already crossed, so the pump's de-framer on the far
|
||||
// side reassembles — no shorter-datagram misread is possible).
|
||||
// side reassembles; no shorter-datagram misread is possible).
|
||||
if !this.stage.pending.is_empty() {
|
||||
let n = this.stage.pending.len().min(buf.remaining());
|
||||
buf.put_slice(&this.stage.pending.split_to(n));
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
|
||||
// Recv one raw datagram (full-size scratch — try_recv
|
||||
// Recv one raw datagram (full-size scratch; try_recv
|
||||
// truncates silently at the buffer size, so the scratch must
|
||||
// hold the largest possible datagram before any size check).
|
||||
if this.stage.recv_scratch.is_none() {
|
||||
@@ -370,7 +370,7 @@ impl AsyncRead for UdpHalf {
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!(
|
||||
"udp datagram {len} bytes exceeds the codec frame bound {max} \
|
||||
(truncation fails loud — OQ-TN-13)"
|
||||
(truncation fails loud; OQ-TN-13)"
|
||||
),
|
||||
)
|
||||
},
|
||||
@@ -380,7 +380,7 @@ impl AsyncRead for UdpHalf {
|
||||
} else {
|
||||
// The frame exceeds the caller's buffer: hand out
|
||||
// the first slice, stage the rest (the length
|
||||
// prefix rode along — the far-side de-framer
|
||||
// prefix rode along; the far-side de-framer
|
||||
// reassembles across reads).
|
||||
let n = buf.remaining();
|
||||
buf.put_slice(&framed[..n]);
|
||||
@@ -416,7 +416,7 @@ impl tokio::io::AsyncWrite for UdpHalf {
|
||||
// Backpressure: if too many accepted bytes are still queued
|
||||
// for the socket, try to drain first; if the queue is still
|
||||
// over the line, return Pending (the pump's copy re-offers
|
||||
// its window unsatisfied — safe, nothing is lost).
|
||||
// its window unsatisfied; safe, nothing is lost).
|
||||
if this.deframe.accepted > WRITE_QUEUE_HIGH_WATER {
|
||||
match this.pump_queue_to_socket(cx) {
|
||||
Poll::Ready(Ok(_)) => {}
|
||||
@@ -432,15 +432,15 @@ impl tokio::io::AsyncWrite for UdpHalf {
|
||||
// Accept ALL offered bytes into the incremental de-framer and
|
||||
// report them all consumed. The pump's copy window cannot be
|
||||
// held closed for a later top-up (a full window is re-offered
|
||||
// unsatisfied until drained — a partial-frame tail could sit
|
||||
// unsatisfied until drained; a partial-frame tail could sit
|
||||
// forever in a full window), and reporting fewer than all
|
||||
// bytes risks the copy's `WriteZero` on an empty re-offer.
|
||||
// The DatagramReader's partial-frame state is bounded by
|
||||
// construction (the u16 cap — review 001 N-2), so accepting
|
||||
// construction (the u16 cap, review 001 N-2), so accepting
|
||||
// everything is bounded memory; socket-side backpressure is
|
||||
// the accepted-byte high-water mark above.
|
||||
// The de-framer's feed is infallible (the oversize bound is
|
||||
// enforced at frame time — see the DatagramCodecError docs):
|
||||
// enforced at frame time; see the DatagramCodecError docs):
|
||||
// accept everything, bounded by the u16 cap (review 001 N-2).
|
||||
for dg in this.deframe.reader.feed(buf) {
|
||||
this.deframe.accepted += dg.len() + 2;
|
||||
@@ -462,7 +462,7 @@ impl tokio::io::AsyncWrite for UdpHalf {
|
||||
) -> std::task::Poll<std::io::Result<()>> {
|
||||
// The send pump: tokio's copy polls flush after draining its
|
||||
// window, so the queued datagrams drain here whenever the
|
||||
// pump cycles — and a flush-resolved Ready(Ok(())) tells the
|
||||
// pump cycles, and a flush-resolved Ready(Ok(())) tells the
|
||||
// copy the bytes are on the wire. UDP sends are all-or-nothing
|
||||
// per datagram; a WouldBlock keeps the datagram queued (the
|
||||
// send waker is registered) and the queue persists until it
|
||||
@@ -507,7 +507,7 @@ impl UdpHalf {
|
||||
self.deframe.accepted -= dg.len() + 2;
|
||||
}
|
||||
// WouldBlock (EAGAIN) and ENOBUFS both mean "the socket
|
||||
// cannot take this datagram now" — the unresolved-
|
||||
// cannot take this datagram now": the unresolved-
|
||||
// neighbor + shrunken-SNDBUF state reports ENOBUFS(105);
|
||||
// the stash + waker retry is the same behavior for both.
|
||||
Err(e)
|
||||
@@ -530,7 +530,7 @@ impl UdpHalf {
|
||||
|
||||
// --- Unix ---------------------------------------------------------------
|
||||
|
||||
/// Dial a unix-socket target (OQ-TN-14 — ships in v1, the same halves
|
||||
/// Dial a unix-socket target (OQ-TN-14, ships in v1, the same halves
|
||||
/// shape as TCP): `UnixStream::connect` bounded by [`DIAL_TIMEOUT`],
|
||||
/// `into_split` → boxed halves (raw pass-through, ADR-003).
|
||||
pub async fn dial_unix(path: &str) -> Result<TargetHandle, TunnelEstablishError> {
|
||||
@@ -546,10 +546,10 @@ pub async fn dial_unix(path: &str) -> Result<TargetHandle, TunnelEstablishError>
|
||||
}
|
||||
|
||||
/// Bind a unix-socket listener (the listen shape's assembly-side half,
|
||||
/// OQ-TN-14's dial parity — same wrapper shape as
|
||||
/// OQ-TN-14's dial parity; same wrapper shape as
|
||||
/// [`TcpListenerHalves`]). The binding decision belongs to the caller
|
||||
/// (OQ-TN-04); a stale socket file at `path` fails loud (the
|
||||
/// caller unlinks first — `bind` never silently reuses).
|
||||
/// caller unlinks first; `bind` never silently reuses).
|
||||
pub fn bind_unix(path: &str) -> Result<UnixListenerHalves, TunnelEstablishError> {
|
||||
let listener = tokio::net::UnixListener::bind(path)
|
||||
.map_err(|e| TunnelEstablishError::ResourceShortage(format!("bind `{path}`: {e}")))?;
|
||||
@@ -562,7 +562,7 @@ pub fn bind_unix(path: &str) -> Result<UnixListenerHalves, TunnelEstablishError>
|
||||
/// accept loop ([`UnixListenerHalves::accept_loop`]) is the
|
||||
/// accept-FIRST shape's feed ([`AcceptQueue`]); the per-accept
|
||||
/// [`UnixListenerHalves::accept_pair`] is the bind-FIRST shape's
|
||||
/// (one accept = one `(peer, halves)` pair — the peer is the
|
||||
/// (one accept = one `(peer, halves)` pair; the peer is the
|
||||
/// connecting path, `accept()`'s return value).
|
||||
pub struct UnixListenerHalves {
|
||||
listener: Arc<tokio::net::UnixListener>,
|
||||
@@ -586,7 +586,7 @@ impl UnixListenerHalves {
|
||||
/// The accept-FIRST shape's loop: accept until the listener fails,
|
||||
/// pushing each accepted handle into the queue (the assembly layer
|
||||
/// aborts this task on teardown). Unix peer addresses are not
|
||||
/// reliably readable from an accepted stream — the queue contract
|
||||
/// reliably readable from an accepted stream; the queue contract
|
||||
/// carries halves only, same as the TCP shape's loop.
|
||||
pub async fn accept_loop(&self, queue: AcceptQueue) -> Result<(), TunnelEstablishError> {
|
||||
loop {
|
||||
@@ -608,11 +608,11 @@ impl UnixListenerHalves {
|
||||
}
|
||||
}
|
||||
|
||||
/// ONE accept as a `(peer, halves)` pair — the BIND-FIRST shape's
|
||||
/// ONE accept as a `(peer, halves)` pair: the BIND-FIRST shape's
|
||||
/// substrate half for unix (OQ-TN-14 parity: the bind path is the
|
||||
/// same shape as [`TcpListenerHalves::accept_pair`]). The peer is
|
||||
/// the ACCEPTED side's bound path ([`UnixStream::peer_addr`]);
|
||||
/// an unnamed peer (an autobind client) fails loud — the
|
||||
/// an unnamed peer (an autobind client) fails loud; the
|
||||
/// forwarded op's `peer` param is required on the wire, so an
|
||||
/// addressless peer cannot be represented (ADR-008 §3).
|
||||
pub async fn accept_pair(&self) -> Result<(SubstrateAddr, TargetHandle), TunnelEstablishError> {
|
||||
@@ -645,9 +645,9 @@ impl UnixListenerHalves {
|
||||
/// Bind a unix-socket listener for the BIND-FIRST shape (the
|
||||
/// real-socket [`BindFn`] source, OQ-TN-14's dial/bind parity): the
|
||||
/// returned [`BoundListener::bound`] is the REAL bound socket path
|
||||
/// (the `bound` reply field's data — reply#1-class fidelity; the
|
||||
/// (the `bound` reply field's data, reply#1-class fidelity; the
|
||||
/// forwarded op's `peer` shape matches). The opaque `listener` half is
|
||||
/// the [`UnixListenerHalves`] wrapper — the assembly's
|
||||
/// the [`UnixListenerHalves`] wrapper; the assembly's
|
||||
/// [`BindFirstAcceptLoop`] closure downcasts to it and calls
|
||||
/// [`UnixListenerHalves::accept_pair`] per accept.
|
||||
pub async fn bind_unix_first(path: &str) -> Result<BoundListener, TunnelEstablishError> {
|
||||
@@ -662,7 +662,7 @@ pub async fn bind_unix_first(path: &str) -> Result<BoundListener, TunnelEstablis
|
||||
}
|
||||
|
||||
/// The local dial closure (the ADR-004 injection point): one
|
||||
/// [`DialFn`]-shaped function covering tcp/udp/unix — the assembly
|
||||
/// [`DialFn`]-shaped function covering tcp/udp/unix; the assembly
|
||||
/// layer injects this (closed over nothing substrate-specific beyond
|
||||
/// this module).
|
||||
pub fn local_dial() -> DialFn {
|
||||
@@ -681,13 +681,13 @@ pub fn local_dial() -> DialFn {
|
||||
// --- Bind-first (shape 3, ADR-008 §1) ------------------------------------
|
||||
|
||||
/// Bind a TCP listener for the BIND-FIRST shape (the real-socket
|
||||
/// [`BindFn`] source — shape 3's backing, ADR-008 §1): `port 0` = the
|
||||
/// [`BindFn`] source; shape 3's backing, ADR-008 §1): `port 0` = the
|
||||
/// OS chooses, and the returned [`BoundListener::bound`] is the REAL
|
||||
/// resolved address (`local_addr()` — the `bound` reply field's data,
|
||||
/// resolved address (`local_addr()`: the `bound` reply field's data,
|
||||
/// reply#1-class fidelity). The caller-supplied address is deliberate
|
||||
/// ([`bind_tcp`]'s wildcard-bind posture applies unchanged).
|
||||
///
|
||||
/// The opaque `listener` half is the [`TcpListenerHalves`] wrapper —
|
||||
/// The opaque `listener` half is the [`TcpListenerHalves`] wrapper;
|
||||
/// the assembly's [`BindFirstAcceptLoop`] closure downcasts to it and
|
||||
/// calls [`TcpListenerHalves::accept_pair`] per accept.
|
||||
pub async fn bind_tcp_first(addr: &str) -> Result<BoundListener, TunnelEstablishError> {
|
||||
@@ -704,7 +704,7 @@ pub async fn bind_tcp_first(addr: &str) -> Result<BoundListener, TunnelEstablish
|
||||
|
||||
/// The TCP bind closure + accept-pair loop, bundled (the assembly
|
||||
/// convenience): [`local_bind_first()`] renders the [`BindFn`] shape-3
|
||||
/// expects (the dispatch substrate picks tcp/unix; udp fails loud —
|
||||
/// expects (the dispatch substrate picks tcp/unix; udp fails loud:
|
||||
/// a UDP socket has no accept, so bind-first is not defined there),
|
||||
/// [`local_bindfirst_accept_loop()`] renders the [`BindFirstAcceptLoop`]
|
||||
/// that downcasts the plan's opaque listener to [`TcpListenerHalves`] /
|
||||
@@ -730,12 +730,12 @@ pub fn local_bind_first() -> BindFn {
|
||||
/// [`local_bind_first()`]): downcasts the plan's opaque listener to
|
||||
/// the concrete wrapper the bind closure produced and accepts ONE
|
||||
/// connection as a `(peer, halves)` pair per invocation (the
|
||||
/// dispatcher's per-accept contract — one closure call = one accept;
|
||||
/// dispatcher's per-accept contract: one closure call = one accept;
|
||||
/// the loop lives in the protocol's dispatcher).
|
||||
///
|
||||
/// The downcast is the assembly side of the type-erased injection
|
||||
/// pattern: the protocol crate never knows the listener's type, so the
|
||||
/// closure must — a listener not produced by this module's bind
|
||||
/// closure must; a listener not produced by this module's bind
|
||||
/// closures (or a mismatched substrate/backing pairing in the
|
||||
/// assembly's registry) fails loud as `handler_error`.
|
||||
pub fn local_bindfirst_accept_loop() -> BindFirstAcceptLoop {
|
||||
|
||||
+56
-52
@@ -1,18 +1,18 @@
|
||||
//! Open-op params: `TunnelParams {resource, substrate}` — the
|
||||
//! Open-op params: `TunnelParams {resource, substrate}`, the
|
||||
//! wire-stable carrier of "what to open" (ADR-001), plus the open-op
|
||||
//! `OperationSpec` builder, the scope/ALPN/op-id constants, and the
|
||||
//! typed establishment-reason helper (ADR-049 §4 surface).
|
||||
//!
|
||||
//! The direct op's params ([`TunnelDirectParams`] + [`SubstrateAddr`],
|
||||
//! ADR-007) and its spec builder live here too: `{substrate, target}`
|
||||
//! — the caller names the dial target at session time (the ssh
|
||||
//! ADR-007) and its spec builder live here too: `{substrate, target}`;
|
||||
//! the caller names the dial target at session time (the ssh
|
||||
//! `direct-tcpip` / SOCKS5 CONNECT shape), scope-gated by
|
||||
//! `tunnel:direct` (a separate grant from `tunnel:open`, never implied
|
||||
//! by it).
|
||||
//!
|
||||
//! The forwarded op's params ([`TunnelForwardedParams`], ADR-008 §3
|
||||
//! as amended) and its spec builder live here as well:
|
||||
//! `{listen, substrate, peer}` — the accept-as-open ingress op, keyed
|
||||
//! `{listen, substrate, peer}`, the accept-as-open ingress op, keyed
|
||||
//! by the producer-minted correlation token (Amendment 1), scope-gated
|
||||
//! by `tunnel:forwarded` (a separate grant again). The bind-first
|
||||
//! listen open's reply projection ([`TunnelListenReply`] +
|
||||
@@ -21,11 +21,11 @@
|
||||
//! contributes.
|
||||
//!
|
||||
//! The params layout is a one-way door once a consumer exists
|
||||
//! (ADR-001): `{resource, substrate}` only — the producer owns the
|
||||
//! (ADR-001): `{resource, substrate}` only; the producer owns the
|
||||
//! backing; no URL-style addressing. `deny_unknown_fields` is the
|
||||
//! loud-rejection posture (unknown fields fail, never silently
|
||||
//! ignored); an older producer rejecting a newer substrate value is
|
||||
//! the SSH "unknown channel type" posture — loud, not silent.
|
||||
//! the SSH "unknown channel type" posture: loud, not silent.
|
||||
|
||||
pub use alkcall::channels::client::ChannelOpenError;
|
||||
use alkcall::registry::spec::{
|
||||
@@ -46,25 +46,25 @@ pub const TUNNEL_ALPN: &str = "alk/tunnel";
|
||||
/// The scope gating tunnel opens (ADR-006). Stable once published.
|
||||
pub const TUNNEL_OPEN_SCOPE: &str = "tunnel:open";
|
||||
|
||||
/// The `channels/tunnel/direct` operation id (the direct op —
|
||||
/// The `channels/tunnel/direct` operation id (the direct op:
|
||||
/// dynamic-target egress, ADR-007; the flavor-form op-id convention,
|
||||
/// ADR-002 Amendment 1).
|
||||
pub const OP_TUNNEL_DIRECT: &str = "channels/tunnel/direct";
|
||||
|
||||
/// The scope gating the direct op (ADR-007 §3 / ADR-006 Amendment 1).
|
||||
/// A separate grant from [`TUNNEL_OPEN_SCOPE`] — **never implied by
|
||||
/// A separate grant from [`TUNNEL_OPEN_SCOPE`], **never implied by
|
||||
/// it**: "dial `postgres-primary`" and "dial anything from my
|
||||
/// network" are different capabilities. Stable once published.
|
||||
pub const TUNNEL_DIRECT_SCOPE: &str = "tunnel:direct";
|
||||
|
||||
/// The `channels/tunnel/forwarded` operation id (the forwarded op —
|
||||
/// The `channels/tunnel/forwarded` operation id (the forwarded op:
|
||||
/// accept-as-open ingress, ADR-008 §3; the flavor-form op-id
|
||||
/// convention, ADR-002 Amendment 1).
|
||||
pub const OP_TUNNEL_FORWARDED: &str = "channels/tunnel/forwarded";
|
||||
|
||||
/// The scope gating the forwarded op (ADR-008 §3 / ADR-006 Amendment
|
||||
/// 1). A separate grant from [`TUNNEL_OPEN_SCOPE`] and
|
||||
/// [`TUNNEL_DIRECT_SCOPE`] — **never implied by either**: receiving
|
||||
/// [`TUNNEL_DIRECT_SCOPE`], **never implied by either**: receiving
|
||||
/// producer-initiated forwarded connections (ingress) is a different
|
||||
/// capability from dialing egress. Stable once published.
|
||||
pub const TUNNEL_FORWARDED_SCOPE: &str = "tunnel:forwarded";
|
||||
@@ -75,7 +75,7 @@ pub const TUNNEL_FORWARDED_SCOPE: &str = "tunnel:forwarded";
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TunnelParams {
|
||||
/// The produced resource identifier: the producer's stable name
|
||||
/// for the tunnel target — NOT an address (the producer's
|
||||
/// for the tunnel target, NOT an address (the producer's
|
||||
/// registry maps it to its backing, OQ-TN-11).
|
||||
pub resource: String,
|
||||
/// The extensible substrate discriminator; selects the data-plane
|
||||
@@ -84,7 +84,7 @@ pub struct TunnelParams {
|
||||
}
|
||||
|
||||
/// The substrate discriminator (ADR-001). `Unix` ships in v1 (the
|
||||
/// `local` feature implements it — OQ-TN-14); a new substrate is a
|
||||
/// `local` feature implements it; OQ-TN-14); a new substrate is a
|
||||
/// new value, not a format change.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
@@ -95,12 +95,12 @@ pub enum Substrate {
|
||||
}
|
||||
|
||||
/// The substrate-shaped address object (ADR-007 §2): keyed by the
|
||||
/// sibling `substrate` field of the params — `tcp`/`udp` →
|
||||
/// sibling `substrate` field of the params: `tcp`/`udp` →
|
||||
/// [`SubstrateAddr::HostPort`] (domain-form hosts stay unresolved on
|
||||
/// the wire — producer-side resolution, the ssh `-D` semantic),
|
||||
/// the wire: producer-side resolution, the ssh `-D` semantic),
|
||||
/// `unix` → [`SubstrateAddr::Path`]. Untagged: the shapes are
|
||||
/// mutually exclusive in practice and self-describing. Also the
|
||||
/// `peer` value shape of the forwarded op (ADR-008 §3) — defined
|
||||
/// `peer` value shape of the forwarded op (ADR-008 §3), defined
|
||||
/// once here.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
@@ -114,7 +114,7 @@ pub enum SubstrateAddr {
|
||||
/// shape-vs-substrate correspondence is pinned by the spec's
|
||||
/// `input_schema` (per-substrate `oneOf`, validated registry-side
|
||||
/// before the establisher) and re-checked by serde here (the
|
||||
/// backstop — a mismatch parses as the wrong variant or fails).
|
||||
/// backstop: a mismatch parses as the wrong variant or fails).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TunnelDirectParams {
|
||||
@@ -128,7 +128,7 @@ pub struct TunnelDirectParams {
|
||||
impl TunnelDirectParams {
|
||||
/// Render the target to the backing-string form the `DialFn` /
|
||||
/// registry backings use: `"host:port"` for tcp/udp, the path for
|
||||
/// unix. The direct establisher calls this before dialing — the
|
||||
/// unix. The direct establisher calls this before dialing; the
|
||||
/// dial closure stays substrate-agnostic (ADR-007 §4).
|
||||
pub fn render_target(&self) -> String {
|
||||
match &self.target {
|
||||
@@ -179,11 +179,11 @@ pub fn tunnel_open_spec() -> OperationSpec {
|
||||
|
||||
/// The direct-op spec (`channels/tunnel/direct`, ADR-007): `Sub`-typed,
|
||||
/// external, channel-open marker on the `alk/tunnel` ALPN, scope-gated
|
||||
/// by `tunnel:direct` (a separate grant from `tunnel:open` — never
|
||||
/// by `tunnel:direct` (a separate grant from `tunnel:open`, never
|
||||
/// implied by it, ADR-006 Amendment 1), input/output schemas per
|
||||
/// wire.md §The Direct Op. The target sub-schema is per-substrate —
|
||||
/// wire.md §The Direct Op. The target sub-schema is per-substrate:
|
||||
/// `if`/`then` conditioned on the sibling `substrate` value
|
||||
/// (`tcp`/`udp` → `{host, port}`, `unix` → `{path}`) — so a malformed
|
||||
/// (`tcp`/`udp` → `{host, port}`, `unix` → `{path}`), so a malformed
|
||||
/// target fails the registry's schema validation (the typed
|
||||
/// `INVALID_INPUT` class) before the establisher runs.
|
||||
pub fn tunnel_direct_spec() -> OperationSpec {
|
||||
@@ -272,7 +272,7 @@ pub fn tunnel_direct_spec() -> OperationSpec {
|
||||
/// The reply arrives as `{channel_id, bound?, listen?}` (the
|
||||
/// wrapper-reserved `channel_id` key plus the establisher's additive
|
||||
/// fields); unknown extra reply fields must be tolerated (the additive
|
||||
/// posture — old consumers ignore unknown fields). Parse the full
|
||||
/// posture: old consumers ignore unknown fields). Parse the full
|
||||
/// reply with [`parse_listen_reply`], never with `deny_unknown_fields`
|
||||
/// deserialization of the whole reply. Both fields are `None` for
|
||||
/// accept-first/dial opens (the establisher supplied nothing).
|
||||
@@ -280,33 +280,33 @@ pub fn tunnel_direct_spec() -> OperationSpec {
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TunnelListenReply {
|
||||
/// The real OS-chosen bound address (port `0` resolved),
|
||||
/// substrate-shaped — present only when the establisher supplied
|
||||
/// substrate-shaped, present only when the establisher supplied
|
||||
/// it. Informational surface, NOT addressing: a consumer MUST NOT
|
||||
/// treat `bound` as a dial target (the producer owns the backing —
|
||||
/// treat `bound` as a dial target (the producer owns the backing,
|
||||
/// ADR-001's invariant); it exists for RFC-1928-reply-class
|
||||
/// fidelity (BND.ADDR) and observability.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bound: Option<SubstrateAddr>,
|
||||
/// The correlation token (ADR-008 Amendment 1): the u32 the
|
||||
/// establisher minted — the key the forwarded op's `listen` param
|
||||
/// establisher minted, the key the forwarded op's `listen` param
|
||||
/// carries. Present only when the establisher supplied it.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub listen: Option<u32>,
|
||||
}
|
||||
|
||||
/// Project the bind-first listen open's reply (ADR-008 §2 as amended)
|
||||
/// out of the FULL success output [`Value`] — the shape
|
||||
/// out of the FULL success output [`Value`], the shape
|
||||
/// `ChannelClient::open_channel_with_reply` returns. Extracts the
|
||||
/// optional `bound` + `listen` fields into [`TunnelListenReply`].
|
||||
///
|
||||
/// - Unknown extra reply fields are **tolerated** — the reply viewer
|
||||
/// - Unknown extra reply fields are **tolerated**; the reply viewer
|
||||
/// must not deny unknown fields (the additive posture; the wire is
|
||||
/// plain JSON reply fields, and the projection is a typed view).
|
||||
/// - `channel_id` must be present and a positive integer (the
|
||||
/// wrapper-owned key — its absence means the caller is not looking
|
||||
/// wrapper-owned key: its absence means the caller is not looking
|
||||
/// at an open reply at all; fail loud).
|
||||
/// - A present-but-malformed `bound` (not substrate-shaped) or
|
||||
/// `listen` (not a positive u32) is a loud error — a misbehaving
|
||||
/// `listen` (not a positive u32) is a loud error: a misbehaving
|
||||
/// producer is never silently coerced ([`TunnelError::
|
||||
/// MalformedReply`]).
|
||||
pub fn parse_listen_reply(reply: &Value) -> Result<TunnelListenReply, TunnelError> {
|
||||
@@ -341,7 +341,7 @@ pub fn parse_listen_reply(reply: &Value) -> Result<TunnelListenReply, TunnelErro
|
||||
}
|
||||
|
||||
/// Extract the bind-first listen open's reply fields (ADR-008 §2 as
|
||||
/// amended) from an open-op success reply — the **lenient, infallible
|
||||
/// amended) from an open-op success reply, the **lenient, infallible
|
||||
/// projection** the typed consumer surface reads: both fields
|
||||
/// [`Option`], absent for dial/accept-first producers, unknown extra
|
||||
/// reply fields tolerated (the additive posture).
|
||||
@@ -350,9 +350,9 @@ pub fn parse_listen_reply(reply: &Value) -> Result<TunnelListenReply, TunnelErro
|
||||
/// projects a malformed reply to both-`None`: the session type
|
||||
/// presents `Option` fields and never refuses construction over an
|
||||
/// informational field (`bound`/`listen` are informational surface,
|
||||
/// NOT addressing — ADR-008 §2's invariant; leniency here is safe by
|
||||
/// NOT addressing, per ADR-008 §2's invariant; leniency here is safe by
|
||||
/// construction). A malformed shape is logged loud (`tracing::error!`)
|
||||
/// and surfaces as `None` — the session's caller cannot distinguish
|
||||
/// and surfaces as `None`; the session's caller cannot distinguish
|
||||
/// "producer supplied nothing" from "producer misbehaved"; use
|
||||
/// [`parse_listen_reply`] directly when that distinction matters (the
|
||||
/// fail-loud posture for consumers that must reject misbehaving
|
||||
@@ -377,13 +377,13 @@ pub fn listen_reply_fields(reply: &Value) -> TunnelListenReply {
|
||||
/// The forwarded-op input params (ADR-008 §3 as amended). Wire-stable:
|
||||
/// all three fields required; renames are a breaking wire change.
|
||||
/// `deny_unknown_fields` is the loud-rejection posture (ADR-001's
|
||||
/// precedent — unknown fields fail, never silently ignored).
|
||||
/// precedent: unknown fields fail, never silently ignored).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TunnelForwardedParams {
|
||||
/// The correlation token (ADR-008 Amendment 1): the u32 the
|
||||
/// bind-first establisher minted and returned in the listen
|
||||
/// open's `listen` reply field — NOT the listen channel's ID
|
||||
/// open's `listen` reply field, NOT the listen channel's ID
|
||||
/// (that key is superseded: the serving-side handler never sees
|
||||
/// its own channel ID, and generic relays don't rewrite params).
|
||||
/// Positive by wire contract (schema `minimum: 1`; the serde pin
|
||||
@@ -394,7 +394,7 @@ pub struct TunnelForwardedParams {
|
||||
/// data-plane framing (ADR-003).
|
||||
pub substrate: Substrate,
|
||||
/// The accepted connection's remote address, keyed by the sibling
|
||||
/// `substrate` field ([`SubstrateAddr`]). Required in v1 — a
|
||||
/// `substrate` field ([`SubstrateAddr`]). Required in v1: a
|
||||
/// producer that cannot determine the peer does not serve a
|
||||
/// bind-first listener over that substrate (ADR-008 §3).
|
||||
pub peer: SubstrateAddr,
|
||||
@@ -403,7 +403,7 @@ pub struct TunnelForwardedParams {
|
||||
/// The serde-level pin behind `listen`'s schema `minimum: 1`: the
|
||||
/// correlation token is positive by contract (the establisher seeds
|
||||
/// the mint counter non-zero), so a `listen: 0` on the wire is a
|
||||
/// malformed open — rejected at parse time, never silently accepted.
|
||||
/// malformed open, rejected at parse time, never silently accepted.
|
||||
fn positive_u32<'de, D>(deserializer: D) -> Result<u32, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
@@ -417,20 +417,24 @@ where
|
||||
|
||||
/// The forwarded-op spec (`channels/tunnel/forwarded`, ADR-008 §3 as
|
||||
/// amended): `Sub`-typed, external, channel-open marker on the
|
||||
/// `alk/tunnel` ALPN, scope-gated by `tunnel:forwarded` (ingress — a
|
||||
/// `alk/tunnel` ALPN, scope-gated by `tunnel:forwarded` (ingress: a
|
||||
/// separate grant from `tunnel:open` and `tunnel:direct`, ADR-006
|
||||
/// Amendment 1), input/output schemas per wire.md §The Forwarded Op.
|
||||
/// All three params required; the `peer` sub-schema is per-substrate —
|
||||
/// All three params required; the `peer` sub-schema is per-substrate:
|
||||
/// `if`/`then` conditioned on the sibling `substrate` value (`tcp`/
|
||||
/// `udp` → `{host, port}`, `unix` → `{path}`), so a malformed peer
|
||||
/// fails the registry's schema validation (the typed
|
||||
/// `INVALID_INPUT` class) before the opener-side handler runs.
|
||||
///
|
||||
/// **No establisher** on the forwarded op — the data plane starts
|
||||
/// immediately (the opener-side serving registration is
|
||||
/// `register_openable`, handler = correlate by `listen` + accept the
|
||||
/// **Serving registration** (ADR-008 §3 as amended by Amendment 2):
|
||||
/// establisher-less base shape: `register_openable`, the data plane
|
||||
/// starts immediately (handler = correlate by `listen` + accept the
|
||||
/// `BiStream` + pump; the correlation registry is assembly-side
|
||||
/// bookkeeping, ADR-008 §3).
|
||||
/// bookkeeping), or the recommended establisher variant,
|
||||
/// `register_tunnel_forwarded_openable_with_establisher` (the
|
||||
/// injected correlation gate; a stale/missing token fails the open as
|
||||
/// a typed `channel:open_failed`). The spec itself is unchanged: the
|
||||
/// same op id, params, scope, and reply surface under both shapes.
|
||||
pub fn tunnel_forwarded_spec() -> OperationSpec {
|
||||
OperationSpec::new(
|
||||
OP_TUNNEL_FORWARDED,
|
||||
@@ -600,7 +604,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn serde_rejects_unknown_substrate_value() {
|
||||
// The serde layer (the enum deserializer), not the JSON Schema —
|
||||
// The serde layer (the enum deserializer), not the JSON Schema:
|
||||
// the registry's real schema gate is pinned end-to-end by the
|
||||
// integration probes (`schema_rejects_unknown_substrate_loudly`,
|
||||
// `malformed_target_is_schema_class_and_leaves_no_channel`, the
|
||||
@@ -705,7 +709,7 @@ mod tests {
|
||||
});
|
||||
assert!(serde_json::from_value::<TunnelDirectParams>(v).is_err());
|
||||
// The untagged target is lenient about extra target fields
|
||||
// (untagged variants carry no deny_unknown_fields) — a
|
||||
// (untagged variants carry no deny_unknown_fields); a
|
||||
// cross-shape target parses as whichever variant fits. The
|
||||
// SCHEMA is the gate that rejects shape-vs-substrate
|
||||
// mismatches registry-side (additionalProperties: false per
|
||||
@@ -928,7 +932,7 @@ mod tests {
|
||||
#[test]
|
||||
fn listen_reply_bare_projection() {
|
||||
// The accept-first/dial shape: the establisher contributed
|
||||
// nothing — both fields None.
|
||||
// nothing; both fields are None.
|
||||
let reply = json!({"channel_id": 7});
|
||||
let projected = parse_listen_reply(&reply).unwrap();
|
||||
assert_eq!(
|
||||
@@ -942,7 +946,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn listen_reply_tolerates_unknown_extra_fields() {
|
||||
// The additive posture — the reply viewer must NOT deny
|
||||
// The additive posture: the reply viewer must NOT deny
|
||||
// unknown fields (future additive reply fields keep old
|
||||
// consumers working).
|
||||
let reply = json!({
|
||||
@@ -965,9 +969,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn listen_reply_rejects_malformed_shapes() {
|
||||
// No channel_id at all — not an open reply.
|
||||
// No channel_id at all: not an open reply.
|
||||
assert!(parse_listen_reply(&json!({"bound": {"path": "/p"}})).is_err());
|
||||
// channel_id = 0 (the wrapper never allocates 0 — channel 0 is
|
||||
// channel_id = 0 (the wrapper never allocates 0; channel 0 is
|
||||
// the call plane).
|
||||
assert!(parse_listen_reply(&json!({"channel_id": 0})).is_err());
|
||||
// Malformed bound (not substrate-shaped).
|
||||
@@ -1011,9 +1015,9 @@ mod tests {
|
||||
#[test]
|
||||
fn forwarded_spec_peer_schema_gates_shape_per_substrate() {
|
||||
// The per-substrate peer sub-schema: the same if/then
|
||||
// structure the direct op pins its target with — structural
|
||||
// assertions per tunnels/params' precedent (no jsonschema dep
|
||||
// here).
|
||||
// structure the direct op pins its target with, asserted
|
||||
// structurally per tunnels/params' precedent (no jsonschema
|
||||
// dep here).
|
||||
let schema = tunnel_forwarded_spec().input_schema;
|
||||
let allof = schema["allOf"].as_array().expect("allOf branches");
|
||||
assert_eq!(allof.len(), 2, "tcp/udp branch + unix branch");
|
||||
@@ -1052,7 +1056,7 @@ mod tests {
|
||||
// fails loud at the deserializer. `deny_unknown_fields` holds:
|
||||
// unknown top-level params reject; the untagged peer is
|
||||
// lenient about extra peer fields (same documented asymmetry
|
||||
// as the direct op's target — the SCHEMA is the shape-vs-
|
||||
// as the direct op's target: the SCHEMA is the shape-vs-
|
||||
// substrate gate).
|
||||
let cross_shape = json!({
|
||||
"listen": 1,
|
||||
@@ -1105,7 +1109,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn listen_reply_fields_tolerates_unknown_extra_fields() {
|
||||
// The additive posture — the lenient projection must NOT gate
|
||||
// The additive posture: the lenient projection must NOT gate
|
||||
// unknown reply fields.
|
||||
let reply = json!({
|
||||
"channel_id": 7,
|
||||
|
||||
+261
-118
@@ -1,4 +1,4 @@
|
||||
//! The producer half: the `channels/tunnel/sub` open op — its
|
||||
//! The producer half: the `channels/tunnel/sub` open op, its
|
||||
//! establisher (dial shape), its substrate-agnostic pump handler, and
|
||||
//! its registration (`register_tunnel_openable`) per `producer.md` /
|
||||
//! ADR-004.
|
||||
@@ -6,11 +6,11 @@
|
||||
//! The establisher is the open op's awaited, bounded establishment
|
||||
//! phase (alkcall ADR-049): parse params semantically, look the
|
||||
//! resource up in the producer's registry, dial via the injected
|
||||
//! `DialFn` (ADR-004 — "produce boxed halves for a resource" is a
|
||||
//! `DialFn` (ADR-004: "produce boxed halves for a resource" is a
|
||||
//! function, not a trait), and return the handle in
|
||||
//! `Establishment::new(plan)` (R-01 — typed-opaque `ChannelPlan`; its
|
||||
//! `Establishment::new(plan)` (R-01: typed-opaque `ChannelPlan`; its
|
||||
//! `Send + Sync` bound is why `TargetHandle`'s halves carry `+ Sync`).
|
||||
//! The dial closure closes over substrate code — the protocol crate
|
||||
//! The dial closure closes over substrate code: the protocol crate
|
||||
//! never sees a socket type.
|
||||
//!
|
||||
//! The pump handler downcasts the plan and awaits
|
||||
@@ -19,13 +19,13 @@
|
||||
//! return = teardown-at-birth). It cannot know what the halves came
|
||||
//! from and must not (AGENTS.md convention 7).
|
||||
//!
|
||||
//! The listen establisher variant (the producer-side listener — the
|
||||
//! The listen establisher variant (the producer-side listener, the
|
||||
//! `-R` far-side listener shape) is [`listen_establisher`] +
|
||||
//! [`AcceptQueue`] below (the `tunnels/producer-listen` shape). The
|
||||
//! direct op's establisher ([`direct_establisher`] — dynamic-target
|
||||
//! direct op's establisher ([`direct_establisher`], dynamic-target
|
||||
//! egress, ADR-007) is the dial shape minus the registry lookup.
|
||||
//!
|
||||
//! The bind-first shape's plan payload ([`BindFirstPlan`] — the opaque
|
||||
//! The bind-first shape's plan payload ([`BindFirstPlan`]: the opaque
|
||||
//! listener + the correlation token, ADR-008 as amended) is defined
|
||||
//! here; the establisher and accept dispatcher that consume it land
|
||||
//! with `tunnels/bindfirst-producer` (the wire-stable types pin
|
||||
@@ -53,7 +53,7 @@ pub use crate::params::{
|
||||
TUNNEL_OPEN_SCOPE,
|
||||
};
|
||||
|
||||
/// The dialed target handle — the plan payload (typed-opaque
|
||||
/// The dialed target handle: the plan payload (typed-opaque
|
||||
/// `ChannelPlan`; establisher and handler agree on the concrete
|
||||
/// type). The boxed halves carry `+ Sync` to satisfy the plan's
|
||||
/// `Send + Sync` bound (POC F-1, documented upstream).
|
||||
@@ -75,29 +75,29 @@ impl std::fmt::Debug for TargetHandle {
|
||||
/// the assembly's listener handle + the correlation token + the listen
|
||||
/// tunnel's substrate.
|
||||
///
|
||||
/// **The listener is NOT `TargetHandle` halves** — a listener is
|
||||
/// **The listener is NOT `TargetHandle` halves**: a listener is
|
||||
/// accepted FROM, not pumped. The plan carries whatever the assembly's
|
||||
/// `BindFn` produced, typed-opaque ([`Any`]; the same
|
||||
/// injection pattern as `DialFn`/`AcceptFn` — the accept dispatcher
|
||||
/// injection pattern as `DialFn`/`AcceptFn`; the accept dispatcher
|
||||
/// downcasts it to the assembly's listener type via a type-erased
|
||||
/// accept closure; the ADR's "listener handle in the plan" is
|
||||
/// assembly-typed, and the protocol sees only this opaque pair).
|
||||
///
|
||||
/// **The correlation token (ADR-008 Amendment 1):** minted by the
|
||||
/// bind-first establisher — a fresh u32 per open, unique per producer
|
||||
/// bind-first establisher: a fresh u32 per open, unique per producer
|
||||
/// (an `AtomicU32` counter seeded non-zero, e.g. 1, inside the
|
||||
/// establisher's closure state; wraps are acceptable — u32, and a wrap
|
||||
/// establisher's closure state; wraps are acceptable: u32, and a wrap
|
||||
/// collision requires 2^32 concurrent opens on one producer). Carried
|
||||
/// twice: in the plan (the accept dispatcher stamps it into every
|
||||
/// forwarded open it opens for this listener) and as the additive
|
||||
/// `listen` reply field (the opener correlates its waiting session on
|
||||
/// it). It is NOT the listen channel's ID — the serving-side handler
|
||||
/// it). It is NOT the listen channel's ID; the serving-side handler
|
||||
/// never sees its own channel ID, and generic hub relays forward
|
||||
/// params untouched (Amendment 1's rationale). Per-open fresh,
|
||||
/// per-producer unique; retired with the listener session.
|
||||
pub struct BindFirstPlan {
|
||||
/// The assembly-typed listener handle (e.g. a `TcpListener`
|
||||
/// wrapper the `BindFn` produced). Opaque to the protocol — the
|
||||
/// wrapper the `BindFn` produced). Opaque to the protocol: the
|
||||
/// accept dispatcher downcasts via a type-erased accept closure.
|
||||
pub listener: Arc<dyn Any + Send + Sync>,
|
||||
/// The correlation token (see above): minted fresh per open,
|
||||
@@ -106,11 +106,11 @@ pub struct BindFirstPlan {
|
||||
pub token: u32,
|
||||
/// The listen tunnel's substrate (ADR-008 §3), stamped here at
|
||||
/// establisher time (the establisher already parsed the params
|
||||
/// successfully — one parse, one source of truth). The accept
|
||||
/// successfully: one parse, one source of truth). The accept
|
||||
/// dispatcher reads it from the plan it already downcast; it must
|
||||
/// NOT re-parse wire input (a silent `Tcp` fallback on a parse
|
||||
/// failure would mismatch a UDP listen tunnel's data-plane
|
||||
/// framing — review 002 M-1).
|
||||
/// framing, review 002 M-1).
|
||||
pub substrate: Substrate,
|
||||
}
|
||||
|
||||
@@ -158,9 +158,9 @@ impl From<TunnelEstablishError> for EstablishmentError {
|
||||
|
||||
/// The producer's registry of produced resources:
|
||||
/// `(resource, substrate)` → backing. An assembly-owned construct
|
||||
/// (OQ-TN-11 — the collision domain is the producer's registry; two
|
||||
/// (OQ-TN-11: the collision domain is the producer's registry; two
|
||||
/// producers on one connection may expose the same resource name
|
||||
/// independently). The backing is opaque to the protocol — the
|
||||
/// independently). The backing is opaque to the protocol: the
|
||||
/// `DialFn` closes over whatever shape the assembly layer chose.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct ResourceRegistry {
|
||||
@@ -173,7 +173,7 @@ impl ResourceRegistry {
|
||||
}
|
||||
|
||||
/// Produce a resource: map `(resource, substrate)` to its backing
|
||||
/// (a local port, a socket path, an in-process address — the
|
||||
/// (a local port, a socket path, an in-process address; the
|
||||
/// dial closure's business, not the wire's).
|
||||
pub async fn register(&self, resource: &str, substrate: Substrate, backing: &str) {
|
||||
self.targets
|
||||
@@ -208,7 +208,7 @@ pub type DialFn = Arc<
|
||||
>;
|
||||
|
||||
/// The witness capturing the establisher's per-call opener identity
|
||||
/// (CF-006 probe — the establisher sees the END CALLER overlaid onto
|
||||
/// (CF-006 probe: the establisher sees the END CALLER overlaid onto
|
||||
/// the install-time context, not a synthetic install-time value).
|
||||
pub type IdentityWitness = Arc<tokio::sync::Mutex<Option<String>>>;
|
||||
|
||||
@@ -256,26 +256,26 @@ fn parse_params(input: &Value) -> Result<TunnelParams, TunnelEstablishError> {
|
||||
.map_err(|e| TunnelEstablishError::HandlerError(format!("invalid params: {e}")))
|
||||
}
|
||||
|
||||
/// The direct establisher (ADR-007 §4 — the dial shape minus the
|
||||
/// The direct establisher (ADR-007 §4, the dial shape minus the
|
||||
/// registry lookup): parse `TunnelDirectParams`, render the target to
|
||||
/// the same backing-string form the registry backings use
|
||||
/// (`"host:port"` for tcp/udp, the path for unix), and call the
|
||||
/// injected [`DialFn`] — the SAME dial function the base op uses, so
|
||||
/// injected [`DialFn`]; the SAME dial function the base op uses, so
|
||||
/// a producer that registered `dial_tcp` for the base op registers
|
||||
/// the direct op with zero new substrate code (the UDP framed-adapter
|
||||
/// wrap, ADR-003, happens inside the dial closure — the establisher
|
||||
/// stays substrate-agnostic). No registry lookup — `unknown_resource`
|
||||
/// wrap, ADR-003, happens inside the dial closure; the establisher
|
||||
/// stays substrate-agnostic). No registry lookup, so `unknown_resource`
|
||||
/// can never fire.
|
||||
///
|
||||
/// The per-call `auth` carries the opener identity (CF-006); record it
|
||||
/// into `identity_witness` when provided (the direct op is the
|
||||
/// arbitrary-egress capability — identity visibility matters more,
|
||||
/// arbitrary-egress capability: identity visibility matters more,
|
||||
/// not less).
|
||||
///
|
||||
/// Error mapping (ADR-007 §4's table minus the registry row): the
|
||||
/// serde-parse failure of a schema-valid-shaped target maps
|
||||
/// [`TunnelEstablishError::HandlerError`] (the schema gate should
|
||||
/// have caught it — the parse is the backstop); dial failures pass
|
||||
/// 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`).
|
||||
pub fn direct_establisher(dial: DialFn) -> OpenEstablisher {
|
||||
@@ -307,17 +307,17 @@ pub fn direct_establisher_with_witness(
|
||||
})
|
||||
}
|
||||
|
||||
/// The listen establisher (shape 2 — producer.md §The Establisher):
|
||||
/// The listen establisher (shape 2, producer.md §The Establisher):
|
||||
/// the same open op, same params, same typed errors; the establisher
|
||||
/// pops the next accepted connection from the injected [`AcceptFn`]
|
||||
/// instead of dialing. The resource lookup follows the same registry
|
||||
/// miss mapping (`unknown_resource`); the accept closure's failures
|
||||
/// map at the closure (empty queue → `resource_shortage`, closed
|
||||
/// listener → `dial_failed` — producer.md's table). The pump handler
|
||||
/// listener → `dial_failed`; producer.md's table). The pump handler
|
||||
/// is UNCHANGED: the listen variant is plan-flow with a different
|
||||
/// halves source.
|
||||
///
|
||||
/// The registry check is a namespace gate only — the listen producer's
|
||||
/// The registry check is a namespace gate only: the listen producer's
|
||||
/// "backing" is its accept queue, not a dial target. A resource the
|
||||
/// producer does not produce is still `unknown_resource` (same as
|
||||
/// dial); an openable resource with nothing yet accepted is
|
||||
@@ -344,9 +344,9 @@ pub fn listen_establisher(registry: ResourceRegistry, accept: AcceptFn) -> OpenE
|
||||
}
|
||||
|
||||
/// The accepted-connection source the assembly layer injects for a
|
||||
/// LISTEN producer (shape 2 — the `-R` far-side listener; producer.md
|
||||
/// LISTEN producer (shape 2, the `-R` far-side listener; producer.md
|
||||
/// §The Establisher): pops one accepted connection's halves. The
|
||||
/// assembly layer owns the listener + its accept loop — the protocol
|
||||
/// assembly layer owns the listener + its accept loop; the protocol
|
||||
/// crate never binds (OQ-TN-04). The queue contract lives in
|
||||
/// [`AcceptQueue`]; the closure pops from it (or from whatever the
|
||||
/// assembly chose).
|
||||
@@ -360,17 +360,17 @@ pub type AcceptFn = Arc<
|
||||
/// listen establisher (the protocol-side queue contract). The accept
|
||||
/// loop feeds ([`AcceptQueue::push`]); the establisher pops during the
|
||||
/// open op's establishment phase ([`AcceptQueue::pop`]). `pop` is
|
||||
/// bounded by the establisher's own deadline (ADR-049 §2) — but an
|
||||
/// bounded by the establisher's own deadline (ADR-049 §2), but an
|
||||
/// empty queue is NOT inherently an error (a late accept is
|
||||
/// legitimate); the mapping to `resource_shortage` is the ASSEMBLY
|
||||
/// layer's posture (its accept closure knows the listener's bound —
|
||||
/// layer's posture (its accept closure knows the listener's bound:
|
||||
/// a drained listener or an accept budget). The wrapper's
|
||||
/// establishment timeout is the backstop above that.
|
||||
///
|
||||
/// `pop` returns `None` when the queue is closed-and-empty (the
|
||||
/// listener is gone — the assembly layer calls [`AcceptQueue::close`]
|
||||
/// listener is gone; the assembly layer calls [`AcceptQueue::close`]
|
||||
/// on teardown). FIFO order: every popped handle was accepted BEFORE
|
||||
/// that pop began (always-before-take ordering — push enqueues under
|
||||
/// that pop began (always-before-take ordering: push enqueues under
|
||||
/// the same lock that pops dequeue under, and `pop` re-checks the
|
||||
/// queue after every wake).
|
||||
#[derive(Clone, Default)]
|
||||
@@ -414,7 +414,7 @@ impl AcceptQueue {
|
||||
/// is created BEFORE the state check on every iteration. A `push`
|
||||
/// landing in the thread-preemption window between the check and
|
||||
/// the future's first poll is captured by tokio's notify-waiters
|
||||
/// counter comparison at that first poll — the future resolves
|
||||
/// counter comparison at that first poll; the future resolves
|
||||
/// immediately; a later `push` finds the waiter registered and
|
||||
/// wakes it. A future created after the check (the naive shape)
|
||||
/// loses that push, parking the pop until the next one.
|
||||
@@ -443,10 +443,10 @@ impl AcceptQueue {
|
||||
|
||||
/// The pump handler: accept the channel's yield-once `BiStream`,
|
||||
/// downcast the plan to `TargetHandle`, and await `pump_bidi` inline
|
||||
/// (R-02 — the returned `JoinHandle` tracks the data-plane lifetime;
|
||||
/// (R-02: the returned `JoinHandle` tracks the data-plane lifetime;
|
||||
/// an early return is teardown-at-birth, and the birth-teardown
|
||||
/// telemetry diagnoses the spawn-and-forget shape at runtime). The
|
||||
/// handler is substrate-agnostic by construction — it sees boxed
|
||||
/// handler is substrate-agnostic by construction: it sees boxed
|
||||
/// halves, never a socket type.
|
||||
pub fn make_tunnel_pump_handler() -> OpenHandler {
|
||||
Arc::new(
|
||||
@@ -505,7 +505,7 @@ pub fn register_tunnel_openable(
|
||||
/// base op (the direct op's plan payload is a [`TargetHandle`] exactly
|
||||
/// like the base op's; plan-flow is unchanged), via
|
||||
/// `register_openable_with_establisher` (`timeout: None` = the 10s
|
||||
/// default bound). `dial` is the SAME [`DialFn`] the base op uses —
|
||||
/// default bound). `dial` is the SAME [`DialFn`] the base op uses;
|
||||
/// no new substrate code. `identity_witness` is the optional CF-006
|
||||
/// probe seam (the establisher records the per-call opener identity
|
||||
/// it saw).
|
||||
@@ -529,8 +529,8 @@ pub fn register_tunnel_direct_openable(
|
||||
/// Register the tunnel open op with a LISTEN establisher (shape 2):
|
||||
/// the same spec + pump handler as [`register_tunnel_openable`]; the
|
||||
/// establisher pops accepted handles from the injected [`AcceptFn`]
|
||||
/// instead of dialing. One registration per operation id per registry
|
||||
/// — a session serves either a dial establisher or a listen
|
||||
/// instead of dialing. One registration per operation id per registry:
|
||||
/// a session serves either a dial establisher or a listen
|
||||
/// establisher for `channels/tunnel/sub`; a producer that does both
|
||||
/// registers on separate registries (per-connection registries are
|
||||
/// independent, producer.md §Registration API).
|
||||
@@ -553,10 +553,10 @@ pub fn register_tunnel_listen_openable(
|
||||
|
||||
// --- Bind-first (shape 3, ADR-008) ---------------------------------------
|
||||
|
||||
/// The substrate bind function the assembly layer injects (shape 3 —
|
||||
/// The substrate bind function the assembly layer injects (shape 3,
|
||||
/// ADR-008 §1): given a substrate + a registry backing (the same
|
||||
/// backing-string form the dial shape's `DialFn` receives), **bind the
|
||||
/// listener** and produce it. The protocol never binds (OQ-TN-04) —
|
||||
/// listener** and produce it. The protocol never binds (OQ-TN-04);
|
||||
/// this closure is the platform boundary, the same inversion point as
|
||||
/// `DialFn`. The returned [`BoundListener`] pairs the opaque
|
||||
/// assembly-typed listener (the accept loop's business) with the
|
||||
@@ -573,11 +573,11 @@ pub type BindFn = Arc<
|
||||
|
||||
/// What the assembly's bind produces (ADR-008 §1): the opaque listener
|
||||
/// (the accept loop's business) + the OS-chosen bound address (the
|
||||
/// `bound` reply field's source — the REAL resolved address, port `0`
|
||||
/// `bound` reply field's source: the REAL resolved address, port `0`
|
||||
/// resolved; informational surface, never addressing).
|
||||
pub struct BoundListener {
|
||||
/// The assembly-typed listener (e.g. the `TcpListener` wrapper the
|
||||
/// assembly chose). Opaque to the protocol — rides the plan's
|
||||
/// assembly chose). Opaque to the protocol: rides the plan's
|
||||
/// `Any`, downcast only inside the assembly's accept closure.
|
||||
pub listener: Arc<dyn Any + Send + Sync>,
|
||||
/// The real OS-chosen bound address (the `bound` reply field's
|
||||
@@ -587,15 +587,15 @@ pub struct BoundListener {
|
||||
|
||||
/// The token mint (the discipline pinned on [`BindFirstPlan`]): a
|
||||
/// non-zero-seeded `AtomicU32` counter held in the establisher's
|
||||
/// closure state — fresh per open, unique per producer, wraps
|
||||
/// closure state, fresh per open, unique per producer, wraps
|
||||
/// acceptable (a wrap collision requires 2^32 concurrent opens on one
|
||||
/// producer — and that bound is loose: the token is
|
||||
/// producer, and that bound is loose: the token is
|
||||
/// per-producer-CONNECTION and each open rides one connection, so the
|
||||
/// alkcall 256-channels-per-connection cap makes a live-collision
|
||||
/// window unreachable by a wide margin, review 002 N-5). The counter
|
||||
/// seeds at 1: `fetch_add` returns the
|
||||
/// PREVIOUS value, so the first mint is 1 and the minted stream is
|
||||
/// 1, 2, 3, … — token 0 (an illegal wire value; the forwarded params'
|
||||
/// 1, 2, 3, …; token 0 (an illegal wire value; the forwarded params'
|
||||
/// `listen` pin rejects it) is never minted, and a u32 wrap that lands
|
||||
/// on 0 re-increments (the loop skips it; the 2^32 collision window
|
||||
/// makes wrap uniqueness a non-issue at the discipline's own terms).
|
||||
@@ -609,14 +609,14 @@ fn mint_token(counter: &std::sync::atomic::AtomicU32) -> u32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// The bind-first establisher (shape 3 — producer.md §The Establisher,
|
||||
/// The bind-first establisher (shape 3, producer.md §The Establisher,
|
||||
/// ADR-008 §1): the same open op, same params, same typed-error table;
|
||||
/// the establisher **binds** (via the injected [`BindFn`]) instead of
|
||||
/// dialing or popping an accept, mints the correlation token
|
||||
/// (per-open-fresh, per-producer-unique — the discipline pinned on
|
||||
/// (per-open-fresh, per-producer-unique, the discipline pinned on
|
||||
/// [`BindFirstPlan`]), contributes
|
||||
/// the `bound` + `listen` reply fields (the establisher → reply
|
||||
/// projection, ADR-049 amendment 3 — the wrapper merges them into the
|
||||
/// projection, ADR-049 amendment 3; the wrapper merges them into the
|
||||
/// success output after its reserved `channel_id` key), and returns
|
||||
/// the [`BindFirstPlan`] as the plan payload.
|
||||
///
|
||||
@@ -624,7 +624,7 @@ fn mint_token(counter: &std::sync::atomic::AtomicU32) -> u32 {
|
||||
/// [`listen_establisher`]): the bind-first producer's "backing" is the
|
||||
/// [`BindFn`], not a dial target; a resource the producer does not
|
||||
/// produce is still `unknown_resource`. The resolved backing string
|
||||
/// rides the same signature the dial shape's `DialFn` takes — the bind
|
||||
/// rides the same signature the dial shape's `DialFn` takes; the bind
|
||||
/// closure sees the same `(substrate, backing)` form.
|
||||
///
|
||||
/// Error mapping (producer.md's shape-3 table): unbindable/held port →
|
||||
@@ -633,11 +633,11 @@ fn mint_token(counter: &std::sync::atomic::AtomicU32) -> u32 {
|
||||
/// `unknown_resource`; internal → `handler_error`. The `BindFn`
|
||||
/// returns the typed errors; the establisher passes them through.
|
||||
///
|
||||
/// The token lives in BOTH the reply (`listen` — the opener's
|
||||
/// The token lives in BOTH the reply (`listen`, the opener's
|
||||
/// correlation key) and the plan (the accept dispatcher stamps it into
|
||||
/// every forwarded open). Two opens mint distinct tokens (the mint is
|
||||
/// per-open); each dispatcher stamps ITS OWN plan's token (the plan
|
||||
/// flows per-open — R-01).
|
||||
/// flows per-open, R-01).
|
||||
pub fn bindfirst_establisher(registry: ResourceRegistry, bind: BindFn) -> OpenEstablisher {
|
||||
use std::sync::atomic::AtomicU32;
|
||||
let counter = Arc::new(AtomicU32::new(1));
|
||||
@@ -673,10 +673,10 @@ pub fn bindfirst_establisher(registry: ResourceRegistry, bind: BindFn) -> OpenEs
|
||||
}
|
||||
|
||||
/// The accepted-connection source the assembly layer injects for the
|
||||
/// BIND-FIRST producer (shape 3 — ADR-008 §1): pops one accepted
|
||||
/// BIND-FIRST producer (shape 3, ADR-008 §1): pops one accepted
|
||||
/// connection from the plan's opaque listener and yields
|
||||
/// `(SubstrateAddr /* peer */, TargetHandle /* accepted halves */)`.
|
||||
/// The closure receives the listener as the opaque `Any` — the
|
||||
/// The closure receives the listener as the opaque `Any`; the
|
||||
/// assembly downcasts it to ITS concrete listener type inside the
|
||||
/// closure (the same type-erased injection pattern as
|
||||
/// `DialFn`/`AcceptFn`; substrate code lives behind the closure,
|
||||
@@ -685,10 +685,10 @@ pub fn bindfirst_establisher(registry: ResourceRegistry, bind: BindFn) -> OpenEs
|
||||
///
|
||||
/// A returned `Err` is a LOOP-CRITICAL condition: the accept loop's
|
||||
/// substrate source is broken (the listener closed, accept errored
|
||||
/// unrecoverably) — the dispatcher ends the loop, tears the listener
|
||||
/// unrecoverably); the dispatcher ends the loop, tears the listener
|
||||
/// down with the plan, and lets the handler task finish (the channel
|
||||
/// reaps). Per-ACCEPT failures (forwarded-open failures) do NOT come
|
||||
/// from this closure — see `make_bindfirst_accept_handler`'s error
|
||||
/// from this closure; see `make_bindfirst_accept_handler`'s error
|
||||
/// posture.
|
||||
pub type BindFirstAcceptLoop = Arc<
|
||||
dyn Fn(
|
||||
@@ -706,12 +706,12 @@ pub type BindFirstAcceptLoop = Arc<
|
||||
/// opener, adopts the opener-allocated ID on the producer-side
|
||||
/// manager, and returns the adopted channel's halves (the pump side).
|
||||
///
|
||||
/// **This closure is the call plane** (the design pin — producer.md
|
||||
/// **This closure is the call plane** (the design pin: producer.md
|
||||
/// §The Pump Handler item 5): the per-channel `Connection` the
|
||||
/// dispatcher receives is stream-less (`open_bi` is `StreamClosed` —
|
||||
/// dispatcher receives is stream-less (`open_bi` is `StreamClosed`,
|
||||
/// ADR-035) and carries no call surface, so the registration closes
|
||||
/// over the serving session's `Arc<CallConnection>` (the assembly has
|
||||
/// it — it constructed the serving session). The shape is the reverse
|
||||
/// it; it constructed the serving session). The shape is the reverse
|
||||
/// POC's `open_reverse_channel`: `call_with_payload` on channel 0 →
|
||||
/// extract `channel_id` from the reply → `manager.adopt_channel`. The
|
||||
/// accepted halves pump against the adopted channel's halves.
|
||||
@@ -731,41 +731,41 @@ pub type ForwardedOpener = Arc<
|
||||
+ Sync,
|
||||
>;
|
||||
|
||||
/// The bind-first pump handler — the ACCEPT DISPATCHER (ADR-008 §1).
|
||||
/// The bind-first pump handler: the ACCEPT DISPATCHER (ADR-008 §1).
|
||||
///
|
||||
/// Handler contract (producer.md §The Pump Handler item 5 + R-02):
|
||||
///
|
||||
/// 1. `conn.accept_bi()` FIRST (the yield-once contract — the
|
||||
/// 1. `conn.accept_bi()` FIRST (the yield-once contract: the
|
||||
/// wrapper's accepted-flag telemetry requires it), downcast the
|
||||
/// plan to [`BindFirstPlan`].
|
||||
/// 2. The listen channel's own `BiStream` stays idle (no pumps on
|
||||
/// it); **its EOF cancels the loop and tears the listener down** —
|
||||
/// a 1-byte `read` on the idle stream observes the opener dropping
|
||||
/// it); **its EOF cancels the loop and tears the listener down**: a
|
||||
/// 1-byte `read` on the idle stream observes the opener dropping
|
||||
/// the listener session (the zero-byte EOF sentinel; ADR-050's
|
||||
/// teardown contract). `select!` races the EOF watch against the
|
||||
/// accept loop.
|
||||
/// 3. Per accept: open the forwarded channel ([`ForwardedOpener`] —
|
||||
/// 3. Per accept: open the forwarded channel ([`ForwardedOpener`],
|
||||
/// the call plane, `{listen: token, substrate, peer}` params),
|
||||
/// then `pump_bidi` the accepted halves against the adopted
|
||||
/// channel inside a spawned task tracked in the handler's handle
|
||||
/// set. A forwarded-open failure is per-accept isolated: log and
|
||||
/// drop that accepted connection — the loop continues (the
|
||||
/// drop that accepted connection; the loop continues (the
|
||||
/// listener's overall health is not hostage to one failed accept;
|
||||
/// v1: no retry/backoff). A loop-critical accept error ([`BindFirstAcceptLoop`]'s
|
||||
/// contract) ends the loop.
|
||||
/// 4. **Lifetime (R-02):** the handler's returned `JoinHandle` tracks
|
||||
/// the whole accept loop + every per-accept pump it spawned — the
|
||||
/// the whole accept loop + every per-accept pump it spawned; the
|
||||
/// handler task awaits the pump handles on loop exit (a forwarded
|
||||
/// pump outliving its listener is a leak). Handler exit → wrapper
|
||||
/// teardown (the listen channel reaps) → the far listener tears
|
||||
/// down (the plan's listener drops with the loop).
|
||||
///
|
||||
/// The substrate discriminator for the forwarded opens rides the
|
||||
/// plan ([`BindFirstPlan`].substrate — stamped at establisher time
|
||||
/// plan ([`BindFirstPlan`].substrate, stamped at establisher time
|
||||
/// from the one successful `TunnelParams` parse; the listen tunnel's
|
||||
/// substrate; ADR-008 §3 "the listen tunnel's substrate shapes the
|
||||
/// forwarded channel's data-plane framing"). The dispatcher never
|
||||
/// re-parses wire input — no fallback branch exists.
|
||||
/// re-parses wire input: no fallback branch exists.
|
||||
pub fn make_bindfirst_accept_handler(
|
||||
accept_loop: BindFirstAcceptLoop,
|
||||
open_forwarded: ForwardedOpener,
|
||||
@@ -784,14 +784,14 @@ pub fn make_bindfirst_accept_handler(
|
||||
return;
|
||||
};
|
||||
// The substrate rides the plan (stamped at establisher
|
||||
// time — one parse, one source of truth): the
|
||||
// time, one parse, one source of truth): the
|
||||
// dispatcher must not re-parse wire input (a parse
|
||||
// failure here would have nowhere loud to go; review
|
||||
// 002 M-1 removed the silent `Tcp` fallback instead).
|
||||
let substrate = plan.substrate;
|
||||
let (mut idle_read, _idle_write) = tokio::io::split(bidi);
|
||||
|
||||
// The per-accept pump task set — tracked by the
|
||||
// The per-accept pump task set, tracked by the
|
||||
// handler's JoinHandle (R-02: every pump the handler
|
||||
// spawned is awaited on loop exit).
|
||||
let mut pumps: Vec<tokio::task::JoinHandle<(u64, u64)>> = Vec::new();
|
||||
@@ -799,15 +799,15 @@ pub fn make_bindfirst_accept_handler(
|
||||
let loop_result = loop {
|
||||
// The idle-stream EOF watch vs the accept loop
|
||||
// (select!): the opener dropping the listener
|
||||
// session EOFs the listen channel's stream — the
|
||||
// zero-byte sentinel — and cancels the loop.
|
||||
// session EOFs the listen channel's stream (the
|
||||
// zero-byte sentinel) and cancels the loop.
|
||||
let eof_watch = async {
|
||||
// The 1-byte read on the idle stream: `Ok(0)`
|
||||
// (or an error) = the opener dropped the
|
||||
// session; `Ok(1..)` never happens on a well-
|
||||
// behaved opener (the stream is idle — a peer
|
||||
// behaved opener (the stream is idle: a peer
|
||||
// that writes is misbehaving; treat any
|
||||
// non-empty read as an EOF trigger too — the
|
||||
// non-empty read as an EOF trigger too; the
|
||||
// channel is not a data plane).
|
||||
let mut byte = [0u8; 1];
|
||||
match idle_read.read(&mut byte).await {
|
||||
@@ -839,8 +839,13 @@ pub fn make_bindfirst_accept_handler(
|
||||
// Per-accept isolation: the
|
||||
// forwarded open failed (scope-
|
||||
// less opener → FORBIDDEN; old
|
||||
// opener → NOT_FOUND; correlation
|
||||
// miss → handler_error-class) —
|
||||
// opener → NOT_FOUND; a stale/
|
||||
// missing token → the typed
|
||||
// `unknown_resource` open_failed
|
||||
// under the serving side's
|
||||
// establisher variant, ADR-008
|
||||
// Amendment 2; other establisher
|
||||
// failures → handler_error-class):
|
||||
// drop this accepted connection,
|
||||
// keep serving. v1: no retry/
|
||||
// backoff (documented posture).
|
||||
@@ -856,7 +861,7 @@ pub fn make_bindfirst_accept_handler(
|
||||
Err(e) => {
|
||||
// Loop-critical (the listener's
|
||||
// substrate source broke): end the
|
||||
// loop — the listener tears down with
|
||||
// loop; the listener tears down with
|
||||
// the plan.
|
||||
tracing::warn!(
|
||||
listen = plan.token,
|
||||
@@ -873,11 +878,11 @@ pub fn make_bindfirst_accept_handler(
|
||||
reason = loop_result,
|
||||
"tunnel bind-first: accept loop exited"
|
||||
);
|
||||
// The listener drops with the plan (the last Arc) — the
|
||||
// The listener drops with the plan (the last Arc); the
|
||||
// far listener tears down.
|
||||
drop(plan);
|
||||
// R-02: the handler's JoinHandle tracks every per-accept
|
||||
// pump it spawned — await them on loop exit (a forwarded
|
||||
// pump it spawned; await them on loop exit (a forwarded
|
||||
// pump outliving its listener is a leak).
|
||||
for pump in pumps.drain(..) {
|
||||
let _ = pump.await;
|
||||
@@ -889,16 +894,16 @@ pub fn make_bindfirst_accept_handler(
|
||||
|
||||
/// The bind-first registration's injected seams (the assembly layer
|
||||
/// owns all four; bundling keeps the registration's signature to the
|
||||
/// core pair + the bundle — the seams are one coherent injection
|
||||
/// core pair + the bundle; the seams are one coherent injection
|
||||
/// point, the same posture as `DialFn` riding the dial registration):
|
||||
/// - `bind` — the substrate bind function ([`BindFn`]; the protocol
|
||||
/// - `bind`: the substrate bind function ([`BindFn`]; the protocol
|
||||
/// never binds, OQ-TN-04).
|
||||
/// - `accept_loop` — the per-listener accept loop
|
||||
/// - `accept_loop`: the per-listener accept loop
|
||||
/// ([`BindFirstAcceptLoop`]; receives the opaque listener, yields
|
||||
/// `(peer, halves)` per accept).
|
||||
/// - `open_forwarded` — the forwarded-open caller ([`ForwardedOpener`];
|
||||
/// - `open_forwarded`: the forwarded-open caller ([`ForwardedOpener`];
|
||||
/// the call plane: the serving session's `Arc<CallConnection>` +
|
||||
/// the producer-side `ChannelManager` for the adopt — the assembly
|
||||
/// the producer-side `ChannelManager` for the adopt; the assembly
|
||||
/// has both, it constructed the serving session; the reverse POC's
|
||||
/// `open_reverse_channel` shape).
|
||||
pub struct BindFirstInjections {
|
||||
@@ -911,9 +916,9 @@ pub struct BindFirstInjections {
|
||||
}
|
||||
|
||||
/// Register the bind-first openable (shape 3, ADR-008 §1): the SAME
|
||||
/// spec + scope gate (`tunnel:open`) as the dial/listen variants — the
|
||||
/// spec + scope gate (`tunnel:open`) as the dial/listen variants; the
|
||||
/// bind-first shape is a per-registration establisher choice over the
|
||||
/// same op (ADR-008 §5's per-registration posture) — with
|
||||
/// same op (ADR-008 §5's per-registration posture), with
|
||||
/// [`bindfirst_establisher`] as the establisher and
|
||||
/// [`make_bindfirst_accept_handler`] as the pump. One establisher per
|
||||
/// op id per registry (same note as
|
||||
@@ -972,7 +977,7 @@ fn witness_establisher(
|
||||
/// The correlation material the opener-side handler yields per
|
||||
/// forwarded open: the parsed params + the channel's `BiStream`. The
|
||||
/// ASSEMBLY correlates (token → its waiting listener session
|
||||
/// bookkeeping) and owns the pump — it awaits the returned
|
||||
/// bookkeeping) and owns the pump; it awaits the returned
|
||||
/// `JoinHandle` inline (R-02: the forwarded channel's lifetime tracks
|
||||
/// it; early return = teardown-at-birth reaps the channel), because
|
||||
/// the local halves are assembly state (the SOCKS5 BIND client's
|
||||
@@ -983,10 +988,11 @@ pub type ForwardedHandler =
|
||||
Arc<dyn Fn(TunnelForwardedParams, BiStream) -> tokio::task::JoinHandle<()> + Send + Sync>;
|
||||
|
||||
/// The forwarded op's opener-side pump handler (ADR-008 §3, the
|
||||
/// no-establisher serving shape): parse `TunnelForwardedParams`
|
||||
/// (`deny_unknown_fields` + the positive-`listen` serde pin — a
|
||||
/// serving-shape wrapper both registrations share): parse
|
||||
/// `TunnelForwardedParams`
|
||||
/// (`deny_unknown_fields` + the positive-`listen` serde pin: a
|
||||
/// malformed open fails the parse LOUD; the wrapper then returns early
|
||||
/// and the channel tears down at birth — no phantom channel, the
|
||||
/// and the channel tears down at birth; no phantom channel, the
|
||||
/// correct shape for a malformed ingress), accept the channel's
|
||||
/// `BiStream` (yield-once; accepting BEFORE the assembly handler runs
|
||||
/// keeps the R-02 accepted-flag telemetry truthful), and hand the
|
||||
@@ -995,29 +1001,38 @@ pub type ForwardedHandler =
|
||||
/// **Parse-before-accept ordering (the R-02 telemetry note):** the
|
||||
/// params parse happens BEFORE `conn.accept_bi()`, so for a
|
||||
/// malformed-params open the R-02 accepted-flag telemetry reads
|
||||
/// `false` — by design. A malformed-params open IS a birth-teardown
|
||||
/// `false`, by design. A malformed-params open IS a birth-teardown
|
||||
/// shape (the exact case the telemetry's birth-teardown hint exists
|
||||
/// for); accepting first would hold a stream open for a params set
|
||||
/// the producer already got wrong. The hint firing here is correct,
|
||||
/// not noise — future alkcall reviewers: do not flag the flag
|
||||
/// not noise; future alkcall reviewers: do not flag the flag
|
||||
/// (review 002 N-3).
|
||||
///
|
||||
/// **Pump responsibility split (the honest-shape decision):** the
|
||||
/// injected handler receives the channel `BiStream` and owns the pump
|
||||
/// — `pump_bidi(forwarded_bidi, local_read, local_write)` — because
|
||||
/// (`pump_bidi(forwarded_bidi, local_read, local_write)`) because
|
||||
/// the local halves are assembly state. The protocol-side built-in
|
||||
/// handler only bridges wrapper→assembly; it awaits the injected
|
||||
/// handler's `JoinHandle` inline (R-02 — the forwarded channel's
|
||||
/// handler's `JoinHandle` inline (R-02: the forwarded channel's
|
||||
/// lifetime tracks it, so a correlation miss or a dropped assembly
|
||||
/// handler tears the channel down).
|
||||
///
|
||||
/// **Correlation-miss semantics (documented + test-pinned):** a
|
||||
/// `listen` token with no matching waiting session is the ASSEMBLY's
|
||||
/// rejection (it holds the map): its 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 when that
|
||||
/// open fails (the shapes mirror — per-accept isolation,
|
||||
/// `tunnels/bindfirst-producer`'s posture).
|
||||
/// **Correlation-miss semantics (two-shape posture, ADR-008 Amendment
|
||||
/// 2 + test-pinned):** under the BASE shape (this registration,
|
||||
/// establisher-less), a `listen` token with no matching waiting
|
||||
/// session succeeds at the call plane and the miss is the ASSEMBLY's
|
||||
/// post-open rejection (it holds the map): its handler's error path
|
||||
/// drops the stream and returns; teardown-at-birth reaps the
|
||||
/// forwarded channel (`correlation-miss`, the assembled decline
|
||||
/// event at `debug`). Under the ESTABLISHER variant
|
||||
/// ([`register_tunnel_forwarded_openable_with_establisher`], the
|
||||
/// recommended shape), a no-route token fails at the open as a typed
|
||||
/// `channel:open_failed` before the channel is born; THIS handler's
|
||||
/// post-open miss is then only the residual race (a token vanishing
|
||||
/// between gate pass and handler run); teardown-at-birth remains the
|
||||
/// fallback shape under both. The producer's accept loop drops the
|
||||
/// accepted connection when that open fails (the shapes mirror:
|
||||
/// per-accept isolation, `tunnels/bindfirst-producer`'s posture).
|
||||
pub fn make_forwarded_op_handler(handler: ForwardedHandler) -> OpenHandler {
|
||||
Arc::new(
|
||||
move |input: Value, _plan: Option<ChannelPlan>, conn: Connection, _auth: AuthContext| {
|
||||
@@ -1044,29 +1059,39 @@ pub fn make_forwarded_op_handler(handler: ForwardedHandler) -> OpenHandler {
|
||||
)
|
||||
}
|
||||
|
||||
/// Register the forwarded op's opener-side serving openable (ADR-008
|
||||
/// §3): `ChannelCore::register_openable` — **no establisher** (the
|
||||
/// forwarded
|
||||
/// channel needs no establishment; its data plane starts immediately)
|
||||
/// — scope-gated `tunnel:forwarded` (ingress, a separate grant from
|
||||
/// `tunnel:open`/`tunnel:direct`, never implied by either — ADR-006
|
||||
/// Amendment 1), via [`crate::params::tunnel_forwarded_spec`]. The
|
||||
/// wrapper's
|
||||
/// Register the forwarded op's opener-side serving openable, the
|
||||
/// BASE shape (ADR-008 §3 as amended): `ChannelCore::register_openable`;
|
||||
/// **no establisher** (the forwarded channel needs no establishment;
|
||||
/// its data plane starts immediately), scope-gated `tunnel:forwarded`
|
||||
/// (ingress, a separate grant from `tunnel:open`/`tunnel:direct`,
|
||||
/// never implied by either, ADR-006 Amendment 1), via
|
||||
/// [`crate::params::tunnel_forwarded_spec`]. The wrapper's
|
||||
/// `channel_id` allocation IS the reply (the producer adopts it).
|
||||
///
|
||||
/// **Two-shape posture (ADR-008 Amendment 2):** this base shape stays
|
||||
/// byte-for-byte compatible for assemblies that prefer the post-open
|
||||
/// decline (and as the residual-race fallback posture; see
|
||||
/// [`make_forwarded_op_handler`]'s correlation-miss block). The
|
||||
/// ESTABLISHER variant
|
||||
/// ([`register_tunnel_forwarded_openable_with_establisher`]) is the
|
||||
/// recommended shape: a stale/missing token fails the open as a typed
|
||||
/// `channel:open_failed` before the channel is born, instead of
|
||||
/// succeeding at the call plane and declining post-open. Both ride
|
||||
/// the same op id, params, scope, and reply surface.
|
||||
///
|
||||
/// Identity (CF-005 precedence, upstream machinery): the opener-side
|
||||
/// gate sees the PRODUCER's per-call identity (the producer may carry
|
||||
/// an `auth_token` when it is itself forwarding); the `peer` param is
|
||||
/// producer-asserted metadata, not transport truth — the ACL trusts
|
||||
/// producer-asserted metadata, not transport truth: the ACL trusts
|
||||
/// the grant, the application treats `peer` as metadata. Nothing to
|
||||
/// implement here (upstream's per-call overlay handles it); the test
|
||||
/// suite pins it.
|
||||
///
|
||||
/// Post-hoc registration is supported (W2) — the dispatcher reads
|
||||
/// Post-hoc registration is supported (W2): the dispatcher reads
|
||||
/// through the shared `Arc<OperationRegistry>` per dispatch, the same
|
||||
/// posture as [`register_tunnel_openable`]. One registration per op id
|
||||
/// per registry; the op id is `channels/tunnel/forwarded` (distinct
|
||||
/// from the base op — a session may serve both).
|
||||
/// from the base op; a session may serve both).
|
||||
pub fn register_tunnel_forwarded_openable(
|
||||
core: &ChannelCore,
|
||||
on_registry: &Arc<OperationRegistry>,
|
||||
@@ -1081,13 +1106,131 @@ pub fn register_tunnel_forwarded_openable(
|
||||
)
|
||||
}
|
||||
|
||||
/// The forwarded op's establisher plan payload (ADR-008 Amendment 2):
|
||||
/// an empty marker: a gate pass establishes nothing (the correlation
|
||||
/// map is assembly state and the handler needs nothing it does not
|
||||
/// re-derive), the same typed-opaque [`ChannelPlan`] posture as ADR-049
|
||||
/// amendment 2 with the payload choice pinned at "marker". Its `Some`
|
||||
/// presence in the handler's `plan` parameter signals the gate passed;
|
||||
/// the handler is free to ignore it.
|
||||
pub struct ForwardedPlan;
|
||||
|
||||
/// The injected correlation gate (ADR-008 Amendment 2, plan 003 W1):
|
||||
/// the assembly's one-shot correlation check for the forwarded op's
|
||||
/// serving half. `Err` at the gate → a typed `channel:open_failed`
|
||||
/// BEFORE the channel is born (no phantom channel; ADR-049's posture
|
||||
/// extended to the forwarded op's serving side).
|
||||
///
|
||||
/// The gate receives the PARSED [`TunnelForwardedParams`] and runs
|
||||
/// synchronously; it must be an in-memory map lookup (an await there
|
||||
/// would extend the open's call-plane latency; the lookup shape is
|
||||
/// what every assembly's correlation registry provides).
|
||||
///
|
||||
/// **Gate error convention (the pin):** a stale/missing `listen`
|
||||
/// token SHOULD return [`TunnelEstablishError::UnknownResource`]:
|
||||
/// the waiting session IS a resource that is gone, and that maps to
|
||||
/// `details.reason == "unknown_resource"` on the wire (the canonical
|
||||
/// reason ADR-008 Amendment 2 pins; the wire vocabulary is NOT
|
||||
/// extended). The crate passes through whatever the gate returns;
|
||||
/// the gate owns its rejection vocabulary, within the existing
|
||||
/// [`TunnelEstablishError`] table.
|
||||
pub type ForwardedGate =
|
||||
Arc<dyn Fn(&TunnelForwardedParams) -> Result<(), TunnelEstablishError> + Send + Sync>;
|
||||
|
||||
/// The gate establisher (ADR-008 Amendment 2: the correlation gate
|
||||
/// wrapped into the alkcall [`OpenEstablisher`] dance; assemblies do
|
||||
/// not hand-roll the plumbing, the crate-owned registration surface
|
||||
/// below is the only supported path).
|
||||
///
|
||||
/// **Order (pinned, ADR-008 Amendment 2 decision 3):** the registry
|
||||
/// runs the `tunnel:forwarded` ACL BEFORE the establishment phase
|
||||
/// (alkcall's open-op wrapper order: schema → ACL → allocate →
|
||||
/// establisher → handler), so the gate never sees an unauthorized
|
||||
/// open; a scope-less producer is `FORBIDDEN` before any gate
|
||||
/// lookup. The establisher then parses the input and runs the gate;
|
||||
/// the base shape's handler-side parse stays (see below), so
|
||||
/// `deny_unknown_fields` + the positive-`listen` serde pin gate the
|
||||
/// ingress at BOTH layers.
|
||||
///
|
||||
/// Error mapping (pinned):
|
||||
/// - Establisher parse failure →
|
||||
/// [`TunnelEstablishError::HandlerError`] → the existing
|
||||
/// `From<TunnelEstablishError> for EstablishmentError` table →
|
||||
/// `channel:open_failed` with `details.reason == "handler_error"`.
|
||||
/// This is deliberate: a malformed ingress becomes a typed
|
||||
/// call-plane failure instead of the base shape's silent
|
||||
/// birth-teardown (an improvement ADR-008 Amendment 2 pins; do
|
||||
/// not drift it).
|
||||
/// - Gate `Err(TunnelEstablishError)` → the same existing table, 1:1
|
||||
/// (a stale token: `UnknownResource` → `details.reason ==
|
||||
/// "unknown_resource"`). No new variants are minted.
|
||||
/// - A gate pass produces NO resource handle: the plan payload is the
|
||||
/// empty [`ForwardedPlan`] marker, and there is NO reply-field
|
||||
/// contribution (the op's reply stays the wrapper's reserved
|
||||
/// `channel_id`).
|
||||
///
|
||||
/// `timeout: None` (the alkcall 10s default, as the other
|
||||
/// registrations); the gate is an in-memory lookup; the bound is
|
||||
/// belt-and-braces, not workload.
|
||||
fn make_forwarded_gate_establisher(gate: ForwardedGate) -> OpenEstablisher {
|
||||
Arc::new(move |input: Value, _auth: AuthContext| {
|
||||
let gate = Arc::clone(&gate);
|
||||
Box::pin(async move {
|
||||
let params: TunnelForwardedParams = serde_json::from_value(input.clone())
|
||||
.map_err(|e| TunnelEstablishError::HandlerError(format!("invalid params: {e}")))?;
|
||||
gate(¶ms)?;
|
||||
Ok(Establishment::new(Arc::new(ForwardedPlan) as ChannelPlan))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Register the forwarded op's opener-side serving openable WITH the
|
||||
/// correlation-gate establisher (ADR-008 Amendment 2, the
|
||||
/// recommended shape): `ChannelCore::register_openable_with_establisher`
|
||||
/// with the crate-owned gate establisher (private
|
||||
/// `make_forwarded_gate_establisher`) wrapping the injected
|
||||
/// [`ForwardedGate`]. A stale/missing `listen` token fails the open
|
||||
/// as a typed `channel:open_failed` BEFORE the channel's data plane
|
||||
/// is served; the phantom-channel shape (succeed at the call plane,
|
||||
/// decline post-open, pump EOFs silently) is closed at the open.
|
||||
///
|
||||
/// The same op, params, scope (`tunnel:forwarded`, run before the
|
||||
/// establisher; see the
|
||||
/// `make_forwarded_gate_establisher` doc) and reply
|
||||
/// surface as [`register_tunnel_forwarded_openable`] ride; the wire
|
||||
/// delta of Amendment 2 is empty (additive registration only).
|
||||
///
|
||||
/// The base shape (establisher-less) stays for compatibility + as the
|
||||
/// residual-race fallback posture: a token that vanishes between
|
||||
/// gate pass and handler run is post-open; the handler's
|
||||
/// teardown-at-birth semantics (see [`make_forwarded_op_handler`]'s
|
||||
/// correlation-miss block) are the only thing that can reap it, the
|
||||
/// same under both shapes. New assemblies take this shape by default
|
||||
/// (ADR-008 Amendment 2 §Additive-only surface).
|
||||
pub fn register_tunnel_forwarded_openable_with_establisher(
|
||||
core: &ChannelCore,
|
||||
on_registry: &Arc<OperationRegistry>,
|
||||
auth: AuthContext,
|
||||
gate: ForwardedGate,
|
||||
handler: ForwardedHandler,
|
||||
) -> Result<(), String> {
|
||||
core.register_openable_with_establisher(
|
||||
crate::params::tunnel_forwarded_spec(),
|
||||
Some(make_forwarded_gate_establisher(gate)),
|
||||
make_forwarded_op_handler(handler),
|
||||
on_registry,
|
||||
auth,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The plan-payload contract: `BindFirstPlan` rides the
|
||||
/// typed-opaque `ChannelPlan` (`Arc<dyn Any + Send + Sync>`) and
|
||||
/// the accept dispatcher recovers it by downcast — the same
|
||||
/// the accept dispatcher recovers it by downcast: the same
|
||||
/// establisher/handler agreement `TargetHandle` pins. The opaque
|
||||
/// listener half downcasts to the assembly's concrete type.
|
||||
#[test]
|
||||
@@ -1125,7 +1268,7 @@ mod tests {
|
||||
|
||||
/// The mint discipline: the counter seeds at 1, the first mint is
|
||||
/// 1, the stream is per-open fresh + per-producer unique, and
|
||||
/// token 0 is never minted (the loop skips it — a wrap that lands
|
||||
/// token 0 is never minted (the loop skips it: a wrap that lands
|
||||
/// on 0 re-increments).
|
||||
#[test]
|
||||
fn mint_token_seeds_nonzero_and_increments() {
|
||||
@@ -1142,7 +1285,7 @@ mod tests {
|
||||
/// re-increments (the loop skips the illegal wire value).
|
||||
#[test]
|
||||
fn mint_token_never_yields_zero() {
|
||||
// Force the wrap shape: seed the counter at u32::MAX — the
|
||||
// Force the wrap shape: seed the counter at u32::MAX; the
|
||||
// next fetch_add returns u32::MAX (legal), then wraps to 0,
|
||||
// which the loop must skip (the mint after the wrap is 1).
|
||||
let counter = std::sync::atomic::AtomicU32::new(u32::MAX);
|
||||
@@ -1250,7 +1393,7 @@ mod tests {
|
||||
assert_ne!(t1, t2, "two opens mint distinct tokens");
|
||||
}
|
||||
|
||||
/// The registry miss → `unknown_resource` (the namespace gate —
|
||||
/// The registry miss → `unknown_resource` (the namespace gate,
|
||||
/// the same posture as the listen establisher).
|
||||
#[tokio::test]
|
||||
async fn bindfirst_establisher_registry_miss_is_unknown_resource() {
|
||||
|
||||
+13
-13
@@ -4,14 +4,14 @@
|
||||
//! carries its BAST contract at `docs/architecture/bast.md`.
|
||||
//!
|
||||
//! The channels layer already length-prefixes every chunk (8-byte
|
||||
//! header), so stream substrates need zero tunnel-level framing — the
|
||||
//! header), so stream substrates need zero tunnel-level framing: the
|
||||
//! halves are the tunnel, and a zero-length read is genuinely EOF
|
||||
//! (stream semantics). UDP needs boundary re-framing only: datagram
|
||||
//! boundaries do not survive the chunk stream, and `u16` suffices
|
||||
//! (UDP's max payload 65507 < 65535).
|
||||
//!
|
||||
//! Sentinel layering (the F-2 invariant, load-bearing): `len == 0` is
|
||||
//! a legal *empty datagram* — a real datagram, never an EOF signal.
|
||||
//! a legal *empty datagram* (a real datagram, never an EOF signal).
|
||||
//! EOF is the channels-level sentinel on the `BiStream` and lives at
|
||||
//! a different layer; the codec never emits zero-length reads, so the
|
||||
//! layers never collide. This is why raw pass-through is structurally
|
||||
@@ -20,31 +20,31 @@
|
||||
//!
|
||||
//! No chunk-type byte, no 5-byte header: a tunnel has exactly one data
|
||||
//! stream per direction (the channel's read/write halves), so there is
|
||||
//! no sub-demux key — the structural difference from alktty's 5-byte
|
||||
//! no sub-demux key: the structural difference from alktty's 5-byte
|
||||
//! `[stream_type][len]` header.
|
||||
|
||||
use bytes::{Buf, BufMut, Bytes, BytesMut};
|
||||
use thiserror::Error;
|
||||
|
||||
/// The u16 length-field bound: datagrams larger than this are
|
||||
/// rejected at frame time (ADR-003 — never a wire overflow).
|
||||
/// rejected at frame time (ADR-003; never a wire overflow).
|
||||
pub const MAX_DATAGRAM_LEN: usize = u16::MAX as usize;
|
||||
|
||||
const DATAGRAM_LEN_FIELD: usize = 2;
|
||||
|
||||
/// Codec errors: oversize at frame time. The truncated-stream case is
|
||||
/// not a codec error — the decoder buffers partial frames indefinitely
|
||||
/// not a codec error: the decoder buffers partial frames indefinitely
|
||||
/// (`is_mid_datagram` is the diagnostic) and the SESSION surfaces a
|
||||
/// stream end mid-datagram as `TunnelIoError::TruncatedDatagram` (the
|
||||
/// fail-loud posture, OQ-TN-13 — never a silent partial datagram).
|
||||
/// fail-loud posture, OQ-TN-13: never a silent partial datagram).
|
||||
///
|
||||
/// The decode direction (`DatagramReader::feed`) is infallible: with
|
||||
/// the oversize bound enforced at frame time, a declared length can
|
||||
/// never exceed what the frame itself carries, so the incremental
|
||||
/// decode cannot fail — only buffer and complete.
|
||||
/// decode cannot fail, only buffer and complete.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum DatagramCodecError {
|
||||
/// A datagram larger than [`MAX_DATAGRAM_LEN`] was framed — the
|
||||
/// A datagram larger than [`MAX_DATAGRAM_LEN`] was framed; the
|
||||
/// u16 length field would wrap. Rejected at frame time.
|
||||
#[error("datagram too large for u16 length prefix: {0} bytes (max {1})")]
|
||||
Oversize(usize, usize),
|
||||
@@ -68,7 +68,7 @@ pub fn frame_datagram(payload: &[u8]) -> Result<Bytes, DatagramCodecError> {
|
||||
|
||||
/// Incremental datagram decoder over the channel's read half. Feed it
|
||||
/// whatever `read()` yields; it reassembles `[len: u16 BE][payload]`
|
||||
/// frames across chunk boundaries — a datagram may be split across
|
||||
/// frames across chunk boundaries: a datagram may be split across
|
||||
/// two chunks, two datagrams may share one chunk, and partial headers
|
||||
/// wait for more bytes. This is the only consumer-visible decode path
|
||||
/// (ADR-003; chunk splitting/batching is transparent).
|
||||
@@ -93,10 +93,10 @@ impl DatagramReader {
|
||||
}
|
||||
|
||||
/// Feed raw bytes from the read half. Returns every datagram that
|
||||
/// completed with this feed, in order — zero or more per chunk.
|
||||
/// completed with this feed, in order (zero or more per chunk).
|
||||
/// Buffers partial frames across chunk boundaries. Infallible: the
|
||||
/// decode direction cannot fail (see the [`DatagramCodecError`]
|
||||
/// docs — the oversize bound is enforced at frame time).
|
||||
/// docs: the oversize bound is enforced at frame time).
|
||||
pub fn feed(&mut self, chunk: &[u8]) -> Vec<Bytes> {
|
||||
self.buf.extend_from_slice(chunk);
|
||||
let mut out = Vec::new();
|
||||
@@ -134,7 +134,7 @@ impl DatagramReader {
|
||||
}
|
||||
|
||||
/// `true` while a partial datagram is in flight (for teardown
|
||||
/// diagnostics — a clean peer never ends the stream mid-datagram;
|
||||
/// diagnostics: a clean peer never ends the stream mid-datagram;
|
||||
/// an end while this is set is the session's
|
||||
/// `TruncatedDatagram` condition).
|
||||
pub fn is_mid_datagram(&self) -> bool {
|
||||
@@ -144,7 +144,7 @@ impl DatagramReader {
|
||||
/// Bytes currently retained by the decoder state: the leftover
|
||||
/// buffer plus the in-flight partial datagram's accumulated
|
||||
/// payload. Post-feed this is bounded by one frame
|
||||
/// (≤ [`MAX_DATAGRAM_LEN`] + 2) — the decoder never grows its
|
||||
/// (≤ [`MAX_DATAGRAM_LEN`] + 2); the decoder never grows its
|
||||
/// state with fed bytes beyond the frame in progress (pinned by
|
||||
/// the fuzz harness, `docs/research/fuzzing.md` §3.1).
|
||||
pub fn retained_bytes(&self) -> usize {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
---
|
||||
id: tunnels/corrmiss-adr
|
||||
name: "ADR: the forwarded op's establishment phase + the correlation-miss vocabulary (resolves review 003's decisions)"
|
||||
status: completed
|
||||
depends_on: []
|
||||
scope: narrow
|
||||
risk: medium
|
||||
impact: phase
|
||||
level: implementation
|
||||
tags: [adr, forwarder, correlation-miss, vocabulary, review-003]
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
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
|
||||
`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
|
||||
**`correlation-miss`** as the canonical event name with alkssh's level
|
||||
posture (assembly-side declines at `debug`; the producer-side generic
|
||||
arm keeps `warn` now carrying a typed reason instead of a prose one).
|
||||
|
||||
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
|
||||
003's situation section plus the plan's §Verified findings are the
|
||||
primary sources for it.
|
||||
|
||||
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
|
||||
forwarded op's serving shape exactly as §3 pins it (that section's
|
||||
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
|
||||
successor ADR instead only if the amendment balloons
|
||||
(e.g. it forces restating §4/§5 wholesale). Whichever shape: mark the
|
||||
amended §3 sentence, cross-link the plan and review 003, keep the
|
||||
existing ADR text's amendment style (the landed Amendment 1 is the
|
||||
format precedent).
|
||||
|
||||
### The invariants the ADR must pin
|
||||
|
||||
- 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
|
||||
(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), 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, 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).
|
||||
- Additive-only surface: the base `register_tunnel_forwarded_openable`
|
||||
shape stays for compatibility; the establisher variant is the
|
||||
recommended posture (new assemblies take it by default posture).
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] Amendment 2 to ADR-008 (or the successor ADR) lands under
|
||||
`docs/architecture/decisions/`, covering all four §Decisions
|
||||
and the tri-vocabulary history
|
||||
- [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)
|
||||
- [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
|
||||
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
|
||||
instead)
|
||||
|
||||
## References
|
||||
|
||||
- docs/plans/003-correlation-miss-resolve.md §Decisions, §Verified
|
||||
findings, §W1 (this task is the ADR bullet of W1)
|
||||
- docs/reviews/003-correlation-miss-arrival-class-alkssh-input.md
|
||||
- docs/architecture/decisions/008-listen-metadata-forwarded-op.md §3
|
||||
(the serving shape being amended; Amendment 1 is the format
|
||||
precedent)
|
||||
- docs/architecture/wire.md §The Open Ops (the pinned
|
||||
`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, 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 →
|
||||
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
|
||||
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
|
||||
the API task per the amendment's note) and review 003's status
|
||||
block (doc-sync task owns it).
|
||||
|
||||
## Summary
|
||||
|
||||
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
|
||||
`TunnelForwardedParams`) the crate wraps into the alkcall
|
||||
`OpenEstablisher` dance via `register_openable_with_establisher`,
|
||||
with failures mapped to `EstablishmentError::UnknownResource` so a
|
||||
stale/missing `listen` token is a typed
|
||||
`channel:open_failed`/`unknown_resource` before any channel is born.
|
||||
`unknown_resource` is reused as-is; the wire vocabulary is unextended
|
||||
and `wire.md` is untouched. Gate order pinned (`tunnel:forwarded` ACL
|
||||
precedes the establisher); the residual post-open race keeps
|
||||
teardown-at-birth with the canonical assembly-side name
|
||||
`correlation-miss` at `debug` (producer-side generic arm stays `warn`
|
||||
with the typed reason); the base
|
||||
`register_tunnel_forwarded_openable` stays, the establisher variant
|
||||
is the recommended default posture; the wire delta of the amendment
|
||||
is empty. The tri-vocabulary history (alkssh `correlation-miss`
|
||||
debug, alksocks prose warn, alktunnels `handler_error`-class generic
|
||||
warn + the fabric-side silence) is recorded with sources. Doc-only;
|
||||
no code changes.
|
||||
@@ -0,0 +1,106 @@
|
||||
---
|
||||
id: tunnels/corrmiss-doc-sync
|
||||
name: "Doc sync: review 003 status + plan 003 checkboxes + plan-level AC close-out (W1)"
|
||||
status: completed
|
||||
depends_on: [tunnels/corrmiss-review]
|
||||
scope: narrow
|
||||
risk: trivial
|
||||
impact: component
|
||||
level: implementation
|
||||
tags: [docs, review-003, status, bookkeeping]
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Close out W1's remaining doc bookkeeping (the plan-level AC's "review
|
||||
003 status updated to resolved (W1 absorbed; W2/W3 pointed)" clause):
|
||||
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,
|
||||
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
|
||||
the status-block format the other reviews use.
|
||||
- `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, 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
|
||||
TODOs/comments pointing at superseded designs get removed, not
|
||||
annotated.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] Review 003's Status block reads resolved-for-W1 with the W2/W3
|
||||
pointer
|
||||
- [x] Plan 003's W1 acceptance clauses ticked; W2/W3 left visibly
|
||||
open
|
||||
- [x] A stale-comment sweep across `src/` + `docs/architecture/` ran
|
||||
clean (or the stragglers aligned)
|
||||
- [x] `cargo doc --no-deps` clean (doc edits touch lib.rs-adjacent
|
||||
comments only if the sweep found any)
|
||||
|
||||
## References
|
||||
|
||||
- docs/reviews/003-correlation-miss-arrival-class-alkssh-input.md
|
||||
(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, the
|
||||
same job shape for review 002)
|
||||
|
||||
## Notes
|
||||
|
||||
The sweep grep (`correlation miss` / `correlation-miss` /
|
||||
`handler_error`-class across `src/` + `docs/architecture/`) found the
|
||||
two-shape posture docs already synced by the earlier trio (the
|
||||
`make_forwarded_op_handler` doc, both registration docs, the gate
|
||||
establisher doc, wire.md §The Forwarded Op's registration paragraph,
|
||||
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
|
||||
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,
|
||||
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", 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,
|
||||
`correlation-miss` at `debug`.
|
||||
- `producer.md` §The Forwarded Op: "(ADR-008 §3) … no establisher,
|
||||
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.
|
||||
|
||||
## Summary
|
||||
|
||||
Review 003's Status block now reads resolved-for-W1: the ask absorbed
|
||||
via plan 003 §Decisions 1–4 + ADR-008 Amendment 2 (typed
|
||||
`unknown_resource` open_failed at the gate; `correlation-miss` at
|
||||
`debug` for the residual decline; `unknown_resource` reused, wire
|
||||
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, 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
|
||||
the two-shape posture. Verification: `cargo test` (14 suites, 0
|
||||
failures), clippy `--all-targets -- -D warnings` clean,
|
||||
`cargo fmt --check` clean, `cargo doc --no-deps` clean.
|
||||
@@ -0,0 +1,187 @@
|
||||
---
|
||||
id: tunnels/corrmiss-forwarded-gate
|
||||
name: "API: the forwarded-op establisher variant (ForwardedGate + register_tunnel_forwarded_openable_with_establisher)"
|
||||
status: completed
|
||||
depends_on: [tunnels/corrmiss-adr]
|
||||
scope: moderate
|
||||
risk: medium
|
||||
impact: component
|
||||
level: implementation
|
||||
tags: [api, establisher, forwarded-op, correlation-miss, review-003]
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
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
|
||||
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, which this task
|
||||
finalizes it):
|
||||
|
||||
```rust
|
||||
/// The injected correlation gate: `Err` at the gate → typed
|
||||
/// `channel:open_failed` before the channel is born.
|
||||
pub type ForwardedGate = Arc<
|
||||
dyn Fn(&TunnelForwardedParams) -> Result<(), TunnelEstablishError> + Send + Sync,
|
||||
>;
|
||||
|
||||
pub fn register_tunnel_forwarded_openable_with_establisher(
|
||||
core: &ChannelCore,
|
||||
on_registry: &Arc<OperationRegistry>,
|
||||
auth: AuthContext,
|
||||
gate: ForwardedGate,
|
||||
handler: ForwardedHandler,
|
||||
) -> Result<(), String>
|
||||
```
|
||||
|
||||
### 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
|
||||
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, 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). 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
|
||||
`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)
|
||||
as the other registrations do; the gate is an in-memory map lookup.
|
||||
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
|
||||
order in a doc comment + a test, see the tests task).
|
||||
|
||||
### Also in this task (the doc half of W1's API item)
|
||||
|
||||
- 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
|
||||
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
|
||||
semantics (still the residual-race fallback under the 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, per plan decision 4).
|
||||
|
||||
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
|
||||
|
||||
- [ ] `ForwardedGate` type + `register_tunnel_forwarded_openable_with_establisher`
|
||||
landed in `src/producer.rs`, re-exported from `lib.rs`
|
||||
- [ ] The establisher parse-failure → typed `channel:open_failed`
|
||||
mapping is deliberate + documented (design decision 1)
|
||||
- [ ] Gate errors pass through the existing
|
||||
`From<TunnelEstablishError>` table; no new error variants
|
||||
- [ ] `make_forwarded_op_handler` / the base registration are
|
||||
unchanged in behavior (byte-for-byte compat)
|
||||
- [ ] The in-code docs updated: the two-shape posture (establisher =
|
||||
recommended; base = compatibility + residual-race fallback)
|
||||
replaces the assembly-side-only framing
|
||||
- [ ] `cargo test` green (existing suite untouched); clippy
|
||||
`--all-targets -- -D warnings` clean (native + wasm32);
|
||||
`cargo fmt --check` clean; default crate stays wasm-clean
|
||||
- [ ] `--all-features` test run green (the `local` feature surface is
|
||||
untouched)
|
||||
|
||||
## References
|
||||
|
||||
- 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;
|
||||
cite whichever the ADR task landed)
|
||||
- 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`
|
||||
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
|
||||
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
|
||||
load-bearing; typed-opaque plans), 8 (teardown-at-birth posture),
|
||||
16 (re-export posture)
|
||||
|
||||
## Notes
|
||||
|
||||
- `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)`,
|
||||
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
|
||||
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`
|
||||
unit struct (chosen over `()`: self-documenting, downcast-able by
|
||||
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
|
||||
pins `UnknownResource` as the canonical stale-token reason;
|
||||
(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.
|
||||
- Doc updates: `make_forwarded_op_handler`'s correlation-miss block +
|
||||
`register_tunnel_forwarded_openable`'s doc now carry the two-shape
|
||||
posture. Two adjacent doc drifts were synced in the same wave per
|
||||
ADR-008 Amendment 2 ("syncs with the API task's in-code doc
|
||||
updates"): `wire.md` §The Forwarded Op's "no establisher" prose
|
||||
line, and `tunnel_forwarded_spec()`'s "No establisher" doc block in
|
||||
`src/params.rs`.
|
||||
|
||||
## Summary
|
||||
|
||||
Landed the forwarded-op establisher variant per ADR-008 Amendment 2:
|
||||
`ForwardedGate`, `ForwardedPlan`, `make_forwarded_gate_establisher`
|
||||
(private), and `register_tunnel_forwarded_openable_with_establisher`
|
||||
in `src/producer.rs`, re-exported from `lib.rs`. A gate `Err` (and an
|
||||
establisher parse failure) now yields a typed `channel:open_failed`
|
||||
before the channel is born; the base shape stays byte-for-byte
|
||||
compatible as the compatibility + residual-race-fallback posture.
|
||||
In-code docs updated (two-shape posture) plus the wire.md/params.rs
|
||||
doc sync the amendment rides on this task. Verification: `cargo test`
|
||||
green (existing suite untouched), clippy `--all-targets -D warnings`
|
||||
clean native + wasm32, `cargo fmt --check` clean, `cargo doc
|
||||
--no-deps` clean (0 warnings), `--all-features` test run green.
|
||||
Follow-ups: the dedicated gate-miss tests task
|
||||
(`tunnels/corrmiss-gate-tests`), then the sibling-repo adoption waves
|
||||
(plan 003 §W2/§W3; executed in the alkssh and alksocks repos).
|
||||
@@ -0,0 +1,190 @@
|
||||
---
|
||||
id: tunnels/corrmiss-gate-tests
|
||||
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
|
||||
risk: low
|
||||
impact: component
|
||||
level: implementation
|
||||
tags: [tests, establisher, forwarded-op, correlation-miss, review-003]
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
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`, 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).
|
||||
|
||||
The four pins:
|
||||
|
||||
1. **Miss at the gate → typed failure, no phantom.** A `listen` token
|
||||
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,
|
||||
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
|
||||
gate accepts → the registration serves exactly like the base
|
||||
shape: the injected `ForwardedHandler` receives the parsed params +
|
||||
the `BiStream`, the byte round-trip works end to end (the
|
||||
`producer_forwarded.rs` round-trip shape, driven through the
|
||||
establisher-registered variant).
|
||||
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
|
||||
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
|
||||
before the open op's establishment phase; pin the order.
|
||||
|
||||
If the establisher variant's parse-failure behavior (malformed params
|
||||
→ typed `channel:open_failed` rather than birth-teardown, the design
|
||||
decision 1 of the API task) is separately testable through the public
|
||||
surface, pin it as a fifth test; if the existing malformed-params test
|
||||
already covers the base shape, note the establisher-shape delta
|
||||
explicitly (do not silently assume).
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Gate-miss test: typed `channel:open_failed` /
|
||||
`unknown_resource` / no handler invocation / no phantom channel
|
||||
- [ ] Gate-pass test: the full round-trip through the establisher
|
||||
variant (handler receives parsed params + stream, bytes
|
||||
round-trip)
|
||||
- [ ] Residual-race test: gate passes → handler-time miss → the
|
||||
channel reaps (teardown-at-birth re-pinned)
|
||||
- [ ] Ordering test: missing `tunnel:forwarded` scope → FORBIDDEN and
|
||||
the gate never invoked
|
||||
- [ ] The establisher-variant malformed-params delta is pinned or
|
||||
explicitly noted
|
||||
- [ ] Full verification green: `cargo test`,
|
||||
`cargo clippy --all-targets -- -D warnings` (native + wasm32),
|
||||
`cargo fmt --check`,
|
||||
`cargo check --target wasm32-unknown-unknown`,
|
||||
`cargo test --all-features`
|
||||
|
||||
## References
|
||||
|
||||
- docs/plans/003-correlation-miss-resolve.md §W1 (the Tests bullet;
|
||||
plan-level AC "W1 ... tests green")
|
||||
- tests/producer_forwarded.rs (the shapes to mirror: the serving e2e,
|
||||
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))
|
||||
- docs/architecture/wire.md §The Open Ops (the
|
||||
`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` 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, 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
|
||||
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
|
||||
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
|
||||
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, 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
|
||||
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, where
|
||||
the establisher-pass→handler-run window is the residual
|
||||
`correlation-miss` class.
|
||||
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), 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 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
|
||||
gate (the gate's witness stays unmoved), the wrapper tears the
|
||||
transient allocation down, and the handler never runs.
|
||||
- Verification deviation, discovered and characterized, pre-existing:
|
||||
`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
|
||||
re-running: identical failure without the change. The repo's
|
||||
AGENTS.md verification list specifies wasm clippy WITHOUT
|
||||
`--all-targets`
|
||||
(`cargo clippy --target wasm32-unknown-unknown -- -D warnings`),
|
||||
which is green; the AC's "(native + wasm32)" is read through the
|
||||
AGENTS.md command list as `--all-targets` native + the no-targets
|
||||
wasm clippy (same posture the API task's summary pinned: clippy
|
||||
`--all-targets` native, wasm clippy clean).
|
||||
|
||||
## Summary
|
||||
|
||||
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` /
|
||||
`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
|
||||
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
|
||||
field becomes a typed `channel:open_failed` with
|
||||
`details.reason == "handler_error"` and the parse precedes the gate.
|
||||
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 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
|
||||
adoption waves (plan 003 §W2/§W3; executed in the alkssh and alksocks
|
||||
repos). The
|
||||
W1 wave's tests are green.
|
||||
@@ -0,0 +1,153 @@
|
||||
---
|
||||
id: tunnels/corrmiss-review
|
||||
name: "Review: the W1 gate surface before the consumer waves adopt it (the {W2, W3} fork's critical point)"
|
||||
status: completed
|
||||
depends_on: [tunnels/corrmiss-gate-tests]
|
||||
scope: narrow
|
||||
risk: low
|
||||
impact: phase
|
||||
level: review
|
||||
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
|
||||
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
|
||||
API surface is de-facto wire-stable for them.
|
||||
|
||||
Check:
|
||||
|
||||
- [x] The API matches the ADR ink (the gate type, the registration
|
||||
signature, the error-mapping pin, the timeout, the no-scoped
|
||||
change)
|
||||
- [x] The doc comments' two-shape posture (establisher = recommended;
|
||||
base = compatibility + residual-race fallback) is consistent
|
||||
across `make_forwarded_op_handler`, the base/variant
|
||||
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
|
||||
- [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`, 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
|
||||
moment before the consumers fork.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] All checks above pass; any found shape drift fixed and
|
||||
re-verified before the consumer-wave tasks leave `pending`
|
||||
- [x] The review outcome recorded in this task's Notes (what was
|
||||
checked, what moved if anything)
|
||||
|
||||
## References
|
||||
|
||||
- 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)
|
||||
- tasks/tunnels/corrmiss-adr.md, corrmiss-forwarded-gate.md,
|
||||
corrmiss-gate-tests.md (the W1 trio being reviewed)
|
||||
- src/producer.rs (the landed surface)
|
||||
|
||||
## Notes
|
||||
|
||||
Review performed 2026-11-03 (review checkpoint gating the W1 → {W2,
|
||||
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.
|
||||
- The gate type `ForwardedGate = Arc<dyn Fn(&TunnelForwardedParams)
|
||||
-> Result<(), TunnelEstablishError> + Send + Sync>` matches the
|
||||
plan's candidate shape verbatim; the registration signature
|
||||
`(core, on_registry, auth, gate, handler) -> Result<(), String>`
|
||||
matches.
|
||||
- Error-mapping pin: gate `Err` passes through the existing
|
||||
`From<TunnelEstablishError> for EstablishmentError` table
|
||||
(`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
|
||||
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.
|
||||
- No scope-table change: the same `tunnel_forwarded_spec()` /
|
||||
`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, as
|
||||
self-documenting, downcast-able; consistent with the typed-opaque
|
||||
posture of `TargetHandle`/`BindFirstPlan`).
|
||||
- **Two-shape posture consistency (check 2):** present and consistent
|
||||
in `make_forwarded_op_handler`'s correlation-miss block, the base
|
||||
registration's doc, the variant registration's doc, `lib.rs`'s
|
||||
re-export surface (base + variant + `ForwardedGate`/`ForwardedPlan`
|
||||
all re-exported), plus the downstream syncs (`wire.md` §The
|
||||
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, 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
|
||||
`tests/producer_forwarded_gate.rs` pin wire-observable behavior, not
|
||||
internal shapes: the miss asserts `err.code == "channel:open_failed"`
|
||||
and `details["reason"] == "unknown_resource"` plus no-phantom (no
|
||||
opener-side channels) and handler-not-run; the ordering test asserts
|
||||
`FORBIDDEN` naming the missing scope with the gate witness unmoved;
|
||||
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, 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
|
||||
`Establishment::new(Arc::new(..) as ChannelPlan)` construction); the
|
||||
error table is reused 1:1; no new error axes; poisoned-lock test
|
||||
helpers follow convention 2 (`unwrap_or_else(|e| e.into_inner())`);
|
||||
the private establisher + public registration split matches the
|
||||
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
|
||||
the establisher variant per this task's checklist.
|
||||
- **Verification (check 5), run at this review:** `cargo test` green
|
||||
(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
|
||||
stays wasm-clean), `cargo fmt --check` clean, `cargo test
|
||||
--all-features` green (14 suites), `cargo doc --no-deps` clean.
|
||||
The wasm-clippy `--all-targets` deviation recorded in
|
||||
corrmiss-gate-tests' Notes is pre-existing and unchanged (verified
|
||||
there; not re-triangulated here).
|
||||
|
||||
## Summary
|
||||
|
||||
Review checkpoint passed: the W1 gate surface (`ForwardedGate`,
|
||||
`ForwardedPlan`, `register_tunnel_forwarded_openable_with_establisher`)
|
||||
matches ADR-008 Amendment 2's ink on every pin (gate shape,
|
||||
registration signature, error mapping through the existing
|
||||
`From<TunnelEstablishError>` table, typed parse-failure posture, the
|
||||
10s default timeout, the unchanged scope table); the two-shape posture
|
||||
is consistent across all docs and re-exports (one header drift
|
||||
retitled); the five gate tests assert the wire-observable contract at
|
||||
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 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);
|
||||
|
||||
@@ -0,0 +1,586 @@
|
||||
//! Integration tests for the forwarded op's establisher variant
|
||||
//! (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`
|
||||
//! (the base serving shape is that file's; these probes cover the
|
||||
//! establisher-registered delta).
|
||||
//!
|
||||
//! Covered:
|
||||
//! - 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
|
||||
//! reply is the typed error, not a phantom channel); the pump
|
||||
//! handler NEVER runs.
|
||||
//! - 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 →
|
||||
//! the handler's error path drops the stream → teardown-at-birth
|
||||
//! reaps the forwarded channel (re-pinned under the establisher
|
||||
//! variant; the establisher-pass→handler-run window is the residual
|
||||
//! `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 →
|
||||
//! allocate → establisher → handler).
|
||||
//! - the establisher-variant malformed-params delta: an unknown
|
||||
//! top-level field (passes the schema's additive posture, fails the
|
||||
//! establisher's serde parse) → a typed `channel:open_failed` with
|
||||
//! `details.reason == "handler_error"`, NOT the base shape's
|
||||
//! success-then-birth-teardown; the parse precedes the gate (the
|
||||
//! gate never sees malformed input).
|
||||
|
||||
mod harness;
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use alkcall::channels::manager::ChannelManager;
|
||||
use alkcall::core::auth::Identity;
|
||||
use alkcall::protocol::wire::CallError;
|
||||
use alktunnels::params::{TUNNEL_FORWARDED_SCOPE, TUNNEL_OPEN_SCOPE};
|
||||
use alktunnels::producer::{
|
||||
register_tunnel_forwarded_openable_with_establisher, ForwardedGate, TargetHandle,
|
||||
TunnelEstablishError,
|
||||
};
|
||||
|
||||
use harness::{wire_with, RegistrationMode};
|
||||
|
||||
/// The opener-side identity holding ONLY `tunnel:forwarded` (the
|
||||
/// ingress grant; `tests/producer_forwarded.rs`'s shape).
|
||||
fn forwarded_only_identity() -> Identity {
|
||||
Identity {
|
||||
id: "opener-forwarded-only".to_string(),
|
||||
scopes: vec![TUNNEL_FORWARDED_SCOPE.to_string()],
|
||||
resources: std::collections::HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A plain dial-egress identity (`tunnel:open` only, no
|
||||
/// `tunnel:forwarded`; the ordering probe's identity).
|
||||
fn open_only_identity() -> Identity {
|
||||
Identity {
|
||||
id: "opener-open-only".to_string(),
|
||||
scopes: vec![TUNNEL_OPEN_SCOPE.to_string()],
|
||||
resources: std::collections::HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// What the assembly's test handler received per forwarded open (the
|
||||
/// invocation probe; unmoved = the handler never ran).
|
||||
#[derive(Default)]
|
||||
struct Received {
|
||||
params: Option<alktunnels::params::TunnelForwardedParams>,
|
||||
}
|
||||
|
||||
type ReceivedWitness = Arc<tokio::sync::Mutex<Received>>;
|
||||
|
||||
/// The gate-invocation witness: the gate sets it on entry; an
|
||||
/// ordering assertion (`assert !moved`) pins "the gate never saw the
|
||||
/// open" (the scope-gate precedence pin, task AC 4).
|
||||
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).
|
||||
type WaitingTokens = Arc<Mutex<HashSet<u32>>>;
|
||||
|
||||
/// The gate closure under test: set the witness on entry (even for
|
||||
/// 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| {
|
||||
invoked.store(true, Ordering::SeqCst);
|
||||
if waiting
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.contains(¶ms.listen)
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
Err(TunnelEstablishError::UnknownResource(format!(
|
||||
"no waiting session for listen token {}",
|
||||
params.listen
|
||||
)))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Serve the forwarded op on the opener side through the establisher
|
||||
/// 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,
|
||||
/// teardown-at-birth) with the gate establisher wrapped around it.
|
||||
fn serve_forwarded_establisher_on(
|
||||
consumer_registry: &Arc<alkcall::registry::registration::OperationRegistry>,
|
||||
manager: ChannelManager,
|
||||
received: ReceivedWitness,
|
||||
local_halves: Arc<tokio::sync::Mutex<Vec<TargetHandle>>>,
|
||||
gate: ForwardedGate,
|
||||
) {
|
||||
let handler: alktunnels::producer::ForwardedHandler = Arc::new(move |forwarded, bidi| {
|
||||
let received = Arc::clone(&received);
|
||||
let local_halves = Arc::clone(&local_halves);
|
||||
tokio::spawn(async move {
|
||||
*received.lock().await = Received {
|
||||
params: Some(forwarded),
|
||||
};
|
||||
let target = local_halves.lock().await.pop();
|
||||
if let Some(target) = target {
|
||||
alkcall::channels::pump::pump_bidi(bidi, target.read, target.write).await;
|
||||
}
|
||||
})
|
||||
});
|
||||
let core = alkcall::channels::operations::ChannelCore::new(
|
||||
manager,
|
||||
alkcall::channels::policy::default_policy(),
|
||||
);
|
||||
register_tunnel_forwarded_openable_with_establisher(
|
||||
&core,
|
||||
consumer_registry,
|
||||
alkcall::core::auth::AuthContext::anonymous(b"alk/tunnel"),
|
||||
gate,
|
||||
handler,
|
||||
)
|
||||
.expect("register forwarded openable with establisher");
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// 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
|
||||
/// opener-allocated `channel_id` in the reply is what the producer
|
||||
/// adopts.
|
||||
async fn wire_forwarded_establisher(identity: Identity) -> harness::Topology {
|
||||
wire_with(
|
||||
alktunnels::producer::ResourceRegistry::new(),
|
||||
RegistrationMode::Dial(harness::failing_dial(
|
||||
"base op unused in forwarded-gate probes",
|
||||
)),
|
||||
Some(identity),
|
||||
None,
|
||||
Arc::new(alkcall::core::auth::NoopIdentityProvider),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn forwarded_input(listen: u32, peer: (u32, u16)) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"listen": listen,
|
||||
"substrate": "tcp",
|
||||
"peer": {"host": peer.0.to_string(), "port": peer.1},
|
||||
})
|
||||
}
|
||||
|
||||
/// Call the forwarded op on the producer's per-accept call plane.
|
||||
async fn call_forwarded(
|
||||
producer: &alkcall::channels::client::ChannelClient,
|
||||
input: serde_json::Value,
|
||||
) -> Result<serde_json::Value, CallError> {
|
||||
producer
|
||||
.call_open_op(alktunnels::params::OP_TUNNEL_FORWARDED, input)
|
||||
.await
|
||||
.result
|
||||
}
|
||||
|
||||
/// The accepted-side echo pair (the producer-side pump target).
|
||||
fn driving_halves() -> TargetHandle {
|
||||
let (drive_end, target_end) = tokio::io::duplex(64 * 1024);
|
||||
let (mut t_read, mut t_write) = tokio::io::split(target_end);
|
||||
tokio::spawn(async move {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let mut buf = vec![0u8; 8192];
|
||||
loop {
|
||||
match t_read.read(&mut buf).await {
|
||||
Ok(0) | Err(_) => return,
|
||||
Ok(n) => {
|
||||
if t_write.write_all(&buf[..n]).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let (d_read, d_write) = tokio::io::split(drive_end);
|
||||
TargetHandle {
|
||||
read: Box::new(d_read),
|
||||
write: Box::new(d_write),
|
||||
}
|
||||
}
|
||||
|
||||
/// The "local BIND client" pair (the opener-side pump target).
|
||||
fn bind_client_pair() -> (
|
||||
tokio::io::ReadHalf<tokio::io::DuplexStream>,
|
||||
tokio::io::WriteHalf<tokio::io::DuplexStream>,
|
||||
TargetHandle,
|
||||
) {
|
||||
let (a, b) = tokio::io::duplex(64 * 1024);
|
||||
let (a_read, a_write) = tokio::io::split(a);
|
||||
let (b_read, b_write) = tokio::io::split(b);
|
||||
(
|
||||
b_read,
|
||||
b_write,
|
||||
TargetHandle {
|
||||
read: Box::new(a_read),
|
||||
write: Box::new(a_write),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// §5); the pump handler NEVER runs. The gate DID run (its witness is
|
||||
/// 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;
|
||||
let received: ReceivedWitness = Arc::new(tokio::sync::Mutex::new(Received::default()));
|
||||
let local_halves: Arc<tokio::sync::Mutex<Vec<TargetHandle>>> =
|
||||
Arc::new(tokio::sync::Mutex::new(Vec::new()));
|
||||
let waiting: WaitingTokens = Arc::new(Mutex::new(HashSet::new()));
|
||||
let gate_invoked: GateWitness = Arc::new(AtomicBool::new(false));
|
||||
serve_forwarded_establisher_on(
|
||||
&topo.consumer_registry,
|
||||
topo.consumer_manager.clone(),
|
||||
Arc::clone(&received),
|
||||
local_halves,
|
||||
gate_with_tokens(waiting, Arc::clone(&gate_invoked)),
|
||||
);
|
||||
|
||||
// The open with a token the gate rejects: typed failure, not a
|
||||
// success + post-open decline.
|
||||
let err = call_forwarded(&topo.producer, forwarded_input(42113, (198, 55231)))
|
||||
.await
|
||||
.expect_err("a gate miss must fail the open");
|
||||
assert_eq!(
|
||||
err.code, "channel:open_failed",
|
||||
"the establisher's typed failure shape"
|
||||
);
|
||||
let details = err.details.expect("typed error details");
|
||||
assert_eq!(
|
||||
details["reason"], "unknown_resource",
|
||||
"a stale/missing token maps through the existing table"
|
||||
);
|
||||
assert!(
|
||||
details["message"]
|
||||
.as_str()
|
||||
.is_some_and(|m| m.contains("42113")),
|
||||
"the gate's message names the missed token: {}",
|
||||
details["message"]
|
||||
);
|
||||
|
||||
// No phantom channel: the wrapper's transient allocation is torn
|
||||
// down with the establisher's failure (ADR-049 §5).
|
||||
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
||||
assert!(
|
||||
topo.consumer_manager
|
||||
.channel_ids()
|
||||
.iter()
|
||||
.all(|&id| id == 0),
|
||||
"a gate-miss open leaves no channel on the opener side"
|
||||
);
|
||||
|
||||
// The gate ran (its witness is set); the pump handler NEVER did.
|
||||
assert!(
|
||||
gate_invoked.load(Ordering::SeqCst),
|
||||
"the miss happened at the gate"
|
||||
);
|
||||
assert!(
|
||||
received.lock().await.params.is_none(),
|
||||
"a gate-miss open must not reach the pump handler"
|
||||
);
|
||||
}
|
||||
|
||||
/// The gate PASS: the registration serves exactly like the base shape
|
||||
/// (`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.
|
||||
#[tokio::test]
|
||||
async fn gate_pass_serves_the_unchanged_serving_flow() {
|
||||
let topo = wire_forwarded_establisher(forwarded_only_identity()).await;
|
||||
let received: ReceivedWitness = Arc::new(tokio::sync::Mutex::new(Received::default()));
|
||||
let local_halves: Arc<tokio::sync::Mutex<Vec<TargetHandle>>> =
|
||||
Arc::new(tokio::sync::Mutex::new(Vec::new()));
|
||||
let waiting: WaitingTokens = Arc::new(Mutex::new(HashSet::new()));
|
||||
serve_forwarded_establisher_on(
|
||||
&topo.consumer_registry,
|
||||
topo.consumer_manager.clone(),
|
||||
Arc::clone(&received),
|
||||
Arc::clone(&local_halves),
|
||||
gate_with_tokens(Arc::clone(&waiting), Arc::new(AtomicBool::new(false))),
|
||||
);
|
||||
|
||||
// The waiting session's bookkeeping: token in the gate map + the
|
||||
// correlated halves pre-registered (the correlate-first posture).
|
||||
const TOKEN: u32 = 918273;
|
||||
waiting
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.insert(TOKEN);
|
||||
let (mut b_read, mut b_write, local) = bind_client_pair();
|
||||
local_halves.lock().await.push(local);
|
||||
|
||||
// The gate-pass open resolves with the wrapper's channel_id.
|
||||
let out = call_forwarded(&topo.producer, forwarded_input(TOKEN, (198, 55231)))
|
||||
.await
|
||||
.expect("the gate-pass open");
|
||||
let channel_id = out["channel_id"].as_u64().expect("channel_id") as u32;
|
||||
assert!(channel_id > 0);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
|
||||
assert!(
|
||||
topo.consumer_manager.has_channel(channel_id),
|
||||
"the forwarded channel is live on the opener side"
|
||||
);
|
||||
|
||||
// The injected handler saw the parsed params verbatim.
|
||||
let got = received.lock().await.params.clone().expect("invoked");
|
||||
assert_eq!(got.listen, TOKEN, "the token rides `listen`");
|
||||
assert_eq!(got.substrate, alktunnels::params::Substrate::Tcp);
|
||||
assert_eq!(
|
||||
got.peer,
|
||||
alktunnels::params::SubstrateAddr::HostPort {
|
||||
host: "198".to_string(),
|
||||
port: 55231,
|
||||
}
|
||||
);
|
||||
|
||||
// The producer adopts the opener-allocated ID and drives the
|
||||
// accepted side's echo.
|
||||
let (send, recv) = topo
|
||||
.producer
|
||||
.manager()
|
||||
.adopt_channel(channel_id, alktunnels::TUNNEL_ALPN, None)
|
||||
.await
|
||||
.expect("producer adopts the opener-allocated ID");
|
||||
let driving = driving_halves();
|
||||
let _producer_pump = tokio::spawn(alkcall::channels::pump::pump_bidi(
|
||||
alkcall::core::types::BiStream::from_joined(recv, send),
|
||||
driving.read,
|
||||
driving.write,
|
||||
));
|
||||
|
||||
// THE round-trip: the test writes on the BIND client's end → the
|
||||
// assembly's pump → the forwarded channel → the producer's pump →
|
||||
// the driving echo → back.
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
b_write.write_all(b"gate-pass-ping").await.expect("write");
|
||||
b_write.flush().await.expect("flush");
|
||||
let mut buf = vec![0u8; 14];
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
b_read.read_exact(&mut buf),
|
||||
)
|
||||
.await
|
||||
.expect("round trip")
|
||||
.expect("read");
|
||||
assert_eq!(&buf, b"gate-pass-ping");
|
||||
}
|
||||
|
||||
/// The residual race (the base shape's pinned semantics, re-pinned
|
||||
/// under the establisher variant): the gate passes (waiting token
|
||||
/// present) but the route is GONE at handler time (no correlated
|
||||
/// 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
|
||||
/// it, under both shapes.
|
||||
#[tokio::test]
|
||||
async fn residual_race_still_reaps_cleanly() {
|
||||
let topo = wire_forwarded_establisher(forwarded_only_identity()).await;
|
||||
let received: ReceivedWitness = Arc::new(tokio::sync::Mutex::new(Received::default()));
|
||||
let local_halves: Arc<tokio::sync::Mutex<Vec<TargetHandle>>> =
|
||||
Arc::new(tokio::sync::Mutex::new(Vec::new()));
|
||||
let waiting: WaitingTokens = Arc::new(Mutex::new(HashSet::new()));
|
||||
serve_forwarded_establisher_on(
|
||||
&topo.consumer_registry,
|
||||
topo.consumer_manager.clone(),
|
||||
Arc::clone(&received),
|
||||
local_halves,
|
||||
gate_with_tokens(Arc::clone(&waiting), Arc::new(AtomicBool::new(false))),
|
||||
);
|
||||
|
||||
// The gate passes (the waiting token is present at establisher
|
||||
// time), but nothing was correlated for the handler: the race.
|
||||
const TOKEN: u32 = 42114;
|
||||
waiting
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.insert(TOKEN);
|
||||
|
||||
// The open itself STILL RESOLVES (the gate passed; the call-plane
|
||||
// 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 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);
|
||||
assert!(
|
||||
!topo.consumer_manager.has_channel(channel_id),
|
||||
"a residual-race handler-miss reaps the forwarded channel"
|
||||
);
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// op's establishment phase, so the gate never sees an unauthorized
|
||||
/// open.
|
||||
#[tokio::test]
|
||||
async fn scope_gate_precedes_the_establisher() {
|
||||
let topo = wire_forwarded_establisher(open_only_identity()).await;
|
||||
let received: ReceivedWitness = Arc::new(tokio::sync::Mutex::new(Received::default()));
|
||||
let local_halves: Arc<tokio::sync::Mutex<Vec<TargetHandle>>> =
|
||||
Arc::new(tokio::sync::Mutex::new(Vec::new()));
|
||||
let waiting: WaitingTokens = Arc::new(Mutex::new(HashSet::new()));
|
||||
waiting.lock().unwrap_or_else(|e| e.into_inner()).insert(77);
|
||||
let gate_invoked: GateWitness = Arc::new(AtomicBool::new(false));
|
||||
serve_forwarded_establisher_on(
|
||||
&topo.consumer_registry,
|
||||
topo.consumer_manager.clone(),
|
||||
Arc::clone(&received),
|
||||
local_halves,
|
||||
gate_with_tokens(waiting, Arc::clone(&gate_invoked)),
|
||||
);
|
||||
|
||||
// The scope-less open is FORBIDDEN (even though the token WOULD
|
||||
// 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"),
|
||||
Err(e) => {
|
||||
assert_eq!(
|
||||
e.code, "FORBIDDEN",
|
||||
"missing `tunnel:forwarded` → FORBIDDEN"
|
||||
);
|
||||
assert!(
|
||||
e.message.contains("tunnel:forwarded"),
|
||||
"the denial names the missing scope: {}",
|
||||
e.message
|
||||
);
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
|
||||
// The establishment phase never ran: no gate invocation, no
|
||||
// channel allocation, no handler execution.
|
||||
assert!(
|
||||
!gate_invoked.load(Ordering::SeqCst),
|
||||
"the ACL precedes the establisher; the gate never saw the open"
|
||||
);
|
||||
assert!(
|
||||
topo.consumer_manager
|
||||
.channel_ids()
|
||||
.iter()
|
||||
.all(|&id| id == 0),
|
||||
"a FORBIDDEN open leaves no channel on the opener side"
|
||||
);
|
||||
assert!(received.lock().await.params.is_none());
|
||||
}
|
||||
|
||||
/// The establisher-variant malformed-params delta (the base shape's
|
||||
/// `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'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
|
||||
/// never runs.
|
||||
#[tokio::test]
|
||||
async fn malformed_params_fail_typed_at_the_establisher() {
|
||||
let topo = wire_forwarded_establisher(forwarded_only_identity()).await;
|
||||
let received: ReceivedWitness = Arc::new(tokio::sync::Mutex::new(Received::default()));
|
||||
let local_halves: Arc<tokio::sync::Mutex<Vec<TargetHandle>>> =
|
||||
Arc::new(tokio::sync::Mutex::new(Vec::new()));
|
||||
let gate_invoked: GateWitness = Arc::new(AtomicBool::new(false));
|
||||
serve_forwarded_establisher_on(
|
||||
&topo.consumer_registry,
|
||||
topo.consumer_manager.clone(),
|
||||
Arc::clone(&received),
|
||||
local_halves,
|
||||
gate_with_tokens(
|
||||
Arc::new(Mutex::new(HashSet::new())),
|
||||
Arc::clone(&gate_invoked),
|
||||
),
|
||||
);
|
||||
|
||||
// The schema gates are unchanged by the establisher (they precede
|
||||
// it, the wrapper order's schema step).
|
||||
match call_forwarded(
|
||||
&topo.producer,
|
||||
serde_json::json!({
|
||||
"listen": 0,
|
||||
"substrate": "tcp",
|
||||
"peer": {"host": "10", "port": 53},
|
||||
}),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => panic!("`listen: 0` must fail the open"),
|
||||
Err(e) => assert_eq!(e.code, "INVALID_INPUT", "schema gate unchanged"),
|
||||
}
|
||||
match call_forwarded(
|
||||
&topo.producer,
|
||||
serde_json::json!({"listen": 7, "substrate": "tcp"}),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => panic!("missing `peer` must fail the open"),
|
||||
Err(e) => assert_eq!(e.code, "INVALID_INPUT", "schema gate unchanged"),
|
||||
}
|
||||
|
||||
// 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
|
||||
// the improvement ADR-008 Amendment 2 pins).
|
||||
let err = call_forwarded(
|
||||
&topo.producer,
|
||||
serde_json::json!({
|
||||
"listen": 918273,
|
||||
"substrate": "tcp",
|
||||
"peer": {"host": "198", "port": 55233},
|
||||
"sneaky": true
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect_err("the malformed parse must fail the open as a typed error");
|
||||
assert_eq!(err.code, "channel:open_failed");
|
||||
let details = err.details.expect("typed error details");
|
||||
assert_eq!(
|
||||
details["reason"], "handler_error",
|
||||
"an establisher parse failure maps through the existing table"
|
||||
);
|
||||
|
||||
// The establisher's parse precedes the gate; the wrapper tears
|
||||
// the transient allocation down; the pump handler never runs.
|
||||
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"
|
||||
);
|
||||
assert!(
|
||||
topo.consumer_manager
|
||||
.channel_ids()
|
||||
.iter()
|
||||
.all(|&id| id == 0),
|
||||
"a parse-failed open leaves no channel on the opener side"
|
||||
);
|
||||
assert!(received.lock().await.params.is_none());
|
||||
}
|
||||
+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