diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 891350d72..f6aa3e22b 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -30,6 +30,25 @@ on: - '*.adoc' - 'README.adoc' - '.github/workflows/docs.yml' + # Manual trigger, for authoring a replacement doc/lint/baseline.json in the CI + # environment — see the "Baseline reseed" steps at the end of the antora job. + # A reseed REWRITES the reference point of the doc-quality gate, so it must never + # happen on push or pull_request: an automatic reseed would absorb real + # regressions into the grandfathered backlog, which is precisely what the gate + # exists to prevent. workflow_dispatch is the only trigger that reaches those + # steps (they are additionally guarded on github.event_name), and they only + # upload an artifact for a human to review and commit. + workflow_dispatch: + inputs: + allow_emptied: + description: >- + Space-separated gated checks whose backlog is genuinely closed. + baseline-diff.mjs refuses a candidate where a gated check drops to + zero, because a crashed check looks identical -- this is how you say + the zero is real. Verify first, then name the check here so the + decision is recorded in the run log. + required: false + default: '' jobs: antora: @@ -39,10 +58,21 @@ jobs: run: shell: bash steps: + # asciidoctor here is the Ruby CLI that Vale 3.x shells out to when linting + # .adoc files (its lintAdoc scope). It is NOT the same as the JS + # @asciidoctor/core that build_antora.sh pulls in via npm — that provides no + # `asciidoctor` binary on PATH. Without the Ruby CLI, `vale modules` and + # baseline.mjs's vale_adoc error with "asciidoctor not found" — the check is + # marked skipped — and because vale_adoc AND vale_docstrings are both GATED + # checks, the gate's gated-skip path fails the job on missing infra rather + # than passing a run that measured nothing. The docstring corpus needs it + # too: the extracted files are .adoc, so a missing Ruby CLI skips that slice + # as well. Installed here, in the first step, so it is on PATH for the + # Antora build and every lint step after it. - name: Install packages uses: alandefreitas/cpp-actions/package-install@v1.9.0 with: - apt-get: git cmake + apt-get: git cmake asciidoctor - name: Clone Boost.Corosio uses: actions/checkout@v4 @@ -171,6 +201,321 @@ jobs: fi echo "the rendered reference carries its injected examples" + # --- Documentation quality (doc/STYLE_GUIDE.md Part F) ------------------- + # The enforcement tiers live in doc/lint/; doc/lint/README.md is the operator + # guide and carries this repository's F4 bite-test log. + # + # POSTURE, and it is deliberate: the gate step below REPORTS without --strict, + # so it annotates new findings on the diff but does not fail the job, and + # selftest.mjs runs continue-on-error. Both flips — adding --strict here and + # promoting selftest.mjs to blocking — are the exit criteria of the final + # remediation phase in doc/design/style-guide-compliance.md section 6. Until + # then a new violation is reported but lands, which the per-phase reseed + # cadence in that document is what keeps bounded. + # + # The accuracy gate for .adoc example code (B2/B3 correctness) is separate and + # already hard: the doc snippet/program targets built by test/doc, run from + # ci.yml, not this job. + - name: "Lint: install Vale" + if: always() + continue-on-error: true + run: | + mkdir -p "$RUNNER_TEMP/vale-bin" + curl -sSL https://github.com/errata-ai/vale/releases/download/v3.15.1/vale_3.15.1_Linux_64-bit.tar.gz \ + | tar -xz -C "$RUNNER_TEMP/vale-bin" vale + echo "$RUNNER_TEMP/vale-bin" >> "$GITHUB_PATH" + echo "$(pwd)/boost-root/libs/corosio/doc/node_modules/.bin" >> "$GITHUB_PATH" + + - name: "Lint: sync Vale styles" + if: always() + continue-on-error: true + working-directory: boost-root/libs/corosio/doc + run: vale sync + + # Vale's exit codes: 0 = no alerts, 1 = alerts found, 2 = fatal (e.g. no + # asciidoctor on PATH). Only 2 is a tooling failure worth surfacing here; 1 + # is the backlog, which the gate step below is what judges. So map 1 to + # success and let anything else through. + - name: "Lint: Vale over pages" + if: always() + continue-on-error: true + working-directory: boost-root/libs/corosio/doc + run: | + vale modules; s=$? + if [ "$s" = "1" ]; then exit 0; else exit "$s"; fi + + - name: "Lint: Vale over docstrings" + if: always() + continue-on-error: true + working-directory: boost-root/libs/corosio/doc + run: | + node lint/extract-docstrings.mjs + vale lint/.docstrings; s=$? + if [ "$s" = "1" ]; then exit 0; else exit "$s"; fi + + - name: "Lint: structure (doc-lint.mjs)" + if: always() + continue-on-error: true + working-directory: boost-root/libs/corosio/doc + run: node lint/doc-lint.mjs + + - name: "Lint: sentence length (C2)" + if: always() + continue-on-error: true + working-directory: boost-root/libs/corosio/doc + run: node lint/sentence-length.mjs + + # BLOCKING, and deliberately outside the baseline/comparator posture above: a + # tagged include that no longer resolves renders as an EMPTY code block on the + # page, so the reader silently loses the example. There is no backlog to + # grandfather here — every tagged include resolved at the port — so this one + # stays a hard gate. + - name: "Lint: include tags resolve (BLOCKING)" + if: always() + continue-on-error: false + working-directory: boost-root/libs/corosio/doc + run: node lint/check-include-tags.mjs + + - name: "Lint: reference warnings (MrDocs)" + if: always() + continue-on-error: true + working-directory: boost-root/libs/corosio/doc + run: node lint/mrdocs-warnings.mjs + + # selftest.mjs mutates the linters and asserts they notice. It is the only + # standing guard between a silent linter regression and a green run — several + # of these checks (A1's value whitelist, A7's numeral match, B2's block walk, + # the role=output exemption boundary) were fail-open at some point in Capy and + # were only ever found by planting a violation. SHAPE in particular is + # advisory and reads 0, so a broken looksLikeCode() is invisible everywhere + # else. BLOCKING as of the final remediation phase: it has no baseline and no + # environment dependence — it plants violations in throwaway fixtures and + # asserts the linters notice — so a red run here means a linter regressed, which + # is exactly the failure nothing else can see. + - name: "Lint: linter self-test (BLOCKING)" + if: always() + continue-on-error: false + working-directory: boost-root/libs/corosio/doc + run: node lint/selftest.mjs + + # The remaining backlog, as warnings: every finding present RIGHT NOW that + # baseline.json grandfathers. Not the baseline file's contents — the baseline + # is only reseeded when something is ADDED, so it accumulates dead clauses for + # findings already fixed. This prints the live intersection, which is the + # actual worklist. + - name: "Lint: remaining backlog" + if: always() + continue-on-error: true + working-directory: boost-root/libs/corosio/doc + run: node lint/check-no-new-violations.mjs --show-baseline + + # The gate: reports any NEW A1/A6/B2/D2/ANCHOR violation, any NEW MrDocs + # reference-surface warning, any NEW C2 sentence-length violation in the hard + # slice, and any NEW C4/C9/C10 wording violation on EITHER surface. + # + # THE TWO GATE-SPEC SHAPES DIFFER, AND THE DIFFERENCE IS LOAD-BEARING. + # check-no-new-violations.mjs tests each regex against the WHOLE fingerprint. + # * Vale fingerprints are `file:#N:Check.Name` — check name at the TAIL. So the + # Vale specs tail-anchor with `$` and MUST NOT carry a leading `^`. An + # `^`-anchored Vale spec matches nothing and reports `gated: true, + # gatedNew: 0` — a gate that says it is gating while checking nothing. That + # was bite-tested on this corpus (doc/lint/README.md): the `^`-anchored + # form exits 0 against a planted A7 heading, the tail-anchored form exits 1. + # * sentence_length fingerprints are `C2:file:#N:message` — rule at the HEAD. + # So `^C2:` is the correct shape THERE, and it deliberately cannot reach + # the `advisory-C2` slice (doc/STYLE_GUIDE.md C2 relaxes the 25-word limit + # for 2.networking-tutorial/, which is essay-style protocol theory). + # * doc_lint is also rule-at-the-head, hence `^(A1|A6|B2|D2|ANCHOR):`. SHOULD + # a rule be added there, note SHAPE is deliberately excluded: it is an + # advisory content heuristic, not a defect. + # + # A skip of ANY gated check (doc_lint / vale_adoc / vale_docstrings / + # sentence_length / mrdocs_warnings) fails the gate — can't verify a gated rule + # = not a pass. Bite-tested: a crashed extract-docstrings.mjs marks + # vale_docstrings and sentence_length SKIPPED and exits non-zero, rather than + # reading zero findings as success. + # + # Do NOT reseed baseline.json locally — a local run grandfathers local-vs-CI + # drift as if it were the real backlog. Reseed via the workflow_dispatch steps + # below, per doc/lint/README.md. + # THE GATE IS SPLIT IN TWO. Everything blocks EXCEPT the MrDocs + # reference-surface check, and that exception is evidence-based. + # + # A1/A6/B2/D2/ANCHOR, C2, and the C4/C9/C10/A7 wording rules are all STRICT. + # baseline.json is now authored by this job (workflow_dispatch reseed), so a + # strict comparison is CI-against-CI and carries no environment drift. The + # wording rules additionally have an EMPTY gated subset — zero baselined + # C4/C9/C10/A7 fingerprints on either corpus — so any match at all is a real + # regression, which is why they were safe to promote even from a local run. + # + # mrdocs_warnings deliberately stays REPORTING. The baseline is zero, so its + # `.*` spec would gate every warning that ever appears — and MrDocs itself is + # a rolling `develop-release` build whose output demonstrably moves: the same + # asset reported 460 warnings under 0.8.0 and 352 under 2026.9.5, days apart, + # on an unchanged tree. Gating `.*` on a tool that rewrites its own output + # would fail this job for upstream reasons that have nothing to do with + # Corosio's documentation — the same mistake as the version pin that skipped + # this check on the first reseed (doc/lint/mrdocs-warnings.mjs). Promote it + # only alongside a pinned MrDocs. + # + # Do NOT "fix" a strict-step failure by reseeding locally. Reseed via the + # workflow_dispatch steps below, per doc/lint/README.md. + - name: "Lint: gate (BLOCKING)" + if: always() + continue-on-error: false + working-directory: boost-root/libs/corosio/doc + run: | + node lint/check-no-new-violations.mjs --strict \ + --gate 'doc_lint:^(A1|A6|B2|D2|ANCHOR):' \ + --gate 'sentence_length:^C2:' \ + --gate 'vale_adoc:Corosio\.PartHeadings$' \ + --gate 'vale_adoc:(Corosio\.SimpleTense|Corosio\.NoFluff|Corosio\.Terminology)$' \ + --gate 'vale_docstrings:(Corosio\.SimpleTense|Corosio\.NoFluff|Corosio\.Terminology)$' + + - name: "Lint: gate (reference surface, reporting)" + if: always() + continue-on-error: false + working-directory: boost-root/libs/corosio/doc + run: | + # Reports and annotates; does NOT fail the job (no --strict). See the + # rolling-build reasoning above the blocking step. + node lint/check-no-new-violations.mjs \ + --gate 'mrdocs_warnings:.*' + + # --- Baseline reseed (workflow_dispatch only) --------------------------- + # doc/lint/baseline.json is the gate's reference point: anything in it is + # grandfathered. It goes stale as the backlog is worked down (a fix removes + # findings but not their baseline entries), and a stale-high baseline + # grandfathers findings that no longer exist — so they can be reintroduced and + # the gate stays green. Retiring them needs a regenerated baseline. + # + # Regenerating on a developer machine is NOT safe: a local run differs from a + # CI run (a different MrDocs develop build hash, file-processing order), and + # committing those differences would grandfather environment drift as if it + # were the real backlog. So the candidate is authored HERE, by the same job, + # on the same runner image, with the same PATH the gate above just used. + # Reusing the gate's own job — rather than a second job that re-creates its + # setup — is deliberate: an imitated environment is exactly the bug this + # avoids, and it cannot drift from the gate's environment because it IS that + # environment. + # + # Two safety properties of the ordering and paths below: + # * these steps run AFTER the gate, and + # * the candidate is written to RUNNER_TEMP, never to the checked-out + # doc/lint/baseline.json, + # so the gate in this same run still compares against the COMMITTED baseline. + # A candidate that overwrote it first would make the gate compare a run + # against itself and pass unconditionally. + # + # The job never commits or pushes. It uploads a candidate for review; a human + # reads the diff and commits it. + - name: "Reseed: generate candidate" + if: always() && github.event_name == 'workflow_dispatch' + working-directory: boost-root/libs/corosio/doc + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/baseline-candidate" + node lint/baseline.mjs "$RUNNER_TEMP/baseline-candidate/baseline.json" + + # Reports per-check counts before/after and, per check and rule, which + # fingerprints the candidate would ADD (grandfather) and REMOVE (retire). Any + # ADDED fingerprint matching the gate spec is a finding a reseed would + # silently un-gate; those are named individually and fail this step. So do a + # SKIPPED check and a GATED check that collapsed to zero findings — both + # would wipe a gated check's whole grandfathered backlog. + # + # The gate spec is EXTRACTED from this workflow file rather than restated + # here. A second verbatim copy is a rot hazard with a silent failure mode: + # promote a rule in the gate step above, forget this one, and the report keeps + # printing "none gated" for a rule that now blocks — the safety net stops + # covering exactly the rule just deemed important enough to gate. Extraction + # means there is one copy. If extraction yields nothing (someone reformatted + # the gate step's arguments), this step FAILS rather than reporting against an + # empty gate spec, which would look identical to "no gated additions". + - name: "Reseed: report changes" + if: always() && github.event_name == 'workflow_dispatch' + working-directory: boost-root/libs/corosio/doc + env: + ALLOW_EMPTIED: ${{ inputs.allow_emptied }} + run: | + set -uo pipefail + out="$RUNNER_TEMP/baseline-candidate" + workflow=../.github/workflows/docs.yml + + # Read the run blocks of BOTH gate steps: each starts at its `- name:` line + # and ends at the next blank line. Two details are load-bearing: + # * the toggle is `inblock = 0`, not `exit` -- the gate is split in two + # steps, and exiting at the first blank line would silently drop the + # second step's specs from this safety net. + # * the pattern is anchored to `^ *- name:`, because this awk program + # CONTAINS the string it searches for. Without the anchor it matches + # its own source line and captures the grep/sed lines below as if they + # were gate specs (measured: two junk entries, one an invalid regex). + gate_args=() + while IFS= read -r spec; do + gate_args+=(--gate "$spec") + done < <( + awk '/^[[:space:]]*- name: "Lint: gate/ { inblock = 1; next } + inblock && /^[[:space:]]*$/ { inblock = 0 } + inblock' "$workflow" \ + | grep -o -- "--gate '[^']*'" \ + | sed "s/^--gate '//; s/'\$//" \ + | sort -u + ) + if [ "${#gate_args[@]}" -eq 0 ]; then + echo "::error title=Gate spec not found::could not extract any --gate spec from $workflow; refusing to report against an empty gate spec" + exit 1 + fi + echo "gate spec extracted from $workflow: ${gate_args[*]}" + + # A gated check dropping to zero is refused by default, because that is + # indistinguishable from a crash. Naming it here is the acknowledgement, + # and it lands in the run log next to the report it authorised. + # ALLOW_EMPTIED arrives through `env:` rather than template + # interpolation: the value is attacker-chosen text and would otherwise + # be pasted straight into this script. Note that a run: block is + # scanned for expressions in full, comments included -- writing an + # empty expression pair here, even inside a comment, is a parse error + # ("An expression was expected") for the whole workflow. + # Unquoted on purpose -- word splitting is what turns the space-separated + # input into separate checks. An empty value splits to zero words, so the + # loop simply does not run. + allow_args=() + for check in ${ALLOW_EMPTIED:-}; do + allow_args+=(--allow-emptied "$check") + done + if [ "${#allow_args[@]}" -gt 0 ]; then + echo "::warning title=Emptied gated check accepted::${ALLOW_EMPTIED}" + fi + + status=0 + node lint/baseline-diff.mjs lint/baseline.json "$out/baseline.json" \ + "${gate_args[@]}" "${allow_args[@]+"${allow_args[@]}"}" \ + | tee "$out/baseline-diff.txt" || status=$? + diff -u lint/baseline.json "$out/baseline.json" > "$out/baseline.json.diff" || true + # GitHub rejects a step summary over 1 MiB, so cap it and point at the + # artifact for the full text. + { + echo '## Candidate doc/lint/baseline.json' + echo + echo 'Download the `doc-lint-baseline-candidate` artifact. Do not commit it' + echo 'without accounting for every ADDED fingerprint below. Full untruncated' + echo 'report: `baseline-diff.txt` in that artifact.' + echo + echo '```' + head -c 900000 "$out/baseline-diff.txt" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + exit "$status" + + - name: "Reseed: upload candidate" + if: always() && github.event_name == 'workflow_dispatch' + uses: actions/upload-artifact@v4 + with: + name: doc-lint-baseline-candidate + path: ${{ runner.temp }}/baseline-candidate + if-no-files-found: error + - name: Create Antora Docs Artifact uses: actions/upload-artifact@v4 with: diff --git a/doc/.gitignore b/doc/.gitignore index b38db2f29..c74fc96f4 100644 --- a/doc/.gitignore +++ b/doc/.gitignore @@ -1,2 +1,6 @@ node_modules/ build/ +lint/.docstrings/ +# Vale-managed style packages (regenerated by `vale sync`); Corosio/ is ours and is tracked. +.vale/styles/Google/ +.vale/styles/Vale/ diff --git a/doc/.vale.ini b/doc/.vale.ini new file mode 100644 index 000000000..630c17be1 --- /dev/null +++ b/doc/.vale.ini @@ -0,0 +1,96 @@ +StylesPath = .vale/styles +MinAlertLevel = warning +; Pinned deliberately. A bare `Packages = Google` resolves to whatever release the +; feed serves at `vale sync` time, so the corpus moves underneath baseline.json and +; findings appear as NEW without a word of prose changing. Note the project also +; moved errata-ai -> vale-cli. Bump this URL on purpose, and reseed in the same change. +Packages = https://github.com/vale-cli/Google/releases/download/v0.7.1/Google.zip +; Corosio domain vocabulary: .vale/styles/config/vocabularies/Corosio/accept.txt. +; It holds genuine prose words and proper nouns ONLY. Bare C++ identifiers used as +; running text stay unlisted on purpose — they are style-guide B1 defects and must +; keep showing up as Vale.Spelling alerts until the prose is fixed. +Vocab = Corosio +[*.adoc] +BasedOnStyles = Vale, Google, Corosio +; NO BlockIgnores here, on purpose. Vale's native AsciiDoc handling (it shells out to +; asciidoctor and only lints extracted prose nodes) already excludes delimited listing +; blocks — `[source,cpp]`/`----`, bare `----`, `....` literal blocks, blocks nested in +; list items or admonitions, `role=pseudocode`/`role=external`, and callout markers are +; all skipped natively. A `BlockIgnores = (?s) *(\[source.*?----.*?----)` substitution +; does NOT additionally protect anything — it destroys the `----` delimiters before +; asciidoctor sees them, which corrupts the block structure and hands the code inside to +; the linter as if it were a paragraph. That was measured on Capy's corpus, where +; removing the line dropped `.adoc` warning-level alerts 503 -> 376; Corosio inherits the +; decision rather than re-deriving it. If you are tempted to add a BlockIgnores line for +; source blocks: don't. Confirm first, with an isolated fixture, that Vale is actually +; failing to skip something. +; +; One Vale/asciidoctor artifact to know about before chasing a missing alert: a +; correctly-excluded code block can suppress an UNRELATED, later Vale.Spelling or +; Google.Colons alert when the block's own text shares a SUBSTRING with the flagged word, +; and only when the block sits BEFORE the flagged prose in the file. Reduced fixture: +; `// token` before a paragraph containing `foo_token` suppresses the alert; `// hello` +; before it does not. This is a position-resolution artifact, not something any +; BlockIgnores/TokenIgnores value controls — do not try to "fix" it with a config change +; without a bite-tested fixture proving the change does something. +; +; Ignore inline code spans (backticks) AND `cpp:target[...]` reference macros: the B1 +; conversion replaces backtick symbol spans with cpp: macros, and their symbol text must +; stay unlinted, exactly as the backtick spans were. +; +; The third clause is the fixed label of the boost-wide thread-safety idiom +; ("Distinct objects: Safe." / "Shared objects: Unsafe."). Each instance is a genuine +; Google.Colons hit, but the form is boost-wide and Corosio does not get to rewrite it; +; `grep -ro '\(Distinct\|Shared\) objects:' --include='*.hpp' include` reports 72, spread +; over io_context, resolver_results, endpoint, signal_set, tcp_acceptor, the TLS streams +; and others. Only the LABEL and its colon are blanked, so whatever follows stays fully +; linted by every other rule; the pattern deliberately does NOT spell out "Safe."/ +; "Unsafe." because instances continue into longer clauses that a phrase-exact form would +; leave exposed while suppressing its siblings. +; This is NOT a Google.Colons demotion, on purpose: demoting the rule would also hide the +; genuine non-idiom Colons hits. +TokenIgnores = (\x60[^\x60]+\x60), (cpp:[^\s\[]*\[[^\]]*\]), ((?:Distinct|Shared) objects:) + +; --- Google house-style pack: deliberately demoted, not abandoned ---------------- +; These eight rules encode GOOGLE's house style, not Corosio defects, and are scoped out +; of the "vale clean" criterion. They are demoted to `suggestion` (below MinAlertLevel) +; rather than removed, so a curious reader can still run +; `vale --minAlertLevel=suggestion` and see them. Counts below are measured on Corosio's +; two corpora at the port commit — `.adoc` = doc/modules, docstrings = +; lint/.docstrings — and are the same ruling Capy made, re-measured here rather than +; inherited. +; +; Google.Headings — Corosio writes Title Case section headings; Google style mandates +; sentence case. Retitling every heading is a user-visible house-style change, not a +; defect fix. +; Google.WordListCase — Google's capitalisation list for words like "Internet"/"email"; +; disagrees with Boost usage, not with Part C. Note Corosio's networking tutorial uses +; "Internet" heavily, so this rule is noisier here than in Capy. +; Google.EmDash — bans spaced em dashes. Corosio uses ` -- ` (AsciiDoc's em-dash form) as +; a deliberate typographic convention. +; Google.We / Google.FirstPerson — ban first-person. The tutorial and design prose +; address the reader directly by design (D1/D3). +; Google.Latin — bans "e.g."/"i.e."; both are standard in Boost reference documentation. +; Google.Quotes — demands commas and periods inside quotation marks (US convention). +; Corosio quotes code-like strings, where moving punctuation inside the quotes would +; misstate the string's contents. +; Google.Spacing — flags spacing around punctuation in prose that is mostly quoted code. +; +; NOT demoted, on purpose: Google.Will (a genuine C4 signal, and C4 is gated), +; Google.Colons, Google.OxfordComma, Google.LyHyphens, Google.Units, Google.Ordinal. +; +; Google.LyHyphens misfires on Corosio's `family-*` compounds -- `family-neutral`, +; `family-sensitive`, `family-generic`, `family-specific`. The rule targets adverb +; hyphenation (`newly-created`) and matches these only because "family" ends in "ly". +; The hyphens are correct: they are compound adjectives, not adverbs. The rule stays +; un-demoted because it catches the real thing elsewhere, so this bounded set of false +; positives is carried in baseline.json instead -- grandfathered on purpose, not by +; accident. Re-check it if the `family-*` vocabulary grows. +Google.Headings = suggestion +Google.WordListCase = suggestion +Google.EmDash = suggestion +Google.We = suggestion +Google.FirstPerson = suggestion +Google.Latin = suggestion +Google.Quotes = suggestion +Google.Spacing = suggestion diff --git a/doc/.vale/styles/Corosio/NoFluff.yml b/doc/.vale/styles/Corosio/NoFluff.yml new file mode 100644 index 000000000..fc28c22d3 --- /dev/null +++ b/doc/.vale/styles/Corosio/NoFluff.yml @@ -0,0 +1,14 @@ +extends: existence +message: "Filler/fluff — delete or rewrite (style guide C5/C9): '%s'." +level: warning +ignorecase: true +tokens: + - simply + - basically + - essentially + - obviously + - of course + - note that + - in order to + - due to the fact that + - utilize diff --git a/doc/.vale/styles/Corosio/PartHeadings.yml b/doc/.vale/styles/Corosio/PartHeadings.yml new file mode 100644 index 000000000..3aadb3e84 --- /dev/null +++ b/doc/.vale/styles/Corosio/PartHeadings.yml @@ -0,0 +1,7 @@ +extends: existence +message: "Heading uses 'Part N' ceremony instead of a descriptive title (style guide A7): '%s'." +level: warning +scope: heading +ignorecase: true +raw: + - '^Part\s+([0-9]+|[IVXLC]+)\b' diff --git a/doc/.vale/styles/Corosio/SentenceLength.yml b/doc/.vale/styles/Corosio/SentenceLength.yml new file mode 100644 index 000000000..5cb7df4c6 --- /dev/null +++ b/doc/.vale/styles/Corosio/SentenceLength.yml @@ -0,0 +1,68 @@ +# RETIRED AS AN AUTHORITY — demoted rather than deleted, the same treatment +# .vale.ini gives the Google house-style pack. `doc/lint/sentence-length.mjs` is +# the authority for C2 on both surfaces; this rule is kept only so that +# `vale --minAlertLevel=suggestion` can still show what Vale made of a page. +# `suggestion` is below .vale.ini's `MinAlertLevel = warning`, so it no longer +# reaches baseline.json, the gate, or a default `vale modules` run. +# +# ============================================================================ +# DO NOT GATE THIS RULE. `--gate 'vale_adoc:Corosio\.SentenceLength$'` — the +# obvious spec, by analogy with the working `vale_adoc:Corosio\.PartHeadings$` — +# is VACUOUS. At `suggestion` this rule never enters a Vale fingerprint set, so +# the spec matches nothing and the gate reports `gated: true, gatedNew: 0` and +# exits 0 while checking NOTHING. Measured, exactly that. That is the same +# fail-open shape as the vacuous `Corosio.PartHeadings` rule this branch already +# had to fix. Gate the script instead: +# +# --gate 'sentence_length:^C2:' +# +# which is live in docs.yml since the Phase-4 exit. Re-measured at the Phase-4 +# final fix wave: EXIT=1 / gatedNew=2, both findings the grandfathered +# `when_any.hpp` refusals, so it still needs a baseline reseed before it can go +# green. (An earlier version of this comment said gatedNew=135, the figure from +# before the .adoc hard slice was worked to zero; `doc/lint/README.md` carried +# the identical staleness and was corrected, this copy was missed.) +# `^C2:` binds the HARD slice only; the design-essay +# findings are keyed `advisory-C2` and are deliberately unreachable from a +# `C2`-prefixed spec (doc/STYLE_GUIDE.md Part C2: hard in API docs, soft in +# essays). +# ============================================================================ +# +# Three measured reasons this rule cannot be the authority, none fixable inside +# a Vale rule (all `cwd=doc`, vale 3.15.1, target `modules`): +# +# 1. UNDER-COUNTS. It runs after .vale.ini's `TokenIgnores` blanks inline code +# spans, so a span contributes ZERO words where a reader counts one. Three +# measurements of the size of that blind spot, all agreeing: +# * task P4-prereq, Vale with rewritten TokenIgnores, on the +# pre-BlockIgnores-fix config: 140 -> 170 (+30) +# * this task, Vale with the committed TokenIgnores, every backtick span +# (2115) and `cpp:` macro (557) outside code blocks replaced by one +# word, `--minAlertLevel=suggestion`: 135 -> 164 (+29) +# * sentence-length.mjs, spans blanked versus spans as one word, all +# slices of `.adoc`: 125 -> 152 (+27) +# An earlier version of this comment claimed "+35, measured twice" by +# substituting today's 135 for prereq's 140 and by counting words with +# Vale's tokenizer. Both were wrong; the real figure is +27 to +30. +# 2. MIS-ATTRIBUTES, which is worse: the missed block produces no alert to +# chase, so re-running to a fixpoint never finds it. The blanking corrupts +# Vale's position mapping for `scope: sentence` rules. Hand-verified case: +# `5.buffers/5b.types.adoc` holds two over-limit sentences in list items +# (27 and 34 words) and Vale reports NONE. The artifact is not confined to +# `scope: sentence` either — a `Corosio.Terminology` alert on +# `4.coroutines/4b.launching.adoc` is reported at line 25, an `include::` +# line inside a `[source,cpp]` block, when the text that matched is at +# line 68. +# 3. FALSE-POSITIVES on under-segmentation. `4.coroutines/4f.composition.adoc:93` +# is flagged as one sentence; its four real sentences are 23, 14, 11 and 6 +# words. Reproduced with that paragraph alone in a file. +# +# Do NOT re-promote this to warning/error without first showing, on a fixture, +# that Vale's position mapping for `scope: sentence` is fixed. Two checkers +# reporting different C2 numbers is how a gate loses credibility. +extends: occurrence +message: "Sentence over 25 words — split it (style guide C1/C2)." +level: suggestion +scope: sentence +token: \b(\w+)\b +max: 25 diff --git a/doc/.vale/styles/Corosio/SimpleTense.yml b/doc/.vale/styles/Corosio/SimpleTense.yml new file mode 100644 index 000000000..fc142ff59 --- /dev/null +++ b/doc/.vale/styles/Corosio/SimpleTense.yml @@ -0,0 +1,48 @@ +# Deliberately scoped to the two literal tokens doc/STYLE_GUIDE.md Part C4 names +# ("Avoid needless 'will' and 'has been'") — NOT extended to every perfect-tense +# form. What this rule does and does not see, re-measured at commit 620fdf2c with +# cwd=doc and PATH including node_modules/.bin (Vale shells out to asciidoctor for +# .adoc, and the extracted docstrings are .adoc too): +# +# `will\s` IS wrap-tolerant, and that is deliberate. Commit 30464086 changed the +# token from the space-literal `'will '` to `'will\s'`, so a `will` at the end of a +# wrapped source line now matches. Fixture proof: `The buffer will\nbe consumed.` +# is FLAGGED, Match `"will\n"`. Do not "simplify" this back to a literal space. +# +# `has been` is NOT wrap-tolerant — a known, open hole. Fixture proof: `The buffer +# has\nbeen consumed.` is NOT flagged, while the same text on one line is. It costs +# nothing today (0 occurrences of `has\s*\n\s*been` in either corpus at 620fdf2c), +# so it is recorded rather than fixed; the fix is the same one-character change +# (`has\sbeen`) if an instance ever appears. +# +# The perfect-tense family this rule deliberately excludes — 17 occurrences on the +# docstring corpus (`doc/lint/.docstrings/`), plus 4 on the `.adoc` pages: +# have been x12 (ex/this_coro.hpp x4, read_at_least.hpp x3, +# write_at_least.hpp x3, ex/thread_pool.hpp, +# write.hpp) +# has already been x3 (when_any.hpp x2, ex/async_mutex.hpp) +# has not been x1 (write_at_least.hpp) +# has nowbeen x1 (ex/async_waker.hpp — invisible for BOTH reasons: +# an intervening adverb and a line wrap) +# (.adoc: have been x2 in 4h.lambda-captures, 9l.RunApi; has been x2 in +# 9b.Separation, why-corosio) +# Maintainer ruling: these stay excluded. Part C4 names only "will" and "has been", +# so the rule is FAITHFUL to the guide as written; extending `tokens` would be a +# style-guide change smuggled in as a lint fix, and would surface ~17 new prose +# findings at once. A future editor who wants broader coverage changes Part C4 +# first, then this file. +extends: existence +message: "Avoid needless future/perfect tense — prefer present simple (style guide C4): '%s'." +level: warning +ignorecase: true +# `have been` and `had been` are the same present/past-perfect construct as +# `has been`, which C4 names explicitly. Matching only the singular left a blind +# spot a future author could write into freely: a raw grep found 8 live sites the +# rule could not see (6 in published docstrings, 2 on pages), including one CI +# surfaced only because a long single-line paragraph parses differently there. +# All 8 were fixed when these tokens were added, so the rule stays at zero. +tokens: + - 'will\s' + - 'has been' + - 'have been' + - 'had been' diff --git a/doc/.vale/styles/Corosio/Terminology.yml b/doc/.vale/styles/Corosio/Terminology.yml new file mode 100644 index 000000000..2cddf18eb --- /dev/null +++ b/doc/.vale/styles/Corosio/Terminology.yml @@ -0,0 +1,36 @@ +# C10 / C.1 one-term-per-concept. Two things this rule has to get right at once, +# and it got both wrong before: it must catch the verb in every form a writer +# actually uses, and it must NOT touch API identifiers or the noun `launcher`. +# +# `ignorecase: false` plus bare stems meant only the exact lowercase stem was +# seen. Measured on a fixture: `Launch the task for execution.`, `launches it on +# the executor`, `launched`, `launching`, `Spawn`, `spawns`, `spawned`, +# `spawning`, `Fire off`, `fires off`, `Kick off`, `kicked off` and `Boxed` all +# passed; only `launch`, `cancellation token`, `cancel token` and `boxed` were +# caught. That left the majority of real C10 prose invisible to the C10 checker. +# +# `ignorecase: true` is safe here, MEASURED rather than assumed. Every +# `launch`/`spawn`-bearing identifier in the library and the pages is either +# snake_case (`launch_one`, `launch_all`, `spawn_work`, `co_spawn`, +# `launch_policies`, the snippet tag `4b_launching`) or a `launcher` compound +# (`when_any_io_launcher`, `launchable`, `relaunch`, `launchers`). `_` is a word +# character, so the `\b` after the stem already excludes the snake_case forms, +# and the inflection list below is deliberately limited to VERB endings so it +# cannot reach `launcher`. On top of that, `.vale.ini`'s `TokenIgnores` blanks +# backtick spans and `cpp:` macros, so an identifier written as code is invisible +# to this rule anyway. +# +# The noun `launcher` STAYS, and must not be flagged: it is the role name of the +# `run_async` wrapper object, and plan Task 11's approved brief for all 18 +# overloads uses it — "Bind to produce a launcher; invoke the launcher +# with a task to start it." The verb `launch` is the violation, the noun +# `launcher` is not. Verify both directions if you touch the pattern. +extends: substitution +message: "Use '%s' for one-term-per-concept consistency (style guide C.1)." +level: warning +ignorecase: true +swap: + '\b(launch(?:es|ed|ing)?|spawn(?:s|ed|ing)?|fire[sd]? off|firing off|kick(?:s|ed)? off|kicking off)\b': start + '\bcancellation token\b': stop token + '\bcancel token\b': stop token + '\bboxed\b': type-erased diff --git a/doc/.vale/styles/config/vocabularies/Corosio/accept.txt b/doc/.vale/styles/config/vocabularies/Corosio/accept.txt new file mode 100644 index 000000000..3275f5036 --- /dev/null +++ b/doc/.vale/styles/config/vocabularies/Corosio/accept.txt @@ -0,0 +1,283 @@ +# +# Copyright (c) 2026 Michael Vandeberg +# +# Distributed under the Boost Software License, Version 1.0. (See accompanying +# file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +# +# Official repository: https://github.com/cppalliance/corosio +# + +# Corosio documentation vocabulary — class (a) terms only. +# +# Every line is a case-insensitive regex, matched with word boundaries. `(?i)` is +# deliberate: a case-SENSITIVE entry makes Vale synthesise an error-level Vale.Terms +# alert on every other casing of the term, which would turn this file into a silent +# new casing rule. Possessives ("Corosio's") are handled by Vale; plurals are not, so +# inflections are spelled out. +# +# ONLY genuine prose words, proper nouns and language keywords belong here. A bare +# corosio or std SYMBOL sitting unbackticked in running prose is a real defect (style +# guide B1) — it has a generated reference page and should be a `cpp:` link — and it +# must stay visible as a Vale.Spelling alert. Do not add one to silence it. +# +# Keywords are the deliberate exception: `co_await` has no reference page, so B1 +# offers nothing to link it to and the alert cannot be actioned. + +# --- Projects, libraries, tools, publishers ------------------------------------- +(?i)capy +(?i)corosio +(?i)asio +(?i)tmc +(?i)http +(?i)traccc +(?i)tcmalloc +(?i)wxwidgets +(?i)javadoc + +# --- People and cited sources --------------------------------------------------- +(?i)carruth +(?i)cern +(?i)chuanqi +(?i)dimovian +(?i)dobb +(?i)kohlhoff +(?i)lakos +(?i)nostrand +(?i)ousterhout +(?i)parnas +(?i)stepanov +(?i)yaknyam + +# --- Coroutine and asynchrony vocabulary (style guide C.1) ---------------------- +(?i)coroutines? +(?i)awaitables? +(?i)awaiters? +(?i)wakers? +(?i)wakeup +(?i)resumers? +(?i)combinators? +(?i)async +(?i)asynchrony + +# --- Types, idioms and roles named in prose ------------------------------------- +(?i)mutex(es)? +(?i)vtables? +(?i)typelists? +(?i)mixins? +(?i)serializers? +(?i)associators? +(?i)accessors? +(?i)functors? +(?i)callables? +(?i)callees? +(?i)requestors? +(?i)decompressors? +(?i)unchunkers? +(?i)proactors? +(?i)toolkits? +(?i)namespaces? +(?i)destructors? +(?i)virtuals? +(?i)freelists? +(?i)allocators? +(?i)implementors? +(?i)pimpl +(?i)data + +# --- Platform and systems terms ------------------------------------------------- +(?i)epoll +(?i)kqueue +(?i)io_uring +(?i)iovec +(?i)syscalls? +(?i)datagrams? +(?i)wakeups? +(?i)fd +(?i)tcp +(?i)apis? +(?i)abis? +(?i)cpus? + +# --- C++ terms used adjectivally or nominally in running prose ------------------ +# +# Language keywords belong here, and several already did (const, nullptr, nothrow, +# enum, bool). The line against bare identifiers below is about SYMBOLS: `io_result` +# has a generated reference page, so leaving it unlinked in prose is a real missed +# `cpp:` link (style guide B1). A keyword has no page and nothing to link to, so B1 +# offers no remedy and the alert can never be actioned -- it is noise, not a signal. +(?i)const +(?i)constness +(?i)nullptr +(?i)nothrow +(?i)enum +(?i)bool +(?i)boolean +(?i)lvalues? +(?i)rvalues? +(?i)prvalues? +(?i)co_await +(?i)co_yield +(?i)co_return +(?i)thread_local +(?i)variadic +(?i)templated +(?i)invocable +(?i)arity +(?i)config +(?i)codegen +(?i)devirtualization +(?i)prefetch +(?i)pseudocode +(?i)subtree +(?i)postconditions? + +# --- Coined adjectives and nouns the guide uses --------------------------------- +(?i)joinable +(?i)schedulable +(?i)launchable +(?i)buildable +(?i)greppable +(?i)greppability +(?i)composable +(?i)composability +(?i)reusability +(?i)discoverability +(?i)levelization +(?i)levelized +(?i)swappable +(?i)untyped +(?i)unbuffered +(?i)uncancellable +(?i)unclamped +(?i)uncontended +(?i)unexecuted +(?i)preconstructed +(?i)walkthrough + +# --- Verbs and participles ------------------------------------------------------ +(?i)rethrow(s|n|ing)? +(?i)preallocat(e|es|ed|ion) +(?i)deallocat(e|es|ed|ion) +(?i)dequeue[sd]? +(?i)enqueue[sd]? +(?i)dereferences? +(?i)destructur(e|es|ed|ing) +(?i)co_awaited +(?i)disambiguates? +(?i)deregisters? +(?i)unregisters? +(?i)unlinks? +(?i)downcasts? +(?i)recompiles? +(?i)reframed +(?i)reinstalls? +(?i)interop +(?i)interoperate +(?i)interoperation +(?i)websocket + +# --- Networking and transport vocabulary ---------------------------------------- +# +# Prose words, not symbols. Every entry below was a Vale.Spelling alert on Corosio's +# pages that named an English/technical term rather than a documented entity. Names +# that DO have a generated reference page are deliberately absent -- they are B1 +# defects and must keep alerting until they become `cpp:` links (io_stream, +# signal_set, buffer_param, worker_base and friends). +(?i)hostnames? +(?i)loopback +(?i)multicast +(?i)netmasks? +(?i)subnets? +(?i)connectionless +(?i)keepalive +(?i)backoff +(?i)backpressure(d)? +(?i)teardown +(?i)nonblocking +(?i)unfragmented +(?i)misrouted +(?i)routable +(?i)sendable +(?i)pipelining +(?i)demultiplexer +(?i)demultiplexing +(?i)failover +(?i)writability +(?i)lockless +(?i)bitmask +(?i)prepends +(?i)iterable +(?i)polymorphically +(?i)hardcode +(?i)unparseable +(?i)undeterminable +(?i)untrusted +(?i)firewalled +(?i)reimplementing +(?i)lookups +(?i)acks? +(?i)mtus? +(?i)ttls? +(?i)nics? +(?i)tlds? +(?i)vpns? +(?i)cas +(?i)crls? +(?i)bdps? +(?i)uris? +(?i)fds +(?i)gbps +(?i)mbps +(?i)nagle +(?i)netcat +(?i)xcode +(?i)libpq +(?i)libssh +(?i)close_notify +(?i)ipaddress + +# --- Names with NO generated reference page -------------------------------------- +# +# These are Capy types and Corosio test helpers. mrdocs.yml limits the reference to +# `boost::corosio::**`, and none of the following resolves there (verified against +# reference.tag.xml), so B1 offers nothing to link them to and the alert cannot be +# actioned -- the same reasoning the language-keyword block above records. If a +# reference page ever appears for one, delete its line here and link it instead. +(?i)mockets? +(?i)socket_pair +(?i)io_result +(?i)const_buffer +(?i)mutable_buffer +(?i)buffer_slice +(?i)consuming_buffers +(?i)execution_context +(?i)any_executor +(?i)stop_tokens? +(?i)acceptors? +(?i)descriptors? + +# --- Reference-prose vocabulary (header docstrings) ----------------------------- +# +# Words the docstring corpus uses as English or as proper nouns. Symbol names are +# NOT here -- the docstring B1 pass backticked those instead, which is the house +# convention (1179 backticks against 197 @ref). +(?i)bitwise +(?i)unicast +(?i)devirtualized +(?i)devirtualization +(?i)inlined +(?i)parameteriz(e|es|ed|ing) +(?i)deallocating +(?i)downcasted +(?i)unconfigured +(?i)unscoped +(?i)bursty +(?i)schannel +(?i)winsock +(?i)openssl +(?i)wolfssl +(?i)backend[’']s +(?i)scheduler[’']s +(?i)IP[’']s +(?i)UDP[’']s +(?i)URL[’']s diff --git a/doc/STYLE_GUIDE.md b/doc/STYLE_GUIDE.md new file mode 100644 index 000000000..978215628 --- /dev/null +++ b/doc/STYLE_GUIDE.md @@ -0,0 +1,389 @@ +# Corosio Documentation Style Guide + +**Audience:** human editors and AI agents writing or editing the documentation. + +This guide was ported from Capy's, which remains the origin of the shared rulings. Where a +rule's evidence was measured on Capy's corpus it says so; everything stated about Corosio's +corpus was measured on Corosio's, and the per-check evidence lives in `doc/lint/README.md`. + +This guide is a **checkable contract**: every rule is phrased so an agent can apply it, and +most can be checked automatically. Enforcement falls into three tiers — a CI **gate** that +blocks a merge, a CI **warning** that flags candidates for a human to judge, or **review** +via the PR checklist when no tool can decide (see Part F for the per-rule mapping). It is +organized by five documentation axes — Structure, Accuracy, Wording, Completeness, +Presentation — plus the cross-cutting concern that motivates all of them: **drift**. + +> **The prime directive — prevent drift.** Prose and generated reference tend to diverge +> over time; hand-copied signatures and pasted examples rot silently. Every rule below +> exists to make the docs *self-correcting*: single-sourced, compiled, and linted. When a +> rule trades elegance for drift-resistance, drift-resistance wins. + +--- + +## Part A — Structure (Diátaxis) + +Follow **[Diátaxis](https://diataxis.fr)**. Every page is exactly one of four modes, and +**modes must not mix**: + +| Mode | Purpose | Answers | +|---|---|---| +| **Tutorial** | learning, by doing | "teach me" | +| **How-to** | a single task, start→finish | "how do I X?" | +| **Reference** | information, dry and complete | "what is the signature of X?" | +| **Explanation** | understanding, rationale | "why is it this way?" | + +**Rules:** +- **A1.** Each page declares its mode; an edit keeps content within that mode. +- **A2. Reference belongs in the reference.** Exposition pages never reproduce full + signatures or concept definitions — they *link* (Part B). This is the highest-value + structural rule. +- **A3. Rationale belongs in Explanation.** Use interleaved admonitions (`[NOTE]`/`[TIP]`) + for *local* rationale on a how-to/tutorial page; reserve dedicated design/explanation + pages for *cross-cutting* rationale. Do not run multiple parallel rationale channels for + the same material. +- **A4. One concept, one home.** Before adding a page, find where the concept already + lives. If two pages teach the same thing, merge them. +- **A5. Ordering follows dependency.** A page may not rely on a concept introduced only on + a *later* page. "Advanced" material comes after the basics it builds on. +- **A6. Quick-start / getting-started content sits near the top of the navigation**, not + buried near the reference. +- **A7. Headings describe content, not ceremony.** No numbered "Part N" mega-headings for + short sections; use plain descriptive headings. + +## Part B — Single-source-of-truth (anti-drift core) + +The Antora pipeline provides two mechanisms; use them instead of hand-authoring: + +- **B1. Never hand-type an API signature in prose.** Reference a symbol with the `cpp:` + macro so it links to the generated reference and cannot drift + (e.g. `cpp:boost::corosio::tcp_socket[]`). To describe what a function does, link it; do not + restate its declaration. +- **B2. Never paste example code.** Every code block is an `include::example$...[tag=...]` + of a compiled source file. New examples are written as compiled sources with tagged + regions, not typed into the page. +- **B3. Intentionally-non-compiling blocks are tagged**, not silently pasted: use a + pseudocode role for sketches/rejected designs and an external role for other-library + comparisons, so the compile gate knows to skip them. These two apply to a `[source,*]` + block only — role=pseudocode/external on a bare listing does nothing (B2 doesn't look for + them there). + A bare listing (`----` with no `[source,*]` attribute, or `....`) that holds literal + program output or a hand-drawn figure — never code — is tagged `[role=output]` or + `[role=figure]` respectively, so B2 does not mistake it for an unmarked code block. This is + the one constraint that makes the design safe, and it is load-bearing: **role=output/ + role=figure exempt only a bare listing, never a `[source,*]` block** — a block that + actually compiles is tagged `[source,*]` and cleared through pseudocode/external, full + stop, however output-shaped its content looks. Confusing the two would let `role=output` + launder real code past B2. `doc-lint.mjs`'s SHAPE check runs a content heuristic over every + role=output/role=figure block and reports (non-gated, advisory) any whose content looks + like code, so a wrong tag is not silently permanent — see the check's header comment. + Note `role=` on a bare listing emits a real CSS class (`class="listingblock output"` / + `...figure`) with no stylesheet rule behind it today; a future UI bundle that styles + `.output`/`.figure` will change how every such block renders, project-wide, in one step. +- **B4. A brief describes behavior, not identity — classes included.** A reference brief + says what the entity *does*, not what it *is*, and never restates its declaration or claims + parameters it does not take. This binds class briefs as much as function briefs. *(Reversal, ruled + on Capy and binding here: two documentation audits there read identity-shaped class briefs + — "A test utility for…", "Result type for…" — as house convention, attested across 17+ + headers, and on that reading dropped roughly 230 findings apiece. The maintainer ruled that + B4 binds them anyway: describing what a class *is* is not a licensed house style, it is the + defect B4 exists to catch. Do not re-derive the house-convention reading from Corosio's + corpus either — roughly 30 briefs here open "A…"/"An…"/"The…", and their number is not + an argument.)* +- **B5. Doc code uses the namespace alias, never a using-directive.** Every file under + `test/doc/`, and every code block authored inline in a `.adoc` page, spells library names + against `namespace corosio = boost::corosio;` — `corosio::tcp_socket`, + `corosio::io_context`, `corosio::tls_context` — even on a page that never declares the + alias itself. Corosio's doc code also names Capy, which takes the same treatment via + `namespace capy = boost::capy;`. A reader should be able to tell which names in an example + come from which library without knowing either one. + `using namespace std::chrono_literals;` is not a library-name directive and is not covered + by this rule. + The exception: a block that verbatim-quotes library-internal source keeps that source's own + unqualified spelling. Neither `using namespace boost::corosio;` nor + `using namespace boost::capy;` is used in doc code. Warning + suppressions live in `test/doc/doc_warnings.hpp`, included once per file outside every tag; a + warning that fires in one fragment stays in that fragment with a comment saying why. B5 is + enforced by review only — no script checks it. + +## Part C — Wording (pragmatic Simplified Technical English) + +Apply an **ASD-STE100–derived subset**: STE's *spirit* — short sentences, active voice, one +idea per sentence, simple tense, one term per concept — **not** its strict word-ban. Enforce +**hard** in reference briefs and how-to steps; **relax** to spirit-only in tutorials and +design essays. + +- **C1. One idea per sentence.** Split compounds joined by "and/but/;/—". +- **C2. Length.** ≤ 20 words for instructions, ≤ 25 for descriptive text. Hard in API docs; + soft in essays. +- **C3. Active voice; name the actor.** "The task receives the executor," not "the executor + is received." +- **C4. Present simple.** Avoid needless "will" and "has been". +- **C5. No unnecessary negatives.** Write "This is X," not "This is X. Not Y. Not Z." +- **C6. No decorative figurative language.** At most one analogy per page, only when it + carries real explanatory weight. Cut clichés and metaphors the reader must + reverse-engineer. +- **C7. Define terms before use.** No expression enters the text without a definition or a + glossary link. +- **C8. Keep articles.** "the task", "a coroutine" — never drop *the/a* to save words. +- **C9. Plain words.** *use* (not utilize/leverage), *to* (not in order to), *before* (not + prior to), *because* (not due to the fact that); delete + *simply/basically/obviously/of course/note that*. +- **C10. One term per concept** (Part C.1). Never alternate synonyms. +- **C11. One documentation command per concept — docstring tags included.** Doxygen offers + both `@pre` and `@par Preconditions` for the same concept, a precondition. Use `@pre`; never + `@par Preconditions`. *(Placement: this is C10's "one term per concept" applied to command + choice rather than word choice, not a drift risk, so it sits in Part C rather than Part B — + neither tag can go stale relative to the code; they only differ in which markup an author + reaches for. It is deliberately not a C.1 table row: C.1 governs English words chosen while + writing prose, enforced by matching that prose after docstring extraction; these are Doxygen + commands consumed *by* the extractor itself, and the two do not even survive extraction in + the same shape — `@par Preconditions` re-emits as a bare "Preconditions" prose line, `@pre` + re-emits with no label at all — so a C.1-style substitution rule could not enforce this as + written. Evidence: the docstring corpus was split exactly 17/17 between the two forms when + this was ruled — a genuine tie, not two conventions living in different files; `thread_pool.hpp` + alone contains both (`@pre` once, `@par Preconditions` twice). There was no house rule to + preserve; the maintainer broke the tie in favor of `@pre`. Treat `@par Preconditions` as the + form to replace wherever a docstring is touched.)* + +### C.1 Terminology table (controlled vocabulary) + +Use the **Use** column everywhere; never the **Avoid** synonyms. API identifiers are +technical names and never change. *(This table is the one part of the guide expected to grow +as vocabulary is added — extend it rather than letting synonyms drift.)* + +| Concept | Use | Avoid | +|---|---|---| +| begin executing a coroutine | **start** | launch, spawn, fire off, kick off, run (verb) | +| the `co_await` operation | **await** | wait on, waiting for | +| value a coroutine yields at completion | **result** | return value (except naming the C++ type) | +| object that schedules work | **executor** | scheduler (reserve for P2300) | +| context owning threads/executors | **execution context** | context (bare), backend context | +| concrete I/O impl behind a type-erased type | **I/O backend** | backend, provider, engine | +| type that hides its concrete type | **type-erased** | erased, opaque, boxed | +| `stop_token`-based cancellation | **stop token** / **cancellation** | cancel token, cancellation token | +| callback passed to `run_async` | **completion handler** | handler (bare), callback | +| a `task` value | **task** | coroutine (the language feature), coro | +| the C++20 language feature | **coroutine** | coro, async function | +| awaitable satisfying `IoAwaitable` | **I/O awaitable** | awaitable (bare, when the concept is meant) | +| object that listens for inbound connections | **acceptor** | listener, server socket, listening socket | +| an address plus a port | **endpoint** | address (when the port is included), socket address | +| the machine at the other end of a connection | **peer** | remote, other side, partner | +| turning a host name into addresses | **resolve** | look up (as the noun), DNS-ify | +| the object holding TLS settings | **TLS context** | SSL context, security context | +| a TLS-wrapped stream | **TLS stream** | SSL stream, secure socket | +| the in-process test double for a socket | **mocket** | mock socket, fake socket | +| a connected pair of in-process sockets | **socket pair** | socketpair, pipe pair | + +Approved technical names (need no paraphrase): coroutine, task, promise, awaiter, +awaitable, executor, execution context, strand, thread pool, allocator, frame, buffer, +buffer sequence, stream, stop token, sender, receiver, scheduler, mutex, event, waker, +acceptor, endpoint, peer, datagram, socket, resolver, TLS context, TLS stream, mocket. +Where a name here also appears in the Avoid column above, the table row governs: use +it only in the sense the row names. **scheduler** is approved only in its P2300 sense +(the `scheduler` concept); it is never a synonym for **executor**. + +## Part D — Completeness & Pedagogy + +- **D1. Goal-oriented, not syntax-first.** Open a concept with a use case ("you want to + X"), then introduce the machinery that achieves it. Do not enumerate syntax before + motivation. +- **D2. Every concept page has a runnable example** of the library's *own* type — not only + of the standard-library types it resembles. A page introducing a type shows that type in + use, actually running. *(Primer carve-out: the "library's own type" clause does not bind a + section that declares itself background material rather than a Corosio concept page. + `doc/modules/ROOT/pages/2.networking-tutorial/` teaches IP, TCP and UDP from first + principles, before Corosio is introduced, and names no Corosio type — so no example of a + Corosio type could belong there. That whole chapter is therefore outside + `doc-lint.mjs`'s `CONCEPT_DIRS`, which is what excuses it. The carve-out is about scope, not + about tolerating a gap: a page inside the scope still owes a real example.)* + *(Landing-page carve-out: every `*.intro.adoc` is a motivating essay plus a mechanical child + list, never an introduction of a Corosio type. `doc-lint.mjs`'s D2 check is scoped to + `CONCEPT_DIRS` (a pedagogical category — "this chapter teaches progressively") and + deliberately does **not** read `:page-mode:` at all, so a page cannot leave D2's scope by + declaring a mode, correct or not. The three in-scope chapter-intro pages + (`3.intro.adoc`, `4.intro.adoc`, `5.intro.adoc`) stay in D2's scope and keep failing it: + they introduce no type, so there is no example to add. That three-finding count is a + documented, intentional consequence of D2's own scope, not a regression to chase to zero by + adding decorative `include::example$` blocks to pages that don't need one.)* +- **D3. Every non-obvious design choice states its rationale** (or links to the explanation + page that does). "Because it is" is not documentation. +- **D4. Document thread-safety *and* executor affinity** at the class level where relevant. +- **D5. No unexplained qualifiers.** Hedges like "even on X" or "where available" either get + explained or get cut. + +## Part E — Presentation & Tooling + +- **E1.** Prose links to the reference via `cpp:` (Part B1) so a first-time reader can see a + type inline. +- **E2.** A right-rail table of contents is enabled; long pages are split at natural mode + boundaries. *(Review tier: verify by eye, do not gate.)* The ToC is switched on by the + `page-toc` attribute in `doc/antora.yml`, not by the theme. +- **E3.** The reference is grouped by functionality where the generator allows; operators are + documented with their types; asynchronous operations are distinguishable from synchronous. +- **E4.** The theme passes a contrast check in both light and dark mode. *(Review tier: + verify by eye, do not gate, and there is no automated scan.) Corosio deliberately runs no + accessibility scanner. Capy's `run-a11y.mjs` could not fail CI there — it carried + `continue-on-error: true` and appeared in no gate spec — and a check that cannot fail earns + nothing, so neither it nor `pa11y-ci` was ported. The findings such a scan reports on a + Boost Antora site are generator or theme output rather than authored content: the empty + `` Asciidoctor emits before every section heading (`link-name`), the + MrDocs reference title's auto-linked namespace segment (`link-in-text-block`), and the + `ui-bundle` stylesheet putting `overflow-x:auto` on `.listingblock pre` without a + `tabindex` (`scrollable-region-focusable`). Those shapes are grandfathered by the same + reasoning that demoted E2 — **but only those shapes.** An accessibility defect in markup + Corosio actually writes is a defect, and this carve-out does not reach it. + +## Part F — Enforcement (makes this guide checkable) + +The guide is only anti-drift if CI checks it. Add **[Vale](https://vale.sh)** and wire it +into the CI documentation job. + +### F.0 Enforcement tier by rule + +Not every rule is machine-checkable. Each rule sits in one of three tiers: + +- **Gate** — CI blocks the merge. Checked by Vale, the snippet-compile job, a small custom + AsciiDoc/nav lint script, or an accessibility scan. +- **Warning** — CI flags candidates, a human decides. Heuristic checks with real + false-positive/negative rates; never block on these. +- **Review** — no tool can judge; enforced by the PR checklist (F3). + +| Tier | Rules | +|---|---| +| **Gate** | A1, A6, A7, B2, B3, C2, C4, C9, C10, D2 | +| **Warning** | A2, B1, C1, C3, C5, C6, D4, D5, E1 | +| **Review** | A3, A4, A5, B4, B5, C7, C8, C11, D1, D3, E2, E3, E4 | + +The accuracy gates (B2, B3, D2 correctness) are enforced by the snippet-compile job, not by +Vale — that job is what makes examples unable to drift. + +`doc/.vale.ini`, in outline — the committed file additionally carries the `TokenIgnores` +clauses (backtick spans, `cpp:` macros, the boost-wide thread-safety label) and the +demotion list for Google house-style rules, each with its rationale: +```ini +StylesPath = .vale/styles +MinAlertLevel = warning +Packages = Google +[*.adoc] +BasedOnStyles = Vale, Google, Corosio +; AsciiDoc source/callout blocks are code, not prose: +BlockIgnores = (?s) *(\[source.*?----.*?----) +TokenIgnores = (\x60[^\x60]+\x60) +``` + +`doc/.vale/styles/Corosio/Terminology.yml` (enforces Part C.1): +```yaml +extends: substitution +message: "Use '%s' for one-term-per-concept consistency (style guide C.1)." +level: warning +ignorecase: false +swap: + '\b(launch|spawn|fire off|kick off)\b': start + '\bcancellation token\b': stop token + '\bcancel token\b': stop token + '\bboxed\b': type-erased +``` + +`doc/.vale/styles/Corosio/NoFluff.yml` (enforces C5/C9): +```yaml +extends: existence +message: "Filler/fluff — delete or rewrite (style guide C5/C9): '%s'." +level: warning +ignorecase: true +tokens: + - simply + - basically + - essentially + - obviously + - of course + - note that + - in order to + - due to the fact that + - utilize +``` + +`doc/.vale/styles/Corosio/SentenceLength.yml` (retired to `suggestion`; does not enforce C2 — +see F1): +```yaml +extends: occurrence +message: "Sentence over 25 words — split it (style guide C1/C2)." +level: suggestion +scope: sentence +token: \b(\w+)\b +max: 25 +``` + +- **F1.** CI runs `vale doc/modules` and fails on `error`-level findings, except **C2**: its + authority is `doc/lint/sentence-length.mjs` (`doc/lint/README.md`), hard on docstrings and + non-essay `.adoc` pages, advisory on `2.networking-tutorial/`. + `Corosio.SentenceLength` is `level: suggestion` and enforces nothing. +- **F2.** The snippet-compile job is the accuracy gate; keep every example sourced from a + compiled file (Part B2). +- **F3.** Doc PR checklist: mode declared (A1)? no hand-typed signatures (B1)? example + compiled (B2)? namespace alias, not a using-directive (B5)? terminology clean (`vale`)? + rationale present (D3)? + +### F4 — A check is not adopted until a planted violation has failed it + +**The rule: before promoting a rule to a gate — or believing a gate you just wired — plant a +violation of that exact rule and watch the check fail. A green run is not evidence.** Twelve times +during Capy's documentation-improvement work a check looked healthy while checking less than it +appeared to, and every one of them read as a pass. This toolkit was ported from that one, so it +inherits the risk along with the code. Three of those failures, worth knowing before you trust a +check here: + +- **A rule that could not match any input.** `Capy/PartHeadings.yml` (A7) was written + `scope: heading` with the pattern `^==+\s+Part\s+\d+`. Vale's heading scope hands the rule the + heading *text*, with the `==` markers already stripped, so the anchor guaranteed zero matches. + It reported clean over a corpus full of `== Part 3:` headings until `b54fe6c8` fixed it. +- **A gate spec that matched no fingerprint.** + `--gate 'vale_adoc:^(Corosio\.SimpleTense|Corosio\.NoFluff|Corosio\.Terminology)$'` reports + `gated: true, gatedNew: 0` at exit 0, because the check name lives at the *tail* of a Vale + fingerprint and the leading `^` anchors to the file name. The un-anchored form fails on the same + input. Found twice, the second time by bite-testing rather than by reading. +- **A gated check that collapsed to zero without being marked skipped.** A crashing check emitted + no findings and reported `count: 0, skipped: false`; the comparator then computed *zero new + violations* from an empty current set and passed. Zero looks exactly like success. Both + comparators now carry an explicit fail-closed rule for it — a gated check with zero findings + against a non-empty baseline is fatal unless `--allow-emptied` names it, in + `doc/lint/baseline-diff.mjs` (reseed candidates) and in + `doc/lint/check-no-new-violations.mjs` (the blocking gate). The reachable case that motivated + the second one, and the reason it is whole-check rather than per-gate-regex, is recorded at the + rule itself: `cd doc && vale --output=JSON lint/.nonexistent-corpus` prints `{}` and exits 0. + `doc/lint/baseline.mjs` additionally checks `extract-docstrings.mjs`'s exit status, because the + docstring corpus is generated and the generator never clears its output directory, so a crashed + extractor used to leave a stale corpus that linted clean. + +The shared shape is that all three failures are **silent and reassuring**: the machinery reports +success, and the only way to distinguish "nothing is wrong" from "nothing is being checked" is to +introduce something wrong and confirm it is caught. `doc/lint/selftest.mjs` exists for the same +reason, and `doc/lint/README.md` records the fingerprint shapes a gate spec has to match — +together with the **bite-test log for this repository**: every check here was verified against a +planted violation of its own rule, and the results are tabulated there. Two of those confirmed +that the traps above are live on Corosio's corpus, not merely historical: an `^`-anchored Vale +gate spec exits 0 while gating nothing, and a crashed docstring extractor is caught by the +fail-closed rule rather than passing as zero findings. Add a check, and you add a row to that +table before you believe it. + +--- + +### How an agent uses this guide +1. Identify the page's Diátaxis mode; keep edits in-mode (A1). +2. Never type a signature or paste code — link (B1) or include a compiled snippet (B2). +3. Run Vale locally over both corpora, from `doc/`, before proposing the change, and fix all + `error`s. Vale must run from `doc/` with `node_modules/.bin` on `PATH` — this project's Vale + needs `asciidoctor` (the asciidoctor.js build under `node_modules`, not a Ruby install; there + is no Ruby on a stock dev machine here) to parse AsciiDoc, and without both of those it exits + 2 having printed nothing, which greps identical to a clean run and has already misled two + audit sub-agents this way: + ``` + cd doc && export PATH="$PWD/node_modules/.bin:$PATH" + vale --output=JSON modules + node lint/extract-docstrings.mjs && vale --output=JSON lint/.docstrings + ``` + A `0` in the output is not evidence of a clean run by itself — it is at least as often + evidence the run never happened (F.4's silent-and-reassuring failures are exactly this + shape). Confirm a non-zero total somewhere before trusting a zero. Vale does not enforce + C2 (sentence length) either way — its authority is `doc/lint/sentence-length.mjs`, not Vale + (F1); do not look to `vale`'s exit code for C2. +4. For every new claim, either link the rationale or add it (D3). \ No newline at end of file diff --git a/doc/antora.yml b/doc/antora.yml index db9aedbf7..35cd380c5 100644 --- a/doc/antora.yml +++ b/doc/antora.yml @@ -15,6 +15,10 @@ asciidoc: attributes: source-language: asciidoc@ table-caption: false + # Style guide E2: the right-rail table of contents is switched on here, not by + # the theme. + page-toc: '' + toclevels: 2 nav: - modules/ROOT/nav.adoc ext: diff --git a/doc/design/style-guide-compliance.md b/doc/design/style-guide-compliance.md new file mode 100644 index 000000000..94880dea6 --- /dev/null +++ b/doc/design/style-guide-compliance.md @@ -0,0 +1,554 @@ +# Bringing Corosio's Documentation Into Style-Guide Compliance + +## 1. Introduction + +**Scope**: This document specifies the work needed to make Corosio's documentation +comply with `libs/capy/doc/STYLE_GUIDE.md`, and to port Capy's CI enforcement +toolkit into Corosio so compliance is machine-checked rather than asserted. + +**Two deliverables**: + +1. A `doc/lint/` toolkit, a Vale configuration, and a Corosio `doc/STYLE_GUIDE.md`, + wired into the existing Documentation workflow. +2. A phased remediation of the measured backlog, ordered so each phase is + verifiable before the next begins. + +**Non-goal**: changing Capy. Capy's own enforcement gaps are recorded in section 7 +as follow-ups with no owner in this plan. + +## 1a. Status + +| Phase | State | +|---|---| +| 0 — infrastructure | **done** — toolkit ported, bite-tested, CI wired, baseline seeded | +| 1 — A1/E2/A6/ANCHOR/B5 | **done** — all four checks at zero | +| 2 — B2/B3 retagging | **done** — B2 at zero, SHAPE clean and proven live | +| 3 — B1 `cpp:` conversion | **done** — pages: 186 backtick spans + 30 bare names. Docstrings: 93 bare identifiers backticked and three Doxygen extractor fixes; corpus 420 → 62 | +| 4 — C11 `@pre` | **done** — no `@par Preconditions` remains | +| 5 — C9/C10 | **done** — both rules at zero on both corpora | +| 6 — C4 present simple | **done** — both rules at zero on both corpora | +| 7 — C2 sentence length | **done** — hard 131 → 1, and that one is the `@n` artifact | +| 8 — B4 briefs | **done** — 40 class briefs rewritten; scope noted at the phase | +| 9 — close the gate | **done** — CI baseline installed; selftest and every gated rule strict except `mrdocs_warnings` (see below) | + +Measured after phase 9, both corpora: Vale over `doc/modules` 466 → 132; +`Vale.Spelling` 284 → 36; Vale over `lint/.docstrings` 785 → 427; `doc_lint` +A1/A6/B2/ANCHOR all 0, D2 3 (the documented carve-out); C9/C10 0/0; C4 0/0; +`sentence_length` hard 131 → 1, advisory 70. + +**How phase 9 closed.** It took three reseeds, and the safety net refused two of them: +the first because the MrDocs version pin reported `mrdocs_warnings` as SKIPPED (section +4.3), the second on a real gated `Corosio.SimpleTense` regression that no local run +surfaced. The accepted candidate retires 365 fingerprints and grandfathers one +`Google.OxfordComma` false positive, documented in `doc/lint/README.md`. + +**Earlier account of the close.** The `workflow_dispatch` reseed replaced the local seed with a +CI-authored baseline: 1096 fingerprints retired, none grandfathered, none gated. +`doc_lint` and `sentence_length` measured identically in both environments (3 and 71), +confirming the split-gate reasoning; `vale_adoc` differed 132 local against 66 in CI, and +`mrdocs_warnings` 460 against 352. + +After the awaitable encapsulation and the special-member documentation pass, +`mrdocs_warnings` measures **8** locally — all of them the unattributed `` findings +that reach MrDocs through libstdc++ and are not fixable in Corosio — and **0** in CI, +which has never emitted them. The sixth reseed (2026-09-10T17:09Z) retired the last 44, +so the reference surface is clean and every future MrDocs warning is a new one; the +seventh (2026-09-21T17:47Z) is what is installed. See `doc/lint/README.md` for how that +zero was authorised. + +Every gated rule is now strict except `mrdocs_warnings`. That one keeps reporting because +the baseline is zero, so its `.*` spec would gate every warning that ever appears, while +MrDocs is a rolling `develop-release` build whose output moved by 108 warnings on an +unchanged tree between two runs days apart. Gating a tool +that rewrites its own output is the same mistake as the version pin in 4.3. Promote it only +alongside a pinned MrDocs. + +**Note for phase 7.** `sentence-length.mjs` does not treat Doxygen's `@n` as a +sentence boundary, so `io_context`'s boost-wide thread-safety idiom +("Distinct objects: Safe.@n Shared objects: Safe, unless ...") is measured as one +29-word sentence rather than two short ones. That finding is an artifact, not a long +sentence; do not "fix" the docstring for it. + +### Corrections to this document + +Three claims below were wrong when written and are corrected in place. They are +listed here because the plan was approved on their strength. + +1. **A1 mode mapping.** Section 6 phase 1 assigned `2.networking-tutorial/*` the + `tutorial` mode. That chapter has no `include::example$` anywhere, no imperative + steps, and purely expository headings, so `explanation` is the accurate Diátaxis + mode. Declaring it a tutorial would be a false declaration under A1 and would + contradict the D2 and C2 carve-outs, which both rest on the chapter being + background material. +2. **B1 and `Vale.Spelling`.** Section 3 said phase 3 "retires the Corosio-symbol + share" of the 284 spelling alerts, and phase 3's exit criterion said the count + would fall "by the corresponding amount". Both are false: a backtick span and a + `cpp:` macro are both in `TokenIgnores`, so Vale never saw either. The 284 were a + different B1 shape — identifiers with no code span at all — and needed their own + work, done as a follow-on and recorded under phase 3. +3. **Per-phase gate promotion.** Section 6 said each phase should "promote that rule + in the gate spec", which contradicts D-5. The gate spec carries every rule from + phase 0 onward; what is deferred to phase 9 is only the `--strict` flag. There is + no per-phase gate edit to make. + +## 2. Decisions + +These were settled before the plan was written. Each shapes the phasing. + +| # | Decision | Rationale | +|---|---|---| +| D-1 | Port the machinery, seed `baseline.json` from the current backlog, then burn down | CI stays green from day one and no *new* violation can land while the backlog is worked. The alternative — remediate first — leaves a long stretch with no check running and every fix unguarded against regression. | +| D-2 | Copy the Vale styles into `doc/.vale`, renamed `Corosio/*` | Corosio's docs job must not depend on Capy's doc-tree layout, even though the CI already clones Capy. Divergence from Capy's copies is the accepted cost. | +| D-3 | Convert only `boost::corosio` public symbols to `cpp:` macros | `doc/mrdocs.yml` sets `include-symbols: boost::corosio::**`, so Corosio's site contains no Capy reference pages. A `cpp:boost::capy::…[]` macro would render a dead link. `capy::`/`cond::` and `std::` spans stay backticks. | +| D-4 | Remediate both corpora: pages *and* extracted header docstrings | The docstring corpus carries the larger share of the C2/C4 backlog and all of C11. Capy gates `vale_docstrings`; excluding it would leave most of the problem unmeasured. | +| D-5 | Gate runs report-only during the burn-down; `--strict` and a blocking `selftest.mjs` are the **final phase's exit criteria** | Matches Capy's stated intent on Corosio's timeline. Section 7 records the cost: until the flip, a new violation sits in the report until a reseed grandfathers it. | +| D-6 | Corosio gets its own `doc/STYLE_GUIDE.md`, retaining every rule, with Capy-specific carve-outs and evidence replaced by Corosio's | The guide is normative for Corosio's authors and agents; it must describe Corosio's corpus, not Capy's. | +| D-7 | Port `mrdocs-warnings.mjs` (with its version pin removed); do **not** port `run-a11y.mjs` | See sections 4.3 and 4.4. | + +## 3. Measured current state + +Produced by running Capy's own scripts against Corosio's corpus. These are the +numbers `baseline.json` will be seeded from; re-measure in CI before seeding, +because a local run drifts from a CI run. + +| Rule | Finding | Pages | Docstrings | +|---|---|---|---| +| A1 | no `:page-mode:` attribute | **48 of 48** | — | +| A6 | `quick-start.adoc` is 8th of 9 top-level nav entries | 1 | — | +| A7 | numbered "Part N" headings | 0 | 0 | +| B1 | `cpp:` macros in prose | **0** | — | +| B2/B3 | raw code in an untagged block | **39** | — | +| ANCHOR | `` `[[...]]` `` renders as an empty `` | 2 | — | +| B4 | identity-shaped briefs | — | ~30 | +| B5 | `using namespace boost::corosio` / `boost::capy` in doc code | — | 3 | +| C2 | sentences over 25 words | **143** | **61** | +| C4 | `Corosio.SimpleTense` + `Google.Will` | 67 | 186 | +| C9 | `Corosio.NoFluff` | 8 | 1 | +| C10 | `Corosio.Terminology` | 10 | 8 | +| C11 | `@par Preconditions` instead of `@pre` | — | **41** across 17 headers | +| E2 | `page-toc` attribute in `doc/antora.yml` | **missing** | — | +| D2 | concept page with no `include::example$` | see 4.2 | — | + +`Vale.Spelling` reports 284 on pages and 552 on docstrings. Most are bare C++ +identifiers used as running text, which Capy's `.vale.ini` deliberately leaves +unlisted because they are B1 defects. They are not a separate work item: phase 3 +retires the Corosio-symbol share, and `accept.txt` absorbs the genuine prose +words and proper nouns. + +**Already compliant**: B2 is 381 of 399 `[source]` blocks sourced from compiled +files, with `test/doc/{snippets,programs,reference}` and the `antora.yml` +collector wiring already in place. A7 is clean. A glossary exists (C7). + +## 4. Infrastructure to port + +### 4.1 File manifest + +Created under `libs/corosio/doc/`: + +``` +STYLE_GUIDE.md +.vale.ini +.vale/styles/Corosio/{Terminology,NoFluff,SimpleTense,PartHeadings,SentenceLength}.yml +.vale/styles/config/vocabularies/Corosio/accept.txt +.vale/styles/Google/ (the upstream pack, as Capy vendors it) +lint/README.md +lint/doc-lint.mjs +lint/extract-docstrings.mjs +lint/sentence-length.mjs +lint/mrdocs-warnings.mjs +lint/check-include-tags.mjs +lint/selftest.mjs +lint/baseline.mjs +lint/baseline-diff.mjs +lint/check-no-new-violations.mjs +lint/baseline.json (seeded in CI, not locally) +``` + +`package.json` gains the `asciidoctor` devDependency Vale shells out to. It does +**not** gain `pa11y-ci` (section 4.4). + +### 4.2 Corosio-specific configuration — decisions, not renames + +Beyond replacing `capy`→`corosio` in paths and rule names, four values carry real +judgment: + +**`doc-lint.mjs`'s `CONCEPT_DIRS` (D2).** Corosio's chapters are +`2.networking-tutorial`, `3.tutorials`, `4.guide`, `5.testing`. +`2.networking-tutorial` teaches IP, TCP, and UDP theory and introduces no Corosio +type — structurally the same case as Capy's `3a`–`3d` primer. It gets the same +explicit carve-out, recorded in Corosio's `STYLE_GUIDE.md` at the D2 entry. +Without it D2 fires roughly 13 findings that no example can fix, exactly the +"count to chase to zero by adding decorative includes" failure Capy's guide warns +against. The five `*.intro.adoc` landing pages stay in D2's scope and keep failing +it, for the same documented reason Capy's do: they introduce no type. + +**`sentence-length.mjs`'s `advisoryDirs` (C2).** Capy's `9.design/` and +`A.specification-methods/` do not exist here. Set `2.networking-tutorial/` +advisory and keep every other page hard. Measured justification: that one chapter +holds 72 of the 143 page-level C2 hits and is essay-style prose teaching protocol +theory, which is precisely the material C2 relaxes for. This drops the hard page +slice from 143 to 71. Docstrings stay hard at 61. + +**`doc-lint.mjs`'s A6 check.** Assert `quick-start.adoc` sits within the first +three top-level `nav.adoc` entries, unchanged in substance from Capy. + +**C.1 terminology table.** Capy's rows are coroutine vocabulary and carry over +verbatim. Corosio needs networking rows added rather than inherited — the +candidates to settle while writing the guide are one term each for: the +`tcp_acceptor`/listening-socket concept, `endpoint` vs address vs peer, the +mock-socket testing vocabulary (`mocket`, socket pair), and TLS context vs stream. +Extend the table; do not let synonyms drift. + +### 4.3 `mrdocs-warnings.mjs` — ported, and its version pin removed + +**This section previously said the pin needed no edit. That was wrong, and the first +CI reseed proved it.** The claim rested on measuring the MrDocs binary on a developer +machine, which reported `0.8.0+`; `mrdocsBaseVersion()` strips the `+` metadata, +so base `0.8.0` matched `PINNED_VERSION`. The mistake was generalising from that: the +`develop-release` asset is a moving target, and the same asset name reported + +``` +0.8.0+e31308f6c944 (local, downloaded by doc/build_antora.sh) +2026.9.5 (CI, days later, same asset) +``` + +Upstream moved to a date-based version. The pin then rejected the only candidate, the +check reported SKIPPED, and the reseed candidate carried `mrdocs_warnings: 0` against +a 460-fingerprint gated baseline — which would have wiped the whole MrDocs gate. +`baseline-diff.mjs` refused the candidate for exactly that reason, which is the +F4 fail-closed rule doing its job. + +The fix is not a new number. **`MRDOCS_ROOT`, when set, is authoritative and gets no +version check.** `doc/build_antora.sh` installs the reference-snippets extension into +exactly one MrDocs and exports `MRDOCS_ROOT`, writing it to `$GITHUB_ENV` so it +survives into later workflow steps. That install is the one the rendered reference was +generated with, which is the invariant the check needs: measure the reference surface +with the same MrDocs that produced it. Pinning a version against a rolling asset +cannot express that and can only fail. + +`PINNED_VERSION` survives as a tiebreaker for the fallback cache scan, where the +reference-collector cache can hold several binaries (the `develop` and `master` tags) +and picking the first would be nondeterministic. `MRDOCS_VERSION` still overrides it. +The resolved binary, its reported version, and which of the two paths chose it are +emitted in the payload, so a future scheme change shows up in the run log instead of +turning into another silent skip. + +### 4.4 `run-a11y.mjs` — not ported + +In Capy this check cannot fail CI: the step carries `continue-on-error: true`, E4 +is Review tier in Part F.0, it appears in no `--gate` spec, and the gate's own +comment states that a skip of the a11y scan does not fail the gate. Per the +maintainer's rule — a check that cannot fail CI is not needed — it does not come +across, and neither does the `pa11y-ci` dependency, `.pa11yci.json`, or the +`PA11Y_CHROME_PATH` job env. Corosio's `STYLE_GUIDE.md` E4 entry is written as +review-by-eye with no scan, rather than copying Capy's "scan runs non-blocking" +wording. + +### 4.5 Workflow changes + +`.github/workflows/docs.yml` gains, after the existing site build and the blocking +reference-examples check: + +| Step | `continue-on-error` | +|---|---| +| install `asciidoctor` (apt, before every Vale step) | false | +| install Vale 3.15.1 | true | +| `vale sync` | true | +| `vale modules` | true | +| `node lint/extract-docstrings.mjs && vale lint/.docstrings` | true | +| `node lint/doc-lint.mjs` | true | +| `node lint/check-include-tags.mjs` | **false** | +| `node lint/mrdocs-warnings.mjs` | true | +| `node lint/selftest.mjs` | true → **false** at phase 9 | +| `node lint/check-no-new-violations.mjs --show-baseline` | true | +| the gate, `check-no-new-violations.mjs --gate …` | false; **no `--strict` until phase 9** | +| reseed candidate steps, `workflow_dispatch` only | false | + +The `asciidoctor` install is load-bearing and must precede every Vale step. Vale +3.x shells out to the **Ruby** CLI to parse AsciiDoc; the JS `@asciidoctor/core` +that `npm ci` pulls in provides no such binary. Without it Vale exits 2 having +printed nothing, which greps identical to a clean run — the F.4 failure shape. +Because both Vale checks are gated, that silent skip must fail the gate rather +than pass it. + +Gate specs, with the fingerprint shapes that make them work: + +``` +--gate 'doc_lint:^(A1|A6|B2|D2|ANCHOR):' +--gate 'vale_adoc:Corosio\.PartHeadings$' +--gate 'mrdocs_warnings:.*' +--gate 'sentence_length:^C2:' +--gate 'vale_adoc:(Corosio\.SimpleTense|Corosio\.NoFluff|Corosio\.Terminology)$' +--gate 'vale_docstrings:(Corosio\.SimpleTense|Corosio\.NoFluff|Corosio\.Terminology)$' +``` + +The two shapes differ and the difference is load-bearing. `doc_lint` and +`sentence_length` fingerprints put the rule at the **head**, so `^` is correct +there. Vale fingerprints are `file:#N:Check.Name` with the check name at the +**tail**, so those specs tail-anchor with `$` and must carry no leading `^`. An +`^`-anchored Vale spec matches nothing and still reports `gated: true, +gatedNew: 0` — a gate that says it is gating while checking nothing. Capy measured +that failure twice. + +## 5. F4 obligations + +Part F4 records twelve checks that looked healthy while checking less than they +appeared to, every one of which read as a pass. Porting them re-inherits that +risk, so no ported check is believed on the strength of a green run. + +**For every check, plant a violation of that exact rule and watch the check fail +before the check is considered adopted.** Minimum bite-test set, recorded with its +results in `doc/lint/README.md`: + +| Check | Planted violation | +|---|---| +| A1 | a page with `:page-mode: concept` (a non-mode value, not merely a missing attribute) | +| A6 | `quick-start.adoc` moved to the 4th top-level nav entry | +| A7 / `Corosio.PartHeadings` | a `== Part 3: …` heading — Capy's version of this rule matched **zero** inputs for months because Vale's heading scope strips the `==` markers | +| B2 | raw code in a bare `----` listing, in a 5-dash listing, and under a `[source,cpp]` + `[role=output]` pair (the laundering case) | +| ANCHOR | `` `[[nodiscard]]` `` in prose | +| C2 | a 30-word sentence in a docstring and one in a hard-slice page | +| C4/C9/C10 | one Vale hit per rule, on **both** corpora | +| the gate | each `--gate` spec in turn, confirming a *new* planted finding is reported and a baselined one is not | +| fail-closed | a gated check forced to zero findings against a non-empty baseline must be **fatal**, not a pass — `vale --output=JSON lint/.nonexistent-corpus` prints `{}` and exits 0, and zero looks exactly like success | +| extractor | `extract-docstrings.mjs` made to crash must not leave a stale corpus linting clean; `baseline.mjs` checks its exit status for this reason | + +`selftest.mjs` automates the subset it can and is the standing guard afterwards. +It stays non-blocking during the burn-down and becomes blocking at phase 9. + +## 6. Remediation phases + +Ordered by increasing prose risk. Each phase ends with a CI reseed of +`baseline.json` via `workflow_dispatch` — never locally, because a local run +grandfathers hundreds of local-vs-CI drift fingerprints — and, where the phase +takes a rule to zero, promotion of that rule in the gate spec. + +### Phase 0 — infrastructure + +Port everything in section 4, seed `baseline.json` in CI, wire the report-only +gate, and complete the section 5 bite-tests. **Exit**: CI green; every bite-test +confirmed failing its own check; `README.md` records the results. + +### Phase 1 — mechanical, no prose risk + +| Item | Count | Detail | +|---|---|---| +| A1 | 48 | add `:page-mode:` to all 48 pages — none has one today. Modes: `2.networking-tutorial/*` → `explanation` (see the correction in 1a); `3.tutorials/*` → `tutorial`; `4.guide/*` and `5.testing/*` → `how-to`; `*.intro.adoc`, `index.adoc`, `benchmark-report.adoc` → `explanation`; `glossary.adoc` → `reference`; `quick-start.adoc` → `tutorial`. Values must be one of the four Diátaxis modes — A1 checks the value, not just presence. | +| E2 | 1 | add `page-toc: ''` and `toclevels: 2` to `doc/antora.yml`'s `asciidoc.attributes`, matching Capy's | +| A6 | 1 | move `quick-start.adoc` to the 2nd top-level `nav.adoc` entry, directly after `index.adoc` | +| ANCHOR | 2 | `4.guide/4e.tcp-acceptor.adoc:75`, `4.guide/4m.error-handling.adoc:23` → passthrough `` `+[[...]]+` `` | +| B5 | 3 | `snippets/3d_tls_context.cpp:47`, `snippets/4l_tls.cpp:53`, `snippets/4g_composed_operations.cpp:74` → replace the using-directive with the `corosio`/`capy` namespace alias and qualify the names. The seven `using namespace std::chrono_literals;` instances are **not** B5 violations and stay. | + +**Exit**: A1, A6, ANCHOR at zero; promote each to a strict gate spec entry. + +### Phase 2 — B2/B3 retagging + +All 39 findings, classified by inspection (full list in appendix A). None needs a +new compiled example; all need correct tags. + +| Kind | Count | Fix | +|---|---|---| +| Bare listings holding hand-drawn figures and notation — dotted quads, IPv6 forms, URL anatomy, connection four-tuples, ASCII handshake ladders, the sliding-window diagram, bandwidth-delay arithmetic | 19 | add `[role=figure]` | +| `[source,bash]` / `[source]` blocks holding terminal transcripts and literal output — `$ ./echo_server 8080 10`, telnet sessions, throughput tables | 18 | **drop the `[source,*]` attribute line**, then add `[role=output]` | +| `[source,cmake]` build snippets in `4.guide/4l.tls.adoc` | 2 | keep `[source,cmake]`, add `role=external` | + +The middle row is the one to get right. B3 is explicit that `role=output` exempts +**only a bare listing, never a `[source,*]` block** — a block that actually +compiles is tagged `[source,*]` and cleared through `pseudocode`/`external`, full +stop, however output-shaped it looks. Adding `role=output` to a surviving +`[source,bash]` line would be the laundering case B3 exists to forbid, and the +bite-test in section 5 covers it. + +`doc-lint.mjs`'s SHAPE heuristic runs over every block phase 2 exempts and is +advisory. Read its output at the end of the phase: a `role=figure`/`role=output` +tag is a permanent B2 exemption, so a wrong tag is a permanent blind spot. + +**Exit**: B2 at zero, SHAPE reviewed and clean; promote B2 to a strict gate entry. + +### Phase 3 — B1 `cpp:` conversion + +214 backtick spans naming 59 distinct `boost::corosio` public symbols become +`cpp:boost::corosio::X[]`. The remaining 521 spans stay backticks: 68 `capy::`/ +`cond::` (no reference pages exist for them, per D-3), 122 `std::` names and +uppercase macros, and 331 non-symbol words. + +Largest by occurrence: `io_context` (38), `tcp_server` (13), `tcp_socket` (10), +`write_some` (8), `read_some` (8), `tls_context` (6), `tcp_acceptor` (6), +`stream_file` (6). + +Generate candidates by intersecting backtick spans against the MrDocs tag file +rather than against a hand-written symbol list, so the set is machine-decidable +and re-runnable. Then review by hand, because a member-function span is ambiguous: +`` `recv` `` (11 occurrences), `` `send` `` (9), `` `send_to` ``, `` `recv_from` ``, +`` `set_option` ``, `` `shutdown` ``, `` `peer` `` each need the owning class +resolved from context before the macro can be written, and several are used as +plain English verbs in the networking tutorial rather than as symbol references. +Convert the unambiguous class and free-function names mechanically; take the +member names page by page. + +**Exit** (as delivered): every backticked span naming a Corosio public entity is a +`cpp:` macro (186), plus 30 bare names that were unambiguous — 28 bold type names in +feature lists and 2 in a thread-safety table. `accept.txt` absorbs the genuine prose +words. Verified against the built site: zero unresolved-reference warnings, zero +literal `cpp:` strings in the HTML. + +**Docstring half (added after the phases were first declared done).** B1 was +originally scoped to `doc/modules/ROOT/pages` only, which left 214 bare Corosio +identifiers in header docstrings — the same defect class, in the published +reference. The remedy there is a code span, not a `cpp:` macro: the house convention +runs 1179 backticks to 197 `@ref`, and `@ref` cannot resolve a name with no Corosio +reference page (`operation_canceled` is a `std::errc`). 93 were backticked in the +public headers; `detail/` was left alone because `extract-docstrings.mjs` excludes it +as implementation-defined. + +Most of the backlog turned out not to be a docstring defect at all. Three extractor +fixes — `@ref`/`@p`/`@c` and `@see` targets re-emitted as code spans, `@par !example` +directives dropped — removed findings that could never have been fixed in a header, +because backticking them there would break a link or a parameter binding. Corpus +420 → 62, `Vale.Spelling` 389 → 24. Details in `doc/lint/README.md`. + +**Residual, deliberate.** About 26 bare type names stay unlinked. Most are the concept +used as an English noun — "binds to a local endpoint", "the resolver may return +multiple endpoints", "both sides read and write" — where a reference link would be +wrong rather than merely noisy. The rest sit in headings, and Capy keeps `cpp:` macros +out of headings: a linked heading changes the ToC text and the anchor. A further 7 are +possessives of acronyms (`IP's`, `UDP's`, `URL's`) and one is a variable name (`ec`) +that wants a code span. `Vale.Spelling` is not a gated check, so all of these are +reported without blocking. Do not silence them by adding symbol names to `accept.txt` +— that file's own rule forbids it, and it would hide genuine defects elsewhere. + +### Phase 4 — C11 docstring commands + +41 `@par Preconditions` become `@pre`, across 17 headers. Heaviest: +`detail/timer.hpp` (7), `io_context.hpp` (5), `tcp_acceptor.hpp` (4), +`{wolfssl,openssl,tls}_stream.hpp` (3 each), `tcp_socket.hpp` (3). The two forms +do not survive extraction in the same shape — `@par Preconditions` re-emits as a +bare "Preconditions" prose line, `@pre` re-emits with no label — so confirm the +rendered reference still reads correctly after the change, not just that the +docstring compiles. + +**Exit**: `grep -r '@par Preconditions' include/` is empty. + +### Phase 5 — C9 fluff and C10 terminology + +9 fluff hits — `simply` ×5, `utilize` ×2 and `essentially` ×1 on pages, `note that` +×1 in a docstring — and 18 terminology hits, 10 on pages and 8 in docstrings, +**every one** of them `launch`/`spawn` where C.1 requires **start**: `3a.echo-server.adoc` (2), `3e.hash-server.adoc` (3), +`4b.concurrent-programming.adoc` (3), `2i.tcp-connections.adoc`, +`4c.io-context.adoc`, plus 8 in docstrings. + +Check each `scheduler` occurrence (10 on pages) individually: C.1 approves the +word only in its P2300 sense and never as a synonym for **executor**. + +**Exit**: `Corosio.NoFluff` and `Corosio.Terminology` at zero on both corpora; +promote both to strict gate entries. + +### Phase 6 — C4 present simple + +253 hits: 186 in docstrings, 67 on pages. Docstrings first — they are the larger +share, and reference briefs are where C4 is enforced hard. Pages after, and within +pages `2.networking-tutorial` holds 41 of the 67. + +C4 has no advisory tier; every hit is in scope. This is the first phase that +changes prose meaning, so it wants review in reviewable slices rather than one +sweep. + +**Exit**: `Corosio.SimpleTense` and `Google.Will` at zero on both corpora; promote +to strict gate entries. + +### Phase 7 — C2 sentence length + +Hard slice after the phase-0 `advisoryDirs` change: 71 page hits + 61 docstring +hits. `2.networking-tutorial`'s 72 are advisory and are not a backlog. + +C2's authority is `lint/sentence-length.mjs`, not Vale. +`Corosio.SentenceLength` is `level: suggestion` and enforces nothing; do not read +`vale`'s exit code for C2. + +**Exit**: `sentence_length`'s `hard` count at zero; promote `^C2:` to a strict gate +entry. `advisory-C2` stays ungated by design. + +### Phase 8 — B4 briefs + +Roughly 30 identity-shaped briefs, mostly class briefs opening "A…" / "An…" / +"The…". The guide's B4 entry carries an explicit reversal: two prior Capy audits +read identity-shaped class briefs as house convention and dropped ~230 findings +each on that reading, and the maintainer ruled B4 binds them anyway. Corosio's +guide must carry that ruling, and this phase applies it: a brief says what the +entity *does*. + +B4 is Review tier — no script decides it. It is a PR-checklist item and a +reviewed pass, not a gate. + +**Exit**: every public class and function brief describes behavior. Reviewed, not +measured. + +### Phase 9 — close the gate + +Flip the gate step to `--strict`, promote `selftest.mjs` to +`continue-on-error: false`, reseed once more, and confirm the residual baseline +holds only the documented carve-outs: `advisory-C2`, D2's `*.intro.adoc` findings, +and `2.networking-tutorial`'s primer exemption. + +**Exit**: a planted violation of each strict-gated rule fails the job. Per F4, the +green run before that test is not the evidence — the failure is. + +## 7. Out of scope, and what that costs + +**Capy's enforcement gaps.** Only two steps in Capy's docs.yml can fail the job: +the reference-examples grep and `check-include-tags.mjs`. Its Vale checks, +`doc-lint.mjs`, `mrdocs-warnings.mjs`, `selftest.mjs`, and the gate itself are all +advisory — the gate runs without `--strict` by deliberate maintainer decision. The +follow-ups this plan does not own: remove `run-a11y.mjs` and `pa11y-ci` from Capy +per section 4.4's reasoning, and restore `--strict` plus a blocking `selftest.mjs` +there. Until then Capy and Corosio diverge, and Corosio is the stricter of the two +from phase 9 onward. + +**The report-only window.** D-5 keeps the gate advisory through phases 1–8. A new +violation introduced in that window is reported and annotated but lands, and stays +until a reseed grandfathers it. The mitigation is the per-phase reseed cadence in +section 6, which keeps the window short per rule rather than open for the whole +effort. + +**Deferred rules.** E3 (reference grouping, operators documented with their types, +async distinguishable from sync) and E4 are Review tier and are not scheduled +here. D1, D3, D4, D5, A3, A4, A5, C7, C8 are likewise Review tier; they enter via +the F3 PR checklist, which Corosio's `STYLE_GUIDE.md` will carry, rather than as +phases. + +## Appendix A — B2 block classification + +`[role=figure]` (19): + +``` +2.networking-tutorial/2b.internet-addresses.adoc 18, 36, 68, 74 +2.networking-tutorial/2d.urls.adoc 18, 42, 93 +2.networking-tutorial/2e.client-server-model.adoc 40, 46, 52 +2.networking-tutorial/2i.tcp-connections.adoc 22, 55 +2.networking-tutorial/2j.tcp-data-flow.adoc 37 +2.networking-tutorial/2k.tcp-reliability.adoc 48 +2.networking-tutorial/2l.tcp-performance.adoc 36 +4.guide/4a.tcp-networking.adoc 151, 305, 360 +4.guide/4b.concurrent-programming.adoc 303 +``` + +Drop `[source,*]`, add `[role=output]` (18): + +``` +3.tutorials/3a.echo-server.adoc 147, 155 +3.tutorials/3b.http-client.adoc 131, 140 +3.tutorials/3c.dns-lookup.adoc 114, 124 +3.tutorials/3e.hash-server.adoc 160, 168 +3.tutorials/3f.reconnect.adoc 167, 175, 183, 197 +quick-start.adoc 80 +benchmark-report.adoc 281, 843, 954, 1039, 1117 +``` + +Keep `[source,cmake]`, add `role=external` (2): + +``` +4.guide/4l.tls.adoc 525, 533 +``` + +Line numbers are from the pre-remediation tree and shift as phases land; re-run +`node lint/doc-lint.mjs` for current positions rather than trusting these. diff --git a/doc/lint/README.md b/doc/lint/README.md new file mode 100644 index 000000000..5ecfd6b3f --- /dev/null +++ b/doc/lint/README.md @@ -0,0 +1,457 @@ + +# `doc/lint` — the documentation-quality toolkit + +These scripts implement the enforcement tiers in `doc/STYLE_GUIDE.md` Part F.0. They run in +the **Documentation** workflow (`.github/workflows/docs.yml`), in the `antora` job, after the +site build. Node built-ins only, no dependencies of their own. + +They were ported from Capy's `doc/lint`, which remains the origin for the shared design +rationale. What is Corosio-specific is recorded here. + +| Script | What it checks | +|---|---| +| `doc-lint.mjs` | Structural AsciiDoc/nav rules (A1, A6, B2, ANCHOR, SHAPE, D2). JSON on stdout. | +| `extract-docstrings.mjs` | Extracts header docstrings into `.docstrings/*.adoc` so Vale can lint them. Doxygen targets (`@ref`/`@p`/`@c`/`@see`) are re-emitted as code spans, and `@par !example` directives are dropped — see below. | +| `sentence-length.mjs` | **The authority for C2** (no sentence over 25 words), over both corpora. | +| `check-include-tags.mjs` | Every `include::example$…[tag=…]` resolves to a live tag in a compiled source. | +| `mrdocs-warnings.mjs` | Runs MrDocs directly and parses its reference-surface warnings. | +| `selftest.mjs` | Mutates the linters and asserts they notice. Exit 1 on regression. | +| `baseline.mjs` | Runs every check and snapshots their findings to `baseline.json`. | +| `check-no-new-violations.mjs` | **The gate.** Diffs a fresh run against `baseline.json`. | +| `baseline-diff.mjs` | Explains what replacing `baseline.json` with a candidate would change. | + +## Running it locally + +Vale must run from `doc/`, with `node_modules/.bin` on `PATH`: it shells out to +`asciidoctor` to parse AsciiDoc, and without it Vale exits 2 having printed **nothing**, +which greps identical to a clean run. + +`baseline.mjs` — and so `check-no-new-violations.mjs`, which spawns it — **appends** that +directory to `PATH` itself, so the wrapper scripts need no `export`. Appended, not +prepended, on purpose: CI apt-installs the Ruby asciidoctor, the committed baseline is +authored against it, and the two produce different HTML and so different findings. A stub +`asciidoctor` placed first on `PATH` still wins, which is how that is tested. + +The `export` below is only for invoking `vale` by hand. + +```sh +cd doc +export PATH="$PWD/node_modules/.bin:$PATH" +vale --output=JSON modules +node lint/extract-docstrings.mjs && vale --output=JSON lint/.docstrings +node lint/doc-lint.mjs +node lint/sentence-length.mjs +node lint/selftest.mjs + +# mrdocs-warnings needs the MrDocs the site build used. build_antora.sh exports +# MRDOCS_ROOT; point it at that install rather than relying on the cache scan. +MRDOCS_ROOT="$PWD/build/mrdocs/MrDocs-0.8.0-Linux" node lint/mrdocs-warnings.mjs +``` + +`mrdocs-warnings.mjs` applies **no** version check to a binary under `MRDOCS_ROOT`, on +purpose: the `develop-release` asset is a rolling build whose reported version has already +changed scheme once (`0.8.0+` locally, `2026.9.5` in CI, days apart, same asset). What +the check needs is the MrDocs that produced the rendered reference, which is what +`MRDOCS_ROOT` names. The `PINNED_VERSION` constant is only a tiebreaker for the fallback +cache scan. + +A `0` in the output is not evidence of a clean run by itself — it is at least as often +evidence the run never happened. Confirm a non-zero total somewhere before trusting a zero. + +The same trap has a second mouth, and it bit: `check-no-new-violations.mjs` reports a +missing asciidoctor as `SKIPPED: vale_adoc` / `SKIPPED: vale_docstrings` and **still exits +0**, because a skipped check has nothing to compare. Read the SKIPPED lines before reading +the exit code. The `PATH` fallback above closes this for the wrapper scripts. + +Vale does not enforce C2 either way; its authority is `sentence-length.mjs`. + +## How the gate works + +`baseline.json` is a snapshot of every finding that already existed when it was taken. +`check-no-new-violations.mjs` runs a fresh scan and reports only fingerprints **not** in the +snapshot. Everything in the snapshot is grandfathered. + +Which findings *block* is the `--gate :` spec in the workflow. Each regex is +tested against the **whole** fingerprint. + +| Check | Fingerprint | Rule position | +|---|---|---| +| `doc_lint` | `rule:file:#N:message` | **head** | +| `sentence_length` | `C2:file:#N:message`, `advisory-C2:…`, `BACKTICK:…` | **head** | +| `vale_adoc`, `vale_docstrings` | `file:#N:Check.Name` | **tail** | +| `mrdocs_warnings` | `file:#N:message` | — | + +`#N` is the Nth occurrence of that (head, tail) pair, **not a line number**, so inserting +text above a finding does not rename it. + +**A local Vale run can under-report, so it is not sufficient evidence.** Local Vale +missed a live `Corosio.SimpleTense` finding on `benchmark-report.adoc` that CI caught +on the same commit — a long single-line paragraph that the Ruby asciidoctor in CI +extracts as prose and the local JS build does not. Local reported 131 page findings +against CI's 66 and still missed that one. For the gated rules, a raw `grep` over the +sources is a useful independent check precisely because it has no extraction step: +`grep -rn '\bwill\b|\bhas been\b|\bhave been\b'` found eight sites the rule could not +see at all. + +**A Vale gate spec must never carry a leading `^`.** The check name is at the tail, so +`^Corosio\.PartHeadings$` matches nothing and the comparator then reports +`gated: true, gatedNew: 0` at **exit 0** — a gate that announces it is gating while checking +nothing. Measured on this corpus; see the bite-test log below. + +**Never hand-edit `baseline.json`.** Reseed via the `workflow_dispatch` steps in the +workflow, never locally: a local run differs from a CI run and would grandfather hundreds of +local-vs-CI drift fingerprints. + +> **A false positive that came and went.** An earlier baseline carried +> `benchmark-report.adoc:#1:Google.OxfordComma` on the sentence "…comparable to its +> unidirectional throughput, suggesting serialization between the read and write +> paths." That is not a list needing an Oxford comma — "the read and write paths" is +> a compound noun phrase and the comma opens a participial clause, which slips past the +> rule's own guard against clause-introducers because "suggesting" is not in its +> exemption list. Grandfathering a rule false positive is what the baseline is for; +> rewriting sound prose to appease a heuristic would be worse. +> +> It surfaced only after the vocabulary additions removed a `Vale.Spelling` alert that had +> been masking it at the same position, vanished in the 2026-09-09T18:40Z reseed, came +> **back** in the 2026-09-09T20:27Z one, and returned again in the 2026-09-10T17:09Z reseed +> as its only added fingerprint — every time with that page untouched. It is the +> position-resolution artifact the `.vale.ini` comment describes, and it flaps. Treat any +> future appearance the same way: it is a false positive on a participial clause, it +> grandfathers, and the prose is left alone. +> +> `baseline.json` is **CI-authored** (`workflow_dispatch`, 2026-09-25T20:36Z) and is the +> reference point the strict gate compares against. Counts at the original local seed and in +> the installed baseline: +> +> | Check | Local seed | CI baseline | +> |---|---|---| +> | `vale_adoc` | 466 | 55 | +> | `vale_docstrings` | 785 | 51 | +> | `sentence_length` | 204 | 72 (hard 1, advisory 71) | +> | `doc_lint` | 93 | 3 (all D2, the documented carve-out) | +> | `mrdocs_warnings` | 460 | **0** | +> +> Keep this table in step with the file. It is what an operator weighs a reseed candidate +> against, and it has drifted from `baseline.json` once already. +> +> Eight reseeds were needed. The first was refused because the MrDocs version pin made +> `mrdocs_warnings` report SKIPPED, which would have wiped a 460-fingerprint gated backlog. +> The second was refused for a **real** gated regression a local Vale run could not see. The +> third retired 365 and grandfathered the one false positive above. The fourth, after the +> rebase onto develop, absorbed the `io_uring`->`uring` rename churn and retired the 19 B4 +> parameter mismatches; its one gated addition was the same rename churn +> (`io_uring_t::construct` -> `uring_t::construct`) and was fixed rather than grandfathered, +> so that baseline was briefly stale-high by 4 in `mrdocs_warnings`. A fifth reseed retired +> 71 more (the parameter and return-value documentation pass) and grandfathered none. +> +> A **sixth** reseed, after the awaitable encapsulation and the special-member +> documentation pass, retired the last 44 `mrdocs_warnings` and grandfathered one +> fingerprint: `benchmark-report.adoc:#1:Google.OxfordComma`, the participial-clause false +> positive above, flapping back in for the fourth time. Nothing gated was added. **The +> reference surface is now clean in CI: `mrdocs_warnings` is 0.** +> +> That zero had to be authorised. `baseline-diff.mjs` refuses a candidate in which a gated +> check drops to zero, because a crashed check produces exactly the same report — the +> refusal is the whole point, and it is what caught the very first reseed. The evidence +> that this zero was real: the candidate recorded `skipped: false`, and both of +> `mrdocsFingerprints()`'s failure paths (a non-zero exit, and the `error` key +> `mrdocs-warnings.mjs` emits when it cannot find the binary) set `skipped: true`. So the +> script ran, located MrDocs, and returned an empty `findings`. The 44 retired +> fingerprints also match the two commits exactly: `dispatch` x18, the awaitable +> constructors, `reset_peer_impl`, and the two `native_tcp`/`native_udp` broken refs. +> Confirmed with `--allow-emptied mrdocs_warnings`, which the workflow now exposes as a +> `workflow_dispatch` input so the acknowledgement is recorded in the run log rather than +> applied by hand. +> +> A **seventh** reseed, after the documentation audit's repair passes, retired 21 fingerprints and grandfathered 5, none gated. Nineteen of the +> retirements were `Vale.Spelling` hits on possessives -- `backend's`, `scheduler's`, +> `IP's`, `UDP's`, `URL's` -- that the accept vocabulary now covers, written +> `(?i)[’']s` so they survive a sentence-initial capital and the curly apostrophe +> asciidoctor substitutes. Of the five additions, two were fixed rather than grandfathered +> (bare parameter names in a `test/mocket.hpp` `@throws` clause, now `@p` references); the +> remaining three are an `advisory-C2` on a page that is advisory-only by decision, and two +> `Google.Colons` on Doxygen error tables where capitalising the definition is correct and +> 14 instances of the identical shape were already grandfathered. +> +> An **eighth** reseed, after the rebase onto the develop that reshaped the address, +> endpoint, socket-option and resolver surface, is what is installed now. It retired 8 +> fingerprints and grandfathered 9, none gated. Two retirements are `Google.Colons` on +> `local_datagram.hpp` and `local_stream.hpp`, headers develop deleted; the other six are +> `Vale.Spelling` hits on prose that the C2 sentence splits rewrote or the vocabulary now +> covers, `unscoped` among them. Eight of the nine additions are one shape: +> `Google.LyHyphens` on the new `family-*` compounds. The rule targets adverb hyphenation +> and matches `family-neutral` and its siblings only because "family" ends in "ly", so the +> hyphens are correct and the rule stays un-demoted -- the ruling and its reasoning are +> recorded in `.vale.ini` beside the demotion list. The ninth is a `Google.Colons` on +> `IPv4` opening a clause after a colon, the same acronym-after-colon shape already +> grandfathered elsewhere; it arrived with a C2 sentence split in this branch. +> +> This reseed is also the first with `Packages` pinned to a release URL rather than the +> bare `Google`, so the corpus no longer moves underneath the baseline at `vale sync` time. +> The pin was verified byte-identical to the pack already installed, so it froze the +> present rather than stepping to a new version. +> +> Note the local number is **8**, not 0: those are the unattributed `` findings below, +> and this reseed proves they never reach CI — the committed baseline has never contained +> one, in any of the seven. `doc_lint` and +> `sentence_length` measured **identically** in both environments (3 and 72), which is what +> makes them safe to gate; every other difference above is environment drift. + +### Corosio's posture: everything blocks except the reference surface + +`selftest.mjs` is **blocking**. It has no baseline and no environment dependence, so a red +run there means a linter regressed. + +The gate runs as two steps: + +| Step | Checks | Posture | +|---|---|---| +| `Lint: gate (BLOCKING)` | `doc_lint` (A1/A6/B2/D2/ANCHOR), `sentence_length` (C2), and the C4/C9/C10/A7 wording rules on both corpora | **`--strict`** | +| `Lint: gate (reference surface, reporting)` | `mrdocs_warnings` | reports, does not fail | + +`baseline.json` is now authored by the Documentation job itself, so a strict comparison is +CI-against-CI and carries no environment drift. The wording rules additionally have an +**empty gated subset** — zero baselined C4/C9/C10/A7 fingerprints on either corpus — so +any match at all is a real regression. + +`mrdocs_warnings` stays reporting on purpose. The baseline is zero, so its `.*` spec would +gate every warning that ever appears, and MrDocs is a rolling `develop-release` build whose +output demonstrably moves: the same asset +reported **460** warnings under `0.8.0` and **352** under `2026.9.5`, days apart, on an +unchanged tree. Gating `.*` against a tool that rewrites its own output would fail the job +for upstream reasons unrelated to Corosio's documentation — the same mistake as the version +pin that skipped this check on the first reseed. Promote it only alongside a pinned MrDocs. + +> **A local `--strict` run of the full gate will fail, and that is expected.** The baseline +> is CI-authored; a developer machine produces different `vale_*` and `mrdocs_warnings` +> fingerprints (measured: `vale_adoc` 132 locally against 66 in CI, from the Ruby-vs-JS +> asciidoctor Vale shells out to; `mrdocs_warnings` 460 against 352). None of that drift +> touches a gated rule, so the **gated** slice does pass locally: +> +> ```sh +> node lint/check-no-new-violations.mjs --strict \ +> --gate 'doc_lint:^(A1|A6|B2|D2|ANCHOR):' --gate 'sentence_length:^C2:' \ +> --gate 'vale_adoc:Corosio\.PartHeadings$' \ +> --gate 'vale_adoc:(Corosio\.SimpleTense|Corosio\.NoFluff|Corosio\.Terminology)$' \ +> --gate 'vale_docstrings:(Corosio\.SimpleTense|Corosio\.NoFluff|Corosio\.Terminology)$' +> ``` + +## Linting Doxygen prose + +Vale is a plain-text speller; Doxygen prose is not plain text. Three extractor +behaviours exist because of that, and each one removed a class of finding that could +never have been fixed in a header: + +* **`@ref X`, `@p X`, `@c X` re-emit as `` `X` ``, not as bare `X`.** All three render + as a link or as monospace in the real reference, so bare text was a lie about the + source *and* a guaranteed `Vale.Spelling` hit. Backticking them in the header + instead would have broken the link or the parameter binding. +* **`@see A, B, C` backticks each identifier-shaped item.** Doxygen auto-links a + `@see` list; `@see epoll_t, select_t, kqueue_t, iocp_t` alone accounted for 21 + findings. +* **`@par !example ` is dropped.** The id names a compiled source under + `test/doc/reference` for the reference-snippets extension. It is a machine + directive and never reaches a reader, so ids like `connect_and_read` were + permanent unfixable findings. + +Together with backticking 93 genuinely bare identifiers in the published headers, +this took the docstring corpus from **420** findings to **62**, and `Vale.Spelling` +from **389** to **24**. Trailing punctuation stays outside the span: `@ref io_stream,` +becomes `` `io_stream` ``, not `` `io_stream,` ``. + +`detail/` headers are deliberately untouched by the B1 pass. `extract-docstrings.mjs` +excludes them (and strips `namespace detail` blocks) because `mrdocs.yml` marks them +implementation-defined, so no rule governs their prose and B1 is a reference rule. + +**Residual, 24 findings and non-gated.** Eight are `backend's`: C.1 lists bare +"backend" as an Avoid term in favour of **I/O backend**, so that one is a real +terminology item rather than noise, and silencing it in the vocabulary would hide the +signal. The rest is a thin tail of single occurrences (`await_suspend`, `key_type`, +`worker_base`, a few `native_*` names) sitting in `@param`/`@return` bodies. + +## Corosio-specific configuration + +Three values differ from Capy's, and each is a judgment rather than a rename. + +**`doc-lint.mjs`'s `CONCEPT_DIRS` (D2)** is `3.tutorials`, `4.guide`, `5.testing`. +`2.networking-tutorial` is deliberately absent: it teaches IP, TCP and UDP theory from first +principles and introduces no Corosio type, so there is no type an example could show. It is +background material, the same carve-out Capy's `3a`–`3d` primer carries. Including it would +file roughly 13 findings that no example can fix, and the fix for a D2 finding is never a +decorative `include::example$`. + +With that scope, D2 reports **3** findings, all `*.intro.adoc` chapter landing pages +(`3.intro`, `4.intro`, `5.intro`). They introduce no type, so they keep failing D2 for the +same documented reason Capy's landing pages do. That count is an intentional consequence of +D2's scope, not a backlog to chase to zero. + +**`sentence-length.mjs`'s `ADVISORY_DIRS` (C2)** is +`modules/ROOT/pages/2.networking-tutorial/`. That chapter is essay-style prose teaching +protocol theory, exactly the material C2 relaxes for, and at the port it held **72 of the 143** +page-level hits — gating it would make the hard slice mostly essays. Docstrings are always +hard, whatever directory they came from. Measured split at the port: `hard: 132` +(71 pages + 61 docstrings), `advisory: 72`. + +**There is no accessibility check.** E4 is Review tier, and Capy's `run-a11y.mjs` cannot fail +CI there (`continue-on-error: true`, in no gate spec). A check that cannot fail earns nothing, +so neither it nor `pa11y-ci` was ported. `baseline.mjs` and `baseline-diff.mjs` had their +`a11y` branches removed rather than left dangling. + +## MrDocs parses one synthetic TU, and that is what "unreachable header" means + +`corosio_setup_mrdocs()` in `cmake/CorosioBuild.cmake` writes a single translation unit +and MrDocs documents whatever that TU pulls in: + +```cpp +#include +#include +#include +#include +``` + +A public header absent from that TU produces no reference pages at all, however +`input:` and `include-symbols:` are configured. That is what caused +`tcp.hpp`'s `@ref native_tcp` and `udp.hpp`'s `@ref native_udp` to fail: those two +headers are deliberately not part of `native/native.hpp` — they include the platform +socket headers so their members can be `constexpr`, and the aggregate must not push +`` onto every consumer. The last two lines above are the fix: the +documentation TU includes them directly, widening what MrDocs parses without widening +the public aggregate. + +A `@ref` that fails does not render as a broken link. It renders as **nothing** — the +symbol name disappears from the page, leaving prose that says "use" and then omits what +to use. Check `Failed to resolve reference` findings against the rendered HTML, not just +the warning count. + +Note `@see` lists render as plain text in this generator regardless: on `tcp.html`, +`tcp_socket` and `tcp_acceptor` are unlinked there too, and both have pages. That is +generator behaviour, not a missing symbol. + +## Awaitable protocol members: encapsulated, not excluded + +Corosio's `mrdocs_warnings` once carried 42 "function is undocumented" findings on the +awaitables — `dispatch` (18), the awaitable constructors (8), `await_ready` / +`await_suspend` / `await_resume`, and a few others — plus 61 on their data members, which +`mrdocs.yml` silenced with 31 lines of `exclude-symbols`. + +**Capy does not document its equivalents**, and the reason is scope, not diligence. +Capy's awaiters are nested inside promise types at non-public scope +(`quitter_return_base::promise_type::awaiter`), so `extract-private`'s defaults never +surface them. Where one *does* surface, Capy leaves it undocumented and grandfathers it: +`task.hpp:#1:await_resume: function is undocumented` is in Capy's own baseline. + +Corosio had no such excuse: its awaitables were `struct`s whose captured arguments, +out-parameters, constructor, and CRTP `dispatch` hook were all public. That was an +unintended API commitment, so the answer was encapsulation rather than a documentation +filter. Only `await_ready`, `await_suspend`, and `await_resume` are the interface, and +the compiler is what calls them: + +- The 22 awaitables deriving from a `detail::*_op_base` inherit all three through a + public base, so nothing declared in the derived struct needs to be public. +- The seven that implement `await_*` themselves — `random_access_file`'s two, the four + acceptor ones, and `io_signal_set::wait_awaitable` — keep those public and privatise + the rest. +- Each takes `friend ;`. The initiator constructs the awaitable and, on + those seven, pre-sets `ec_` when the object is closed. Friending the CRTP base exposes + nothing new: the public header already names it in the base clause, and the base + already declares `friend Derived;` in the other direction. + +`exclude-symbols` is down to the two `protected:` `sched_` entries. `delay_awaitable` +and `clock_delay_awaitable` keep public constructors — they sit at namespace scope and +`test/unit/delay.cpp` builds them directly — and are documented instead. + +The native layer needed nothing: `native_tcp_socket` and its siblings declare their +awaitables before any access specifier in a `class`, so they were already private, which +is why MrDocs never extracted them. + +## A `//` between the `///` and the declaration hides the docstring + +`delay_awaitable`'s and `clock_delay_awaitable`'s move constructors read as undocumented +while carrying a perfectly good `///` brief, because a plain `//` implementation note sat +between the brief and the declaration: + +```cpp +/// Construct by transferring state from `other`. +// Only moved before await_suspend; wait_ is engaged after. +delay_awaitable(delay_awaitable&&) = default; // <-- undocumented +``` + +MrDocs attaches a docstring only to the declaration that immediately follows it. Put the +non-doc comment **above** the `///` and both survive. Worth knowing before assuming a +`warn-if-undocumented` finding means no one wrote the docs. + +## The 8 `unsupported HTML tag ` warnings are not ours + +`mrdocs_warnings` carries eight `unsupported HTML tag ` findings with **no file +attribution** (`file: null`, fingerprinted `?:#N:`). They are **not fixable in Corosio**, +and the trail is worth recording because it is not obvious: + +* `grep -rn '' include/ doc/ test/` returns **zero**. Capy returns zero too. +* Boost.Asio's headers are full of `` (381 occurrences), which makes it the obvious + suspect — and it is wrong. `capy/buffers.hpp` only forward-declares + `namespace asio`; a preprocessor run (`clang++ -H`) over every public Corosio header + confirms `boost/asio/buffer.hpp` is **never reached**. +* Preprocessing all 59 public headers yields 547 reachable files. Exactly one contains + ``: **libstdc++'s `bits/alloc_traits.h`**, which carries 10 of them in its own + Doxygen comments (` pointer_traits::rebind `). + +MrDocs emits the warnings while extracting declarations, with no location, because they +come from the standard library implementation it parses. Nothing in this repository can +change them. They are also **environment-dependent**: a different libstdc++ version, or +libc++, produces a different count, which is part of why local and CI `mrdocs_warnings` +totals differ and why the `?:#N:` fingerprints reindex on any change. + +`mrdocs.yml` has `use-system-libc` and `use-system-stdlib` commented out. Turning them on +would change which standard library MrDocs parses and might retire these eight, but it +would also change the whole reference build; they are off deliberately and this is not a +reason to flip them. + +Treat these the way Part E4 treats generator and theme output: not a defect in authored +content. **Do not spend time on them again.** + +**Update, and it settles the point:** the 2026-09-09T17:25 reseed came back with **zero** +`` findings, where the reseed three hours earlier had eight. Nothing in this repository +changed between them. A rolling MrDocs build or a runner image with a different libstdc++ is +enough to make all eight appear or vanish, which is exactly the environment-dependence +described above. If they reappear, they are still not ours. + +## F4 bite-test log + +Style-guide Part F4: **a check is not adopted until a planted violation has failed it.** A +green run is not evidence. Every check below was verified by planting a violation of that +exact rule and confirming the failure, at the port commit. + +| Check | Planted violation | Result | +|---|---|---| +| A1 | `:page-mode: concept` — an invalid *value*, not a missing attribute | fires: `invalid :page-mode: value 'concept'` | +| A6 | measured against the real nav | fires: `quick-start at top-level position 8, must be <= 3` | +| A7 / `Corosio.PartHeadings` | `== Part 3: The Vacuous Rule` | fires. This is the rule that matched **zero** inputs in Capy until `b54fe6c8`, because Vale's heading scope strips the `==` markers; the fixed pattern anchors on heading text and was confirmed here rather than assumed | +| B2, bare listing | code in a bare `----` block | fires | +| B2, long delimiter | code in a `-----` (five-dash) block | fires — a fixed 4-character match would have missed it | +| B2, laundering | `[source,cpp]` + `[role=output]` over real code | fires: `role=output` does **not** exempt a `[source,*]` block, which is B3's load-bearing boundary | +| ANCHOR | `` `[[nodiscard]]` `` in prose | fires | +| C2 routing | a 30-word sentence in `4.guide/`, in `2.networking-tutorial/`, and in a docstring | routes correctly: `C2` hard, `advisory-C2`, `C2` hard | +| C4/C9/C10 | "The reactor will simply spawn the coroutine." | all four fire: `Corosio.SimpleTense`, `Google.Will`, `Corosio.NoFluff`, `Corosio.Terminology` | +| gate, `--strict` | A7 + C4 + C9 + C10 + B2 planted in a live page | **exit 1**, all 5 named as blocking, 2 non-gated new findings reported separately | +| gate, report-only | the same plant, no `--strict` | **exit 0** — confirms the current posture reports without failing, and that the phase-9 flip is the only change needed | +| fail-closed | `extract-docstrings.mjs` made to exit 3 | **exit 1**: `vale_docstrings` and `sentence_length` marked SKIPPED and, being gated, fail the gate. Zero findings did not read as success | +| tail-anchor trap | `--gate 'vale_adoc:^Corosio\.PartHeadings$'` against a planted A7 | **exit 0 while gating nothing** — the trap is real on this corpus. The correct tail-only spec exits 1 on the same input | +| strict gate, C4/C9/C10 on pages | "The acceptor will simply spawn a coroutine." | **exit 1**, naming `Corosio.SimpleTense`, `Corosio.NoFluff` and `Corosio.Terminology` | +| strict gate, C4/C9/C10 on docstrings | the same sentence spliced into `tcp_socket`'s brief | **exit 1**, naming all three on `vale_docstrings` | +| strict gate, A7 | a `== Part 9:` heading | **exit 1**, naming `Corosio.PartHeadings` | +| strict gate, B2 | a bare `----` listing holding code, against the strict step's real spec | **exit 1** | +| strict gate, C2 | a 28-word sentence on a hard-slice page | **exit 1** | +| strict gate, clean | the same spec against an unmodified tree | exit 0, before and after both plants | +| MrDocs pin | the CI reseed itself, against a binary reporting `2026.9.5` | **caught by the safety net**: the pin rejected the only candidate, `mrdocs_warnings` reported SKIPPED, and `baseline-diff.mjs` refused the candidate rather than let it wipe a 460-fingerprint gated backlog. Re-tested after the fix: with `MRDOCS_ROOT` set, `MRDOCS_VERSION=9999.1.2` is ignored and the check reports its 460 warnings; with `MRDOCS_ROOT` unset the fallback pin still resolves; with `MRDOCS_ROOT` naming an unrunnable binary the check errors instead of silently reporting zero | +| reseed gate-spec extractor | run against the two-step gate | recovers exactly the 6 live specs. It first recovered **8** — the awk program contains the string it searches for, so it matched its own source line and captured the `grep`/`sed` lines below as specs, one of them the invalid regex `[^`. The pattern is anchored to `^ *- name:` for that reason, and the toggle is `inblock = 0` rather than `exit` so the second gate step is not silently dropped | + +`selftest.mjs` automates the subset it can (37 assertions at the port) and is the standing +guard afterwards. It is not a substitute for the table above: it passed *before* several of +these were confirmed, which is exactly F4's point. diff --git a/doc/lint/baseline-diff.mjs b/doc/lint/baseline-diff.mjs new file mode 100644 index 000000000..c06aeb627 --- /dev/null +++ b/doc/lint/baseline-diff.mjs @@ -0,0 +1,279 @@ +#!/usr/bin/env node +// +// Copyright (c) 2026 Michael Vandeberg +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/corosio +// +// baseline-diff.mjs — explains what accepting a candidate doc/lint/baseline.json +// would change, so a maintainer reseeding the "no new violations" gate can tell +// "stale entries retired" from "a real new finding absorbed." +// +// Reseeding a baseline REWRITES the gate's reference point: every fingerprint the +// candidate adds becomes grandfathered forever, and every fingerprint it drops +// becomes newly reportable. That is the one operation in this toolchain that can +// silently un-gate a real regression, so it is never automatic — CI produces a +// candidate, this script explains it, and a human commits it. The reseed steps +// live in .github/workflows/docs.yml; the maintainer procedure is in +// doc/lint/README.md. +// +// Node built-ins only, no dependencies. +// +// Usage: +// node doc/lint/baseline-diff.mjs \ +// [--gate : ...] [--examples N] +// +// --gate takes the SAME specs as check-no-new-violations.mjs (split on the first +// ':', regex tested against the whole fingerprint). Pass the live gate spec and +// any added fingerprint that would have blocked a merge is reported separately, +// in full, and as a GitHub error annotation — those are the entries a reseed +// would grandfather away. The CI step derives these specs from the blocking step +// in .github/workflows/docs.yml rather than restating them, so they cannot rot +// apart; see the reseed steps there. +// +// --allow-emptied acknowledges that a gated check legitimately reached +// zero findings (its backlog is genuinely closed). Repeatable. Without it, a +// gated check that is empty in the candidate while non-empty in the committed +// baseline is FATAL — see emptiedGated below. +// +// Exit status: +// 0 candidate is explainable (it may still add ungated findings — read the report) +// 1 candidate must not be committed as-is. Three reasons, all fail-closed: +// * a check is `skipped` in it (a skipped check snapshots an empty slice, +// wiping that slice's grandfathered backlog), +// * a GATED check collapsed to zero findings without being marked skipped +// (same wipe, but arrives looking like success — see emptiedGated), +// * an added fingerprint matches the gate spec (a finding that would have +// blocked a merge is about to become grandfathered). +// Adding *ungated* findings is not by itself an error: those slices are still +// being worked down, so an intentional new backlog is legitimate. A gated +// addition never is without justification, and neither is an unverifiable check. +// +import fs from 'node:fs'; + +const argv = process.argv.slice(2); +const gateByCheck = new Map(); // check -> [RegExp] +const allowEmptied = new Set(); +const positional = []; +let examples = 5; +for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + let spec = null; + if (a === '--gate') spec = argv[++i]; + else if (a.startsWith('--gate=')) spec = a.slice('--gate='.length); + else if (a === '--examples') { examples = Number(argv[++i]); continue; } + else if (a.startsWith('--examples=')) { examples = Number(a.slice('--examples='.length)); continue; } + else if (a === '--allow-emptied') { allowEmptied.add(argv[++i]); continue; } + else if (a.startsWith('--allow-emptied=')) { allowEmptied.add(a.slice('--allow-emptied='.length)); continue; } + else { positional.push(a); continue; } + const idx = spec.indexOf(':'); + if (idx < 0) { + console.error(`--gate expects :, got: ${spec}`); + process.exit(2); + } + const check = spec.slice(0, idx); + if (!gateByCheck.has(check)) gateByCheck.set(check, []); + gateByCheck.get(check).push(new RegExp(spec.slice(idx + 1))); +} +if (positional.length !== 2) { + console.error('usage: baseline-diff.mjs [--gate spec ...] [--allow-emptied check ...] [--examples N]'); + process.exit(2); +} +const [committedPath, candidatePath] = positional; + +function load(p) { + try { + return JSON.parse(fs.readFileSync(p, 'utf8')); + } catch (e) { + console.error(`cannot read ${p}: ${e.message}`); + process.exit(1); + } +} +const committed = load(committedPath); +const candidate = load(candidatePath); + +// Which rule a fingerprint belongs to. The key SHAPE differs per check and is +// load-bearing (see baseline.mjs occurrenceKey): doc_lint is +// `rule:file:#N:message` (rule at the HEAD), the two Vale checks are +// `file:#N:Check.Name` (check name at the TAIL — the merge gate's +// `Corosio\.PartHeadings$` is anchored on it), mrdocs_warnings is +// `file:#N:message`. Group accordingly rather than +// guessing from the string. +const afterOccurrenceIndex = (fp) => { + const m = fp.match(/:#\d+:/); + return m ? fp.slice(m.index + m[0].length) : fp; +}; +function ruleOf(check, fp) { + switch (check) { + case 'vale_adoc': + case 'vale_docstrings': + return fp.slice(fp.lastIndexOf(':') + 1) || '(unparsed)'; + case 'doc_lint': + // sentence_length shares doc_lint's fingerprint shape (rule at the HEAD), and + // its three keys are the split a maintainer needs to see BEFORE reseeding: + // `C2` is the hard slice that a `--gate 'sentence_length:^C2:'` spec will make + // merge-blocking, `advisory-C2` is the design essays that never block, and + // `BACKTICK` is a tooling diagnostic, not a prose finding. Without this case + // all three collapsed into `(all)` and the reseed report hid exactly the + // distinction that decides what becomes gate-protected. + case 'sentence_length': { + const i = fp.indexOf(':'); + return i > 0 ? fp.slice(0, i) : '(unparsed)'; + } + case 'mrdocs_warnings': + // Collapse quoted identifiers so per-symbol findings group by warning class. + return afterOccurrenceIndex(fp).replace(/'[^']*'/g, "'…'").replace(/"[^"]*"/g, '"…"'); + default: + return '(all)'; + } +} + +const out = []; +const say = (s = '') => out.push(s); +const annotations = []; +const emptiedGated = []; +let fatal = false; + +say('=== doc-lint baseline candidate: what committing it would change ==='); +say(`committed: ${committedPath} (generatedAt ${committed.generatedAt ?? '?'})`); +say(`candidate: ${candidatePath} (generatedAt ${candidate.generatedAt ?? '?'})`); +say(); + +const checkNames = [...new Set([...Object.keys(committed.checks || {}), ...Object.keys(candidate.checks || {})])].sort(); +const rows = []; +const perCheck = new Map(); +for (const check of checkNames) { + const base = committed.checks?.[check] ?? {}; + const cand = candidate.checks?.[check] ?? {}; + const baseSet = new Set(base.fingerprints || []); + const candSet = new Set(cand.fingerprints || []); + const added = [...candSet].filter((fp) => !baseSet.has(fp)).sort(); + const removed = [...baseSet].filter((fp) => !candSet.has(fp)).sort(); + const gateRes = gateByCheck.get(check) || null; + const gatedAdded = gateRes ? added.filter((fp) => gateRes.some((re) => re.test(fp))) : []; + perCheck.set(check, { base, cand, added, removed, gateRes, gatedAdded }); + rows.push([ + check + (gateRes ? ' *' : ''), + cand.skipped ? 'SKIPPED' : String(cand.count ?? candSet.size), + base.skipped ? 'SKIPPED' : String(base.count ?? baseSet.size), + String(added.length), + String(removed.length), + ]); + if (cand.skipped) { + fatal = true; + annotations.push(`::error title=Baseline candidate unusable::check '${check}' is SKIPPED in the candidate (${cand.reason ?? 'no reason given'}). Committing it would wipe that check's grandfathered backlog. Fix the environment and re-run.`); + } else if (gateRes && candSet.size === 0 && baseSet.size > 0 && !allowEmptied.has(check)) { + // A GATED check reporting zero findings where the committed baseline has some + // is treated as a crash until proven otherwise. This is the fail-open that the + // `skipped` flag does NOT catch: a check that dies with empty stdout used to be + // recorded as `count: 0, skipped: false`, and the resulting candidate read + // "retires 214, grandfathers 0, none gated" — an actively reassuring report for + // a candidate that would wipe a merge-blocking check's entire backlog. + // (baseline.mjs now marks such crashes skipped; this is the independent + // second line, because it does not care WHY the slice is empty.) + // + // Emptiness, not a removal-fraction threshold: a crash produces exactly zero, + // never 40% fewer, so emptiness targets the real failure mode with no magic + // number and no arbitrary cliff. A percentage would fire on the very first + // legitimate reseed here (mrdocs_warnings drops 195 of 214 = 91%), training + // maintainers to wave it through — the worst outcome for a guard. The one + // legitimate zero, a gated backlog genuinely closing, is a milestone worth an + // explicit --allow-emptied , which records the decision in the run log. + fatal = true; + emptiedGated.push(check); + annotations.push(`::error title=Baseline candidate unusable::gated check '${check}' reports 0 findings but the committed baseline has ${baseSet.size}. A crashed check looks exactly like this. Verify the check really ran; if the backlog is genuinely closed, re-run with --allow-emptied ${check}.`); + } +} + +// Fixed-width table so before/after is skimmable in a job log. +const header = ['check', 'candidate', 'committed', 'added', 'removed']; +const widths = header.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length))); +const fmt = (cells) => cells.map((c, i) => (i === 0 ? c.padEnd(widths[i]) : c.padStart(widths[i]))).join(' '); +say('--- per-check counts (`*` = gated by the merge gate) ---'); +say(fmt(header)); +say(widths.map((w) => '-'.repeat(w)).join(' ')); +for (const r of rows) say(fmt(r)); +const totalAdded = [...perCheck.values()].reduce((n, v) => n + v.added.length, 0); +const totalRemoved = [...perCheck.values()].reduce((n, v) => n + v.removed.length, 0); +const totalGated = [...perCheck.values()].reduce((n, v) => n + v.gatedAdded.length, 0); +say(); +say(`TOTAL added ${totalAdded} removed ${totalRemoved} added-and-gated ${totalGated}`); +say(); + +function byRule(check, list) { + const groups = new Map(); + for (const fp of list) { + const rule = ruleOf(check, fp); + if (!groups.has(rule)) groups.set(rule, []); + groups.get(rule).push(fp); + } + return [...groups.entries()].sort((a, b) => b[1].length - a[1].length || a[0].localeCompare(b[0])); +} + +// ADDED is the dangerous direction: every one of these becomes grandfathered. +say('--- ADDED fingerprints by check and rule (these become grandfathered) ---'); +if (totalAdded === 0) say('(none)'); +for (const check of checkNames) { + const { added } = perCheck.get(check); + if (added.length === 0) continue; + say(`${check}: ${added.length} added`); + for (const [rule, list] of byRule(check, added)) { + say(` ${String(list.length).padStart(5)} ${rule}`); + for (const fp of list.slice(0, examples)) say(` e.g. ${fp}`); + if (list.length > examples) say(` ... and ${list.length - examples} more`); + } +} +say(); + +// REMOVED is the point of a reseed: retiring entries that are already fixed. +say('--- REMOVED fingerprints by check and rule (backlog being retired) ---'); +if (totalRemoved === 0) say('(none)'); +for (const check of checkNames) { + const { removed } = perCheck.get(check); + if (removed.length === 0) continue; + say(`${check}: ${removed.length} removed`); + for (const [rule, list] of byRule(check, removed)) say(` ${String(list.length).padStart(5)} ${rule}`); +} +say(); + +say('--- ADDED fingerprints that the merge gate WOULD have blocked ---'); +if (gateByCheck.size === 0) { + say('(no --gate spec passed; re-run with the gate spec from .github/workflows/docs.yml to see this)'); +} else if (totalGated === 0) { + say(`(none — no added fingerprint matches the gate spec: ${[...gateByCheck.entries()].map(([c, res]) => res.map((re) => `${c}:${re.source}`).join(' ')).join(' ')})`); +} else { + say(`${totalGated} added fingerprint(s) match the gate spec. Each is EITHER a real regression`); + say('you are about to grandfather away, OR an environment difference. Account for every one'); + say('before committing this candidate:'); + for (const check of checkNames) { + for (const fp of perCheck.get(check).gatedAdded) say(` - ${check} :: ${fp}`); + } + annotations.push(`::error title=Baseline candidate adds gated findings::${totalGated} added fingerprint(s) would have blocked the merge gate. Do not commit this candidate until each is explained.`); +} +say(); + +if (fatal) { + say('RESULT: candidate is NOT safe to commit. Do not use this file.'); + for (const check of checkNames) { + if (candidate.checks?.[check]?.skipped) say(` - ${check} is SKIPPED in the candidate: the check could not run at all.`); + } + for (const check of emptiedGated) { + say(` - ${check} is GATED and reports 0 findings against ${committed.checks?.[check]?.count ?? '?'} in the`); + say(' committed baseline. A crashed check looks exactly like this. Confirm the check really'); + say(` ran; if that backlog is genuinely closed, re-run with --allow-emptied ${check}.`); + } +} else if (totalGated > 0) { + say('RESULT: candidate must not be committed until its gated additions are justified (see above).'); +} else { + say(`RESULT: candidate retires ${totalRemoved} and grandfathers ${totalAdded} fingerprint(s), none gated.`); + if (allowEmptied.size > 0) say(`(--allow-emptied accepted a zero-finding gated check: ${[...allowEmptied].join(', ')})`); +} + +console.log(out.join('\n')); +if (process.env.GITHUB_ACTIONS) for (const a of annotations) console.log(a); +// Gated additions exit 1 too: the skip path already fails closed, and a green step +// beside a red annotation is how a warning gets skimmed past. An ungated addition +// alone is not an error (those slices are still being worked down). +process.exit(fatal || totalGated > 0 ? 1 : 0); diff --git a/doc/lint/baseline.json b/doc/lint/baseline.json new file mode 100644 index 000000000..b6765ce81 --- /dev/null +++ b/doc/lint/baseline.json @@ -0,0 +1,237 @@ +{ + "generatedAt": "2026-09-25T20:36:23.930Z", + "note": "Snapshot of current violations (Style Guide Part F.0). Everything recorded here is grandfathered; check-no-new-violations.mjs fails on findings that are NOT in it, for the rules named by the workflow's --gate spec. Reseed only via the workflow_dispatch steps, never locally. Fingerprints are line-insensitive: the `#N` component is the Nth occurrence of that (file, message) pair, NOT a line number, so inserting text above a finding does not rename it.", + "checks": { + "vale_adoc": { + "count": 55, + "skipped": false, + "fingerprints": [ + "modules/ROOT/pages/2.networking-tutorial/2b.internet-addresses.adoc:#1:Google.OxfordComma", + "modules/ROOT/pages/2.networking-tutorial/2d.urls.adoc:#1:Google.Colons", + "modules/ROOT/pages/2.networking-tutorial/2j.tcp-data-flow.adoc:#1:Google.LyHyphens", + "modules/ROOT/pages/2.networking-tutorial/2k.tcp-reliability.adoc:#1:Google.LyHyphens", + "modules/ROOT/pages/2.networking-tutorial/2k.tcp-reliability.adoc:#1:Google.OxfordComma", + "modules/ROOT/pages/3.tutorials/3a.echo-server.adoc:#1:Vale.Spelling", + "modules/ROOT/pages/3.tutorials/3a.echo-server.adoc:#2:Vale.Spelling", + "modules/ROOT/pages/3.tutorials/3f.reconnect.adoc:#1:Google.Units", + "modules/ROOT/pages/3.tutorials/3f.reconnect.adoc:#2:Google.Units", + "modules/ROOT/pages/4.guide/4.intro.adoc:#1:Google.OxfordComma", + "modules/ROOT/pages/4.guide/4a.tcp-networking.adoc:#1:Google.Colons", + "modules/ROOT/pages/4.guide/4a.tcp-networking.adoc:#1:Google.OptionalPlurals", + "modules/ROOT/pages/4.guide/4a.tcp-networking.adoc:#1:Vale.Repetition", + "modules/ROOT/pages/4.guide/4b.concurrent-programming.adoc:#1:Google.Units", + "modules/ROOT/pages/4.guide/4b.concurrent-programming.adoc:#2:Google.Units", + "modules/ROOT/pages/4.guide/4d.sockets.adoc:#1:Vale.Spelling", + "modules/ROOT/pages/4.guide/4e.tcp-acceptor.adoc:#1:Vale.Spelling", + "modules/ROOT/pages/4.guide/4e.tcp-acceptor.adoc:#2:Vale.Spelling", + "modules/ROOT/pages/4.guide/4e.tcp-acceptor.adoc:#3:Vale.Spelling", + "modules/ROOT/pages/4.guide/4e.tcp-acceptor.adoc:#4:Vale.Spelling", + "modules/ROOT/pages/4.guide/4e.tcp-acceptor.adoc:#5:Vale.Spelling", + "modules/ROOT/pages/4.guide/4e.tcp-acceptor.adoc:#6:Vale.Spelling", + "modules/ROOT/pages/4.guide/4e.tcp-acceptor.adoc:#7:Vale.Spelling", + "modules/ROOT/pages/4.guide/4e.tcp-acceptor.adoc:#8:Vale.Spelling", + "modules/ROOT/pages/4.guide/4f.endpoints.adoc:#1:Google.LyHyphens", + "modules/ROOT/pages/4.guide/4h.timers.adoc:#1:Google.OxfordComma", + "modules/ROOT/pages/4.guide/4h.timers.adoc:#1:Vale.Spelling", + "modules/ROOT/pages/4.guide/4k.tcp-server.adoc:#1:Google.OxfordComma", + "modules/ROOT/pages/4.guide/4k.tcp-server.adoc:#1:Vale.Spelling", + "modules/ROOT/pages/4.guide/4l.tls.adoc:#1:Google.OxfordComma", + "modules/ROOT/pages/4.guide/4l.tls.adoc:#1:Vale.Spelling", + "modules/ROOT/pages/4.guide/4l.tls.adoc:#2:Vale.Spelling", + "modules/ROOT/pages/4.guide/4l.tls.adoc:#3:Vale.Spelling", + "modules/ROOT/pages/4.guide/4l.tls.adoc:#4:Vale.Spelling", + "modules/ROOT/pages/4.guide/4m.error-handling.adoc:#1:Vale.Spelling", + "modules/ROOT/pages/4.guide/4m.error-handling.adoc:#2:Vale.Spelling", + "modules/ROOT/pages/4.guide/4n.buffers.adoc:#1:Vale.Spelling", + "modules/ROOT/pages/4.guide/4o.file-io.adoc:#1:Vale.Spelling", + "modules/ROOT/pages/4.guide/4o.file-io.adoc:#2:Vale.Spelling", + "modules/ROOT/pages/4.guide/4p.unix-sockets.adoc:#1:Google.OxfordComma", + "modules/ROOT/pages/4.guide/4q.udp.adoc:#1:Google.Colons", + "modules/ROOT/pages/4.guide/4q.udp.adoc:#1:Google.OxfordComma", + "modules/ROOT/pages/benchmark-report.adoc:#1:Google.Colons", + "modules/ROOT/pages/benchmark-report.adoc:#1:Google.Units", + "modules/ROOT/pages/benchmark-report.adoc:#2:Google.Colons", + "modules/ROOT/pages/benchmark-report.adoc:#2:Google.Units", + "modules/ROOT/pages/benchmark-report.adoc:#3:Google.Colons", + "modules/ROOT/pages/benchmark-report.adoc:#4:Google.Colons", + "modules/ROOT/pages/benchmark-report.adoc:#5:Google.Colons", + "modules/ROOT/pages/benchmark-report.adoc:#6:Google.Colons", + "modules/ROOT/pages/benchmark-report.adoc:#7:Google.Colons", + "modules/ROOT/pages/benchmark-report.adoc:#8:Google.Colons", + "modules/ROOT/pages/glossary.adoc:#1:Google.OxfordComma", + "modules/ROOT/pages/glossary.adoc:#1:Vale.Spelling", + "modules/ROOT/pages/quick-start.adoc:#1:Vale.Spelling" + ] + }, + "vale_docstrings": { + "count": 51, + "skipped": false, + "fingerprints": [ + "lint/.docstrings/delay.hpp.adoc:#1:Vale.Spelling", + "lint/.docstrings/delay.hpp.adoc:#2:Vale.Spelling", + "lint/.docstrings/endpoint.hpp.adoc:#1:Vale.Spelling", + "lint/.docstrings/family.hpp.adoc:#1:Google.LyHyphens", + "lint/.docstrings/family.hpp.adoc:#2:Google.LyHyphens", + "lint/.docstrings/io/io_object.hpp.adoc:#1:Google.OxfordComma", + "lint/.docstrings/io/io_object.hpp.adoc:#1:Vale.Spelling", + "lint/.docstrings/io/io_stream.hpp.adoc:#1:Vale.Spelling", + "lint/.docstrings/io_context.hpp.adoc:#1:Google.Colons", + "lint/.docstrings/io_context.hpp.adoc:#1:Google.Units", + "lint/.docstrings/io_context.hpp.adoc:#1:Vale.Spelling", + "lint/.docstrings/io_context.hpp.adoc:#2:Google.Units", + "lint/.docstrings/ip_address.hpp.adoc:#1:Google.LyHyphens", + "lint/.docstrings/ip_address.hpp.adoc:#2:Google.LyHyphens", + "lint/.docstrings/local_datagram_socket.hpp.adoc:#1:Google.LyHyphens", + "lint/.docstrings/local_stream_acceptor.hpp.adoc:#1:Google.LyHyphens", + "lint/.docstrings/local_stream_acceptor.hpp.adoc:#2:Google.LyHyphens", + "lint/.docstrings/local_stream_socket.hpp.adoc:#1:Google.LyHyphens", + "lint/.docstrings/local_stream_socket.hpp.adoc:#1:Google.OptionalPlurals", + "lint/.docstrings/native/native_socket_option.hpp.adoc:#1:Google.Colons", + "lint/.docstrings/native/native_socket_option.hpp.adoc:#2:Google.Colons", + "lint/.docstrings/openssl_stream.hpp.adoc:#1:Google.OxfordComma", + "lint/.docstrings/random_access_file.hpp.adoc:#1:Vale.Spelling", + "lint/.docstrings/resolver.hpp.adoc:#1:Vale.Spelling", + "lint/.docstrings/signal_set.hpp.adoc:#1:Google.Colons", + "lint/.docstrings/signal_set.hpp.adoc:#1:Vale.Spelling", + "lint/.docstrings/socket_option.hpp.adoc:#1:Google.Colons", + "lint/.docstrings/stream_file.hpp.adoc:#1:Vale.Spelling", + "lint/.docstrings/stream_file.hpp.adoc:#2:Vale.Spelling", + "lint/.docstrings/tcp_acceptor.hpp.adoc:#1:Google.Colons", + "lint/.docstrings/tcp_acceptor.hpp.adoc:#2:Google.Colons", + "lint/.docstrings/tcp_acceptor.hpp.adoc:#3:Google.Colons", + "lint/.docstrings/tcp_acceptor.hpp.adoc:#4:Google.Colons", + "lint/.docstrings/tcp_acceptor.hpp.adoc:#5:Google.Colons", + "lint/.docstrings/tcp_acceptor.hpp.adoc:#6:Google.Colons", + "lint/.docstrings/tcp_server.hpp.adoc:#1:Vale.Spelling", + "lint/.docstrings/tcp_socket.hpp.adoc:#1:Google.Colons", + "lint/.docstrings/tcp_socket.hpp.adoc:#1:Google.OptionalPlurals", + "lint/.docstrings/tcp_socket.hpp.adoc:#2:Google.Colons", + "lint/.docstrings/tcp_socket.hpp.adoc:#3:Google.Colons", + "lint/.docstrings/tcp_socket.hpp.adoc:#4:Google.Colons", + "lint/.docstrings/tcp_socket.hpp.adoc:#5:Google.Colons", + "lint/.docstrings/tcp_socket.hpp.adoc:#6:Google.Colons", + "lint/.docstrings/tcp_socket.hpp.adoc:#7:Google.Colons", + "lint/.docstrings/tcp_socket.hpp.adoc:#8:Google.Colons", + "lint/.docstrings/tls_context.hpp.adoc:#1:Google.OxfordComma", + "lint/.docstrings/tls_context.hpp.adoc:#2:Google.OxfordComma", + "lint/.docstrings/tls_stream.hpp.adoc:#1:Google.LyHyphens", + "lint/.docstrings/tls_stream.hpp.adoc:#1:Google.OxfordComma", + "lint/.docstrings/udp_socket.hpp.adoc:#1:Vale.Spelling", + "lint/.docstrings/wolfssl_stream.hpp.adoc:#1:Google.OxfordComma" + ] + }, + "sentence_length": { + "count": 72, + "skipped": false, + "byRule": { + "hard": 1, + "advisory": 71, + "unbalancedBackticks": 0, + "max": 25, + "scanned": { + "modules": 49, + "lint/.docstrings": 54 + }, + "advisoryDirs": [ + "modules/ROOT/pages/2.networking-tutorial/" + ] + }, + "fingerprints": [ + "C2:lint/.docstrings/io_context.hpp.adoc:#1:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2.intro.adoc:#1:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2.intro.adoc:#2:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2.intro.adoc:#3:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2.intro.adoc:#4:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2a.how-you-connect.adoc:#1:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2a.how-you-connect.adoc:#2:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2a.how-you-connect.adoc:#3:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2a.how-you-connect.adoc:#4:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2a.how-you-connect.adoc:#5:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2a.how-you-connect.adoc:#6:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2a.how-you-connect.adoc:#7:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2a.how-you-connect.adoc:#8:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2a.how-you-connect.adoc:#9:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2b.internet-addresses.adoc:#1:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2b.internet-addresses.adoc:#2:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2b.internet-addresses.adoc:#3:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2b.internet-addresses.adoc:#4:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2c.domain-name-system.adoc:#1:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2c.domain-name-system.adoc:#2:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2c.domain-name-system.adoc:#3:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2c.domain-name-system.adoc:#4:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2c.domain-name-system.adoc:#5:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2c.domain-name-system.adoc:#6:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2d.urls.adoc:#1:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2d.urls.adoc:#2:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2d.urls.adoc:#3:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2d.urls.adoc:#4:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2d.urls.adoc:#5:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2d.urls.adoc:#6:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2e.client-server-model.adoc:#1:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2e.client-server-model.adoc:#2:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2e.client-server-model.adoc:#3:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2e.client-server-model.adoc:#4:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2f.internet-protocol.adoc:#1:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2f.internet-protocol.adoc:#2:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2f.internet-protocol.adoc:#3:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2f.internet-protocol.adoc:#4:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2f.internet-protocol.adoc:#5:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2f.internet-protocol.adoc:#6:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2g.udp.adoc:#1:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2g.udp.adoc:#2:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2g.udp.adoc:#3:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2g.udp.adoc:#4:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2h.tcp-fundamentals.adoc:#1:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2h.tcp-fundamentals.adoc:#2:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2h.tcp-fundamentals.adoc:#3:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2h.tcp-fundamentals.adoc:#4:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2i.tcp-connections.adoc:#1:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2i.tcp-connections.adoc:#2:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2i.tcp-connections.adoc:#3:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2i.tcp-connections.adoc:#4:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2i.tcp-connections.adoc:#5:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2i.tcp-connections.adoc:#6:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2j.tcp-data-flow.adoc:#1:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2j.tcp-data-flow.adoc:#2:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2j.tcp-data-flow.adoc:#3:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2j.tcp-data-flow.adoc:#4:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2j.tcp-data-flow.adoc:#5:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2j.tcp-data-flow.adoc:#6:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2j.tcp-data-flow.adoc:#7:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2k.tcp-reliability.adoc:#1:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2k.tcp-reliability.adoc:#2:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2k.tcp-reliability.adoc:#3:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2k.tcp-reliability.adoc:#4:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2k.tcp-reliability.adoc:#5:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2l.tcp-performance.adoc:#1:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2l.tcp-performance.adoc:#2:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2l.tcp-performance.adoc:#3:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2l.tcp-performance.adoc:#4:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2l.tcp-performance.adoc:#5:sentence over 25 words", + "advisory-C2:modules/ROOT/pages/2.networking-tutorial/2l.tcp-performance.adoc:#6:sentence over 25 words" + ] + }, + "doc_lint": { + "count": 3, + "skipped": false, + "byRule": { + "A1": 0, + "A6": 0, + "B2": 0, + "SHAPE": 0, + "ANCHOR": 0, + "D2": 3 + }, + "fingerprints": [ + "D2:3.tutorials/3.intro.adoc:#1:tutorial/concept page has no include::example$", + "D2:4.guide/4.intro.adoc:#1:tutorial/concept page has no include::example$", + "D2:5.testing/5.intro.adoc:#1:tutorial/concept page has no include::example$" + ] + }, + "mrdocs_warnings": { + "count": 0, + "skipped": false, + "fingerprints": [] + } + } +} diff --git a/doc/lint/baseline.mjs b/doc/lint/baseline.mjs new file mode 100644 index 000000000..52053893d --- /dev/null +++ b/doc/lint/baseline.mjs @@ -0,0 +1,370 @@ +#!/usr/bin/env node +// +// baseline.mjs — runs every check and snapshots current violations to +// doc/lint/baseline.json (Style Guide Part F.0, "no new violations" while the +// backlog is worked down). Node built-ins only, no dependencies. +// +// Each check contributes a `count` and a `fingerprints` array (stable +// per-finding strings) so a later comparator (check-no-new-violations.mjs) +// can diff a fresh run against this snapshot and flag genuinely new +// findings, independent of how many pre-existing ones remain. Fingerprints +// deliberately carry no line number — see occurrenceKey() below. +// +// Usage: node doc/lint/baseline.mjs [outFile] +// outFile defaults to doc/lint/baseline.json; check-no-new-violations.mjs +// passes a temp path so a comparison run doesn't clobber the committed one. +// +import fs from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const DOC_DIR = path.resolve(SCRIPT_DIR, '..'); +const REPO_ROOT = path.resolve(DOC_DIR, '..'); +const cliArgs = process.argv.slice(2); +// --details additionally emits a `details` map per check, keyed by the SAME +// fingerprint string, carrying { file, line, excerpt } for human-readable +// reporting. Deliberately opt-in and deliberately NOT part of a committed +// baseline: line numbers move, so persisting them would reintroduce exactly +// the churn occurrenceKey() exists to avoid (see its comment). The reseed +// path never passes this; check-no-new-violations.mjs always does. +const wantDetails = cliArgs.includes('--details'); +const outArg = cliArgs.find((a) => !a.startsWith('--')); + +// Vale does not parse AsciiDoc itself: it shells out to `asciidoctor` and lints +// the HTML. With none on PATH it exits 2 with a runtime error, valeFingerprints() +// reports the check SKIPPED, and check-no-new-violations.mjs still exits 0 — a +// local run then passes while linting no prose at all. +// +// `asciidoctor` is a devDependency of doc/package.json, added for exactly this, and +// `npm ci` in doc/build_antora.sh installs it at doc/node_modules/.bin/asciidoctor. +// Antora does NOT pull it in on its own -- @asciidoctor/core provides no binary -- +// so do not prune that devDependency: without it both Vale checks skip, which is +// the fail-open this block exists to prevent. It is APPENDED, not prepended: CI +// apt-installs the Ruby asciidoctor, the committed baseline is authored against +// that one, and the two produce different HTML and therefore different findings +// (the drift the reseed table records). Whatever PATH already offers keeps winning. +const VALE_PATH = [ + process.env.PATH || '', + path.join(DOC_DIR, 'node_modules', '.bin'), +].filter(Boolean).join(path.delimiter); + +function run(cmd, args, opts = {}) { + const env = { ...process.env, PATH: VALE_PATH, ...(opts.env || {}) }; + const r = spawnSync(cmd, args, { + encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, ...opts, env, + }); + return r; +} + +// Fingerprints must survive line shifts. Keying on the reported line number made +// every finding BELOW an insertion point look new: commit df68f9bc, a comment-only +// docstring addition, renamed 27 grandfathered MrDocs findings and red-lined the +// blocking MrDocs-no-warnings gate without introducing a single warning. So the +// line number is replaced by a per-group occurrence index: the Nth finding sharing +// the same (head, tail) pair is keyed `#N`. That keeps multiplicity — the same +// warning appearing one MORE time in the same file is still new — while making the +// key independent of where in the file it appears. +// +// The index goes exactly where the line number was, mid-key. Do not move it to the +// tail: .github/workflows/docs.yml gates on `doc_lint:^(A1|A6|B2|D2):` (head-anchored) +// and `vale_adoc:Corosio\.PartHeadings$` (TAIL-anchored), and the regexes are tested +// against the whole fingerprint, so a trailing index would make the PartHeadings +// gate match nothing and fail open. +// +// The counter is per-base-key, never a raw iteration counter, so the resulting key +// multiset is {base:#1 .. base:#k} whatever order the findings arrive in. +function occurrenceKey(seen, head, tail) { + const base = `${head}\u0000${tail}`; // NUL separator: neither part can contain it + const n = (seen.get(base) ?? 0) + 1; + seen.set(base, n); + return `${head}:#${n}:${tail}`; +} + +// Vale is spawned below with cwd=DOC_DIR, so the keys of its JSON output are +// paths relative to DOC_DIR (or absolute). `path.relative(DOC_DIR, file)` +// resolved a *relative* `file` against process.cwd(), NOT against DOC_DIR — so +// regenerating from anywhere other than doc/ prefixed every Vale path with +// `../` and silently renamed all ~3900 Vale fingerprints at once, retiring the +// entire grandfathered Vale backlog and re-minting it under new keys. Resolve +// against DOC_DIR explicitly so the key depends only on the file, never on +// where the generator happened to be invoked from. The separator normalisation +// is a no-op on POSIX (path.sep === '/') and keeps a Windows run from minting a +// parallel backslash-keyed key set. +function valeRelPath(file) { + return path.relative(DOC_DIR, path.resolve(DOC_DIR, file)).split(path.sep).join('/'); +} + + +// --------------------------------------------------------------------------- +// Source resolution for human-readable reporting (--details only). +// +// A docstring finding is reported against lint/.docstrings/.adoc, a +// GENERATED file nobody edits — unactionable on its own. extract-docstrings.mjs +// writes a `.lines.json` sidecar mapping output-line ranges back to the +// line in the real header where that doc comment starts; we then refine within +// the block by locating the excerpt in the header text, because a single doc +// comment can be 100 output lines long. +const lineMapCache = new Map(); +const srcCache = new Map(); + +const normText = (s) => s.replace(/^[\s*\/]+/, '').replace(/\s+/g, ' ').trim(); + +// Find `excerpt` in the header at/after `fromLine`, tolerating the reflow that +// cleanBlock() applied (`*` prefixes stripped, continuation lines folded). Returns +// a 1-based line, or null when the probe is too short or does not match — in which +// case the caller keeps the block's start line, which is always correct if coarse. +function locateInSource(relSource, fromLine, excerpt) { + if (!excerpt) return null; + let lines = srcCache.get(relSource); + if (lines === undefined) { + try { lines = fs.readFileSync(path.join(REPO_ROOT, relSource), 'utf8').split('\n'); } + catch { lines = null; } + srcCache.set(relSource, lines); + } + if (!lines) return null; + let buf = ''; + const owner = []; + for (let i = fromLine - 1; i < Math.min(lines.length, fromLine + 400); i++) { + const t = normText(lines[i]); + if (!t) continue; + if (buf) { buf += ' '; owner.push(i + 1); } + for (let k = 0; k < t.length; k++) owner.push(i + 1); + buf += t; + } + const probe = normText(excerpt).slice(0, 60); + if (probe.length < 12) return null; + const at = buf.indexOf(probe); + return at >= 0 ? owner[at] : null; +} + +// Map a linted path + line to the file a developer should actually open. +function resolveSource(relFile, line, excerpt) { + if (!relFile || !relFile.startsWith('lint/.docstrings/')) { + return { file: relFile ? `doc/${relFile}` : null, line: line ?? null }; + } + const mapPath = path.join(DOC_DIR, `${relFile}.lines.json`); + let map = lineMapCache.get(mapPath); + if (map === undefined) { + try { map = JSON.parse(fs.readFileSync(mapPath, 'utf8')); } catch { map = null; } + lineMapCache.set(mapPath, map); + } + if (!map) return { file: `doc/${relFile}`, line: line ?? null }; + const span = line == null ? null : map.spans.find((sp) => line >= sp.from && line <= sp.to); + if (!span) return { file: map.source, line: null }; + return { file: map.source, line: locateInSource(map.source, span.srcLine, excerpt) ?? span.srcLine }; +} + +// Head+tail excerpt: a 30-word sentence is unreadable inline, but its opening +// and closing words are what let you find it in the file. +function excerptOf(text, max = 96) { + if (!text) return null; + const t = normText(String(text)); + if (t.length <= max) return t; + return `${t.slice(0, max - 28).trimEnd()} ... ${t.slice(-22).trimStart()}`; +} + +function valeFingerprints(target) { + const r = run('vale', ['--output=JSON', target], { cwd: DOC_DIR }); + if (r.error) { + return { count: 0, skipped: true, reason: `vale failed to launch: ${r.error.message}`, fingerprints: [] }; + } + // Vale's own exit codes: 0 = no alerts at MinAlertLevel, 1 = alerts found (the normal, + // expected case — NOT a failure), 2 = fatal runtime error (e.g. `asciidoctor` off PATH, + // a broken `vale sync`). On a fatal error Vale writes a single JSON error object to + // stderr and leaves stdout empty, so a plain JSON.parse(stdout || '{}') silently yields + // `{}` — indistinguishable from "ran clean, found nothing." Detect that explicitly + // instead of ever reporting a broken Vale run as `count: 0`. + let parsed = null; + try { parsed = r.stdout ? JSON.parse(r.stdout) : null; } catch { parsed = null; } + const looksLikeFileMap = parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed); + if (r.status === 2 || !looksLikeFileMap) { + const tail = (r.stderr || r.stdout || '(no output)').trim().slice(-500); + return { count: 0, skipped: true, reason: `vale on '${target}' did not produce findings (exit ${r.status}): ${tail}`, fingerprints: [] }; + } + const fingerprints = []; + const details = {}; + const seen = new Map(); + for (const [file, alerts] of Object.entries(parsed)) { + const rel = valeRelPath(file); + for (const a of alerts) { + const fp = occurrenceKey(seen, rel, a.Check); + fingerprints.push(fp); + if (wantDetails) { + details[fp] = { ...resolveSource(rel, a.Line, a.Match), rule: a.Check, + message: a.Message, excerpt: excerptOf(a.Match) }; + } + } + } + return { count: fingerprints.length, fingerprints: fingerprints.sort(), details }; +} + +// A crashed check must report as SKIPPED, never as zero findings. doc-lint.mjs and +// mrdocs-warnings.mjs have no error path for an *uncaught throw*: they die with a +// non-zero status and an empty stdout. Defaulting that to an empty findings object +// yielded `count: 0, skipped: false` — a crashed check indistinguishable from a +// clean one. Both are GATED, and the comparator only treats a `skipped` check as +// unverifiable, so the zero sailed through as "backlog empty": the reseed reporter +// called it "0 added, none gated" and the merge gate called it "0 new". That is the +// fail-open shape this toolchain exists to prevent, so the exit status is now +// checked before the output is believed. (mrdocs-warnings.mjs's *designed* +// failures — no binary, version-pin miss, MrDocs itself failing — already emit +// `{error}` on exit 0 and are handled below; this only covers crashes.) +function crashed(r, script) { + if (!r.error && r.status === 0) return null; + const tail = (r.stderr || r.error?.message || r.stdout || '(no output)').trim().slice(-500); + return { count: 0, skipped: true, reason: `${script} failed (exit ${r.status}): ${tail}`, fingerprints: [] }; +} + +function docLintFingerprints() { + const r = run('node', [path.join(SCRIPT_DIR, 'doc-lint.mjs')]); + const bad = crashed(r, 'doc-lint.mjs'); + if (bad) return bad; + let parsed; + try { + parsed = JSON.parse(r.stdout || ''); + } catch { + return { + count: 0, skipped: true, fingerprints: [], + reason: `doc-lint.mjs produced unparseable output: ${(r.stdout || '(empty)').trim().slice(-500)}`, + }; + } + const fingerprints = []; + const details = {}; + const seen = new Map(); + for (const [check, items] of Object.entries(parsed.findings || {})) { + // SHAPE is advisory-only and never gated (see doc-lint.mjs's header + // comment); folding it into doc_lint's fingerprint set let a single + // advisory SHAPE finding keep `currentSet.length` non-zero in + // check-no-new-violations.mjs even when A1/A6/B2/D2 — the checks the + // gate spec `doc_lint:^(A1|A6|B2|D2):` actually cares about — report + // zero, silently disarming the "gated check reports 0 against a + // non-empty baseline" backstop (check-no-new-violations.mjs:187). + if (check === 'SHAPE') continue; + for (const it of items) { + const fp = occurrenceKey(seen, `${check}:${it.file}`, it.message); + fingerprints.push(fp); + // doc-lint reports paths relative to the pages tree, not to doc/. + if (wantDetails) { + details[fp] = { ...resolveSource(`modules/ROOT/pages/${it.file}`, it.line ?? null, null), + rule: check, message: it.message, excerpt: null }; + } + } + } + return { count: fingerprints.length, byRule: parsed.summary, fingerprints: fingerprints.sort(), details }; +} + +// C2 (sentence length) is checked by our own script, not by Vale — see the +// header of sentence-length.mjs and the comment in Corosio/SentenceLength.yml for +// why. The finding shape is doc-lint.mjs's, so the fingerprint is the doc_lint +// shape (`rule:file:#N:message`, rule at the HEAD) and a future gate spec reads +// `--gate 'sentence_length:^C2:'`. Note this check has no entry in the +// committed baseline.json yet, so every finding reports as NEW until the +// maintainer reseeds; it is deliberately NOT in the gate spec, so that cannot +// block a merge. +function sentenceLengthFingerprints() { + const r = run('node', [path.join(SCRIPT_DIR, 'sentence-length.mjs')], { cwd: DOC_DIR }); + const bad = crashed(r, 'sentence-length.mjs'); + if (bad) return bad; + let parsed; + try { + parsed = JSON.parse(r.stdout || ''); + } catch { + return { + count: 0, skipped: true, fingerprints: [], + reason: `sentence-length.mjs produced unparseable output: ${(r.stdout || '(empty)').trim().slice(-500)}`, + }; + } + const fingerprints = []; + const details = {}; + const seen = new Map(); + for (const [check, items] of Object.entries(parsed.findings || {})) { + for (const it of items) { + const fp = occurrenceKey(seen, `${check}:${it.file}`, it.message); + fingerprints.push(fp); + if (wantDetails) { + details[fp] = { ...resolveSource(it.file, it.line ?? null, it.sentence), + rule: check, message: it.words ? `${it.message} (${it.words})` : it.message, + excerpt: excerptOf(it.sentence) }; + } + } + } + return { count: fingerprints.length, byRule: parsed.summary, fingerprints: fingerprints.sort(), details }; +} + +function mrdocsFingerprints() { + const r = run('node', [path.join(SCRIPT_DIR, 'mrdocs-warnings.mjs')]); + const bad = crashed(r, 'mrdocs-warnings.mjs'); + if (bad) return bad; + let parsed = {}; + try { parsed = JSON.parse(r.stdout || '{}'); } catch { /* fall through */ } + if (parsed.error) return { count: 0, skipped: true, reason: parsed.error, fingerprints: [] }; + const seen = new Map(); + const details = {}; + const fingerprints = (parsed.findings || []).map((f) => { + const fp = occurrenceKey(seen, f.file ?? '?', f.message); + if (wantDetails) { + details[fp] = { file: f.file ?? null, line: f.line ?? null, + rule: 'mrdocs', message: f.message, excerpt: null }; + } + return fp; + }); + return { count: fingerprints.length, fingerprints: fingerprints.sort(), details }; +} + +const results = {}; +results.vale_adoc = valeFingerprints('modules'); + +// The docstring corpus is GENERATED, so the generator's exit status is part of +// the measurement. It used to be discarded: extract-docstrings.mjs never clears +// OUT_DIR, so a crash left whatever the last successful run wrote — a stale +// corpus that Vale lints happily and reports `skipped: false` over. Worse, if +// the crash happened before anything was ever written (fresh clone, renamed +// path), Vale over an absent directory exits 0 with `{}`, which valeFingerprints +// reads as `count: 0, skipped: false` — a vacuous clean for the C4/C9/C10 +// docstring gates and for sentence_length's docstring half. Both dependent +// checks are therefore marked SKIPPED, which the comparator treats as a gate +// failure, instead of being believed. +const extract = run('node', [path.join(SCRIPT_DIR, 'extract-docstrings.mjs')]); +const extractBad = crashed(extract, 'extract-docstrings.mjs'); +if (extractBad) { + const reason = `docstring corpus not regenerated: ${extractBad.reason}`; + results.vale_docstrings = { count: 0, skipped: true, reason, fingerprints: [] }; + results.sentence_length = { count: 0, skipped: true, reason, fingerprints: [] }; +} else { + results.vale_docstrings = valeFingerprints('lint/.docstrings'); + + // After extract-docstrings.mjs above: sentence-length.mjs lints BOTH corpora and + // exits non-zero rather than reporting a clean zero for one it could not read. + results.sentence_length = sentenceLengthFingerprints(); +} + +results.doc_lint = docLintFingerprints(); +results.mrdocs_warnings = mrdocsFingerprints(); +// No a11y check: E4 is Review tier (doc/STYLE_GUIDE.md Part F.0) and a scan that +// cannot fail CI is not carried. run-a11y.mjs and pa11y-ci are deliberately not +// ported from Capy -- see doc/design/style-guide-compliance.md section 4.4. + +const baseline = { + generatedAt: new Date().toISOString(), + note: 'Snapshot of current violations (Style Guide Part F.0). Everything recorded ' + + 'here is grandfathered; check-no-new-violations.mjs fails on findings that are ' + + 'NOT in it, for the rules named by the workflow\'s --gate spec. Reseed only via ' + + 'the workflow_dispatch steps, never locally. Fingerprints are line-insensitive: ' + + 'the `#N` component is the Nth occurrence of that (file, message) pair, NOT a ' + + 'line number, so inserting text above a finding does not rename it.', + checks: Object.fromEntries(Object.entries(results).map(([k, v]) => [k, { + count: v.count, skipped: v.skipped || false, reason: v.reason, + byRule: v.byRule, contrastCount: v.contrastCount, + fingerprints: v.fingerprints, + ...(wantDetails && v.details ? { details: v.details } : {}), + }])), +}; + +const outPath = outArg ? path.resolve(outArg) : path.join(SCRIPT_DIR, 'baseline.json'); +fs.writeFileSync(outPath, JSON.stringify(baseline, null, 2) + '\n'); +console.log(JSON.stringify({ + written: path.relative(REPO_ROOT, outPath), + summary: Object.fromEntries(Object.entries(baseline.checks).map(([k, v]) => [k, v.skipped ? 'skipped' : v.count])), +}, null, 2)); diff --git a/doc/lint/check-include-tags.mjs b/doc/lint/check-include-tags.mjs new file mode 100644 index 000000000..c367729d5 --- /dev/null +++ b/doc/lint/check-include-tags.mjs @@ -0,0 +1,145 @@ +#!/usr/bin/env node +// +// check-include-tags.mjs — every `include::example$...[tag=...]` in a page +// must resolve to a real file that really carries that tag. Node built-ins +// only, no dependencies. +// +// Why this exists: pages pull code out of compiled snippets and, since +// issue #381, straight out of the public headers, so a definition shown on +// a page cannot drift from the definition that ships. That moves the risk +// rather than removing it. Asciidoctor treats a missing include tag as a +// WARNING and still exits 0 — verified locally: a page referencing a +// nonexistent tag renders an empty listing block and the build succeeds. +// The Antora CI leg cannot catch it either; it only asserts that +// `build/site` exists, precisely because Antora also exits 0 on failure. +// +// So without this gate, deleting a `tag::`/`end::` marker from a header +// during ordinary refactoring silently empties whatever page included it, +// and nothing goes red. That is a worse failure than the drift it replaced: +// drift is at least visible on the page. +// +// The example$ -> repo-path mapping is read out of doc/antora.yml's +// collector scan config rather than hardcoded here, so adding a scan entry +// cannot leave this check behind. +// +// Blocking: exits 1 on any unresolved include target or missing tag. +// +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const DOC_DIR = path.resolve(SCRIPT_DIR, '..'); +const REPO_ROOT = path.resolve(DOC_DIR, '..'); +const PAGES_DIR = path.join(DOC_DIR, 'modules', 'ROOT', 'pages'); +const EXAMPLES_PREFIX = 'modules/ROOT/examples'; + +// Parse the `dir:`/`into:` pairs under ext.collector.scan in antora.yml. +// A full YAML parser would be a dependency; the block is a flat list of +// two- and three-key entries, so an indentation-aware line scan is enough. +function readScanMap(yamlPath) { + const lines = fs.readFileSync(yamlPath, 'utf8').split('\n'); + const entries = []; + let cur = null; + for (const line of lines) { + if (/^\s*#/.test(line)) continue; + const dir = line.match(/^\s*-\s*dir:\s*(\S+)\s*$/); + if (dir) { + if (cur) entries.push(cur); + cur = { dir: dir[1], into: null }; + continue; + } + const into = line.match(/^\s*into:\s*(\S+)\s*$/); + if (into && cur) { + cur.into = into[1]; + entries.push(cur); + cur = null; + } + } + if (cur) entries.push(cur); + + // example$/ -> /. Longest prefix wins, so a nested + // mapping such as examples/snippets is preferred over bare examples. + return entries + .filter((e) => e.into && e.into.startsWith(EXAMPLES_PREFIX)) + .map((e) => ({ + prefix: e.into.slice(EXAMPLES_PREFIX.length).replace(/^\//, ''), + dir: e.dir, + })) + .sort((a, b) => b.prefix.length - a.prefix.length); +} + +function resolveTarget(resource, scanMap) { + for (const { prefix, dir } of scanMap) { + if (prefix === '') return path.join(REPO_ROOT, dir, resource); + if (resource === prefix || resource.startsWith(prefix + '/')) { + return path.join(REPO_ROOT, dir, resource.slice(prefix.length).replace(/^\//, '')); + } + } + return null; +} + +// tag=a | tags=a;b;!c | tags=a,b — negations and wildcards select +// nothing on their own, so they are not names this check can verify. +function tagsOf(attrs) { + const m = attrs.match(/\btags?=([^,\]]*)/); + if (!m) return []; + return m[1] + .split(/[;,]/) + .map((t) => t.trim()) + .filter((t) => t && !t.startsWith('!') && !t.includes('*')); +} + +function walk(dir, out = []) { + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, e.name); + if (e.isDirectory()) walk(p, out); + else if (e.name.endsWith('.adoc')) out.push(p); + } + return out; +} + +const scanMap = readScanMap(path.join(DOC_DIR, 'antora.yml')); +if (scanMap.length === 0) { + console.error('check-include-tags: no collector scan entries found in antora.yml'); + process.exit(1); +} + +const violations = []; +let checked = 0; + +for (const page of walk(PAGES_DIR)) { + const rel = path.relative(REPO_ROOT, page); + const lines = fs.readFileSync(page, 'utf8').split('\n'); + lines.forEach((line, i) => { + const m = line.match(/^include::example\$(\S+?)\[([^\]]*)\]/); + if (!m) return; + const [, resource, attrs] = m; + const where = `${rel}:${i + 1}`; + const target = resolveTarget(resource, scanMap); + if (!target) { + violations.push(`${where}: example$${resource} matches no collector scan entry`); + return; + } + if (!fs.existsSync(target)) { + violations.push(`${where}: example$${resource} resolves to a missing file (${path.relative(REPO_ROOT, target)})`); + return; + } + const body = fs.readFileSync(target, 'utf8'); + for (const tag of tagsOf(attrs)) { + checked++; + const has = body.includes(`tag::${tag}[]`) && body.includes(`end::${tag}[]`); + if (!has) { + violations.push(`${where}: tag '${tag}' not found in ${path.relative(REPO_ROOT, target)}`); + } + } + }); +} + +if (violations.length) { + console.error(`check-include-tags: ${violations.length} violation(s)\n`); + for (const v of violations) console.error(` ${v}`); + process.exit(1); +} + +console.log(`check-include-tags: OK — ${checked} tagged include(s) resolve to a live tag.`); diff --git a/doc/lint/check-no-new-violations.mjs b/doc/lint/check-no-new-violations.mjs new file mode 100644 index 000000000..502c639b4 --- /dev/null +++ b/doc/lint/check-no-new-violations.mjs @@ -0,0 +1,377 @@ +#!/usr/bin/env node +// +// check-no-new-violations.mjs — the "no NEW violations" gate the Task 2 +// brief's acceptance criterion describes: a fresh check run is diffed +// against doc/lint/baseline.json (Style Guide Part F.0) per-check +// fingerprint sets, and anything not already in the baseline is reported as +// new. This is what would catch a newly introduced banned word ("utilize") +// or a newly undocumented @param, without re-flagging the existing backlog. +// +// Node built-ins only. Regenerates a fresh snapshot via baseline.mjs into a +// temp file (never overwrites the committed baseline.json) and compares. +// +// Non-blocking by default (Task 2: everything stays warning-mode). Pass +// --strict to exit 1 when new violations are found. +// +// --gate : restricts what counts as blocking to a named +// set of (check, rule-regex) pairs — the phase-exit gate-promotion mechanism. +// Repeatable. Each value is split on its FIRST ':' into a check name and a +// regex tested against that check's fingerprints. In gate mode BOTH the exit +// condition and the skip check are filtered: only NEW fingerprints of a gated +// check that match its regex block, and only a skip of a GATED check fails the +// run ("can't verify a gated rule = not a pass"); skips of non-gated checks +// (mrdocs, vale_docstrings) are reported but do not block. Without +// --gate the comparator stays omnibus (the non-blocking report step). +// +// A gated check that reports ZERO findings against a non-empty baseline also +// fails the gate — a check that silently did not run is indistinguishable from +// one that ran clean, and `skipped` does not catch it. See the rule at the +// bottom of the per-check loop for the reachable case and the reasoning. +// +// Phase-1 exit gate spec (A1/A6/A7/B2/D2): +// --gate 'doc_lint:^(A1|A6|B2|D2):' --gate 'vale_adoc:Corosio\.PartHeadings$' +// (A1/A6/B2/D2 come from doc_lint; A7 is the Vale rule Corosio.PartHeadings.) +// +// Phase-2 exit adds MrDocs-no-warnings to the above (full spec): +// --gate 'doc_lint:^(A1|A6|B2|D2):' --gate 'vale_adoc:Corosio\.PartHeadings$' \ +// --gate 'mrdocs_warnings:.*' +// mrdocs_warnings:.* gates the whole reference-surface check. E4 (accessibility +// contrast) is NOT gated and has no check at all here: it is Review tier +// (doc/STYLE_GUIDE.md Part F.0), and Corosio deliberately does not port Capy's +// pa11y scan, because a check that cannot fail CI earns nothing. See +// doc/design/style-guide-compliance.md section 4.4. +// +// --allow-emptied suppresses the "gated check reports zero findings +// against a non-empty baseline" failure described below, for one check. Use it +// only when a gated backlog has genuinely closed; it records the decision in +// the run log. Not used by the committed CI invocation. +// +// Usage: node doc/lint/check-no-new-violations.mjs [--strict] [--gate spec ...] +// [--allow-emptied check ...] +// +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const argv = process.argv.slice(2); +const strict = argv.includes('--strict'); +// --show-baseline additionally lists findings that are present RIGHT NOW and are +// grandfathered by baseline.json, as warnings. That is deliberately the live +// intersection (current ∩ baseline), not the baseline file's contents: the +// baseline is only ever reseeded when something is ADDED, so it accumulates dead +// clauses for findings that were long since fixed. Printing the file would list +// thousands of already-fixed items; printing the intersection is the real +// remaining worklist. +const showBaseline = argv.includes('--show-baseline'); +// --json restores the machine-readable dump for tooling. Nothing in-tree parses +// it today; the CI steps read the human output. +const jsonOut = argv.includes('--json'); + +// Parse --gate specs; everything else (except --strict) is passed through to +// baseline.mjs. NB: --gate values must NOT reach baseline.mjs, whose first +// non-flag arg is taken as the output path. +const gateByCheck = new Map(); // check -> [RegExp] +const allowEmptied = new Set(); // checks whose zero is an accepted milestone +const extraArgs = []; +for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--strict' || a === '--show-baseline' || a === '--json') continue; + let allow = null; + if (a === '--allow-emptied') allow = argv[++i]; + else if (a.startsWith('--allow-emptied=')) allow = a.slice('--allow-emptied='.length); + if (allow != null) { + if (!allow) { + console.error('--allow-emptied expects a check name'); + process.exit(2); + } + allowEmptied.add(allow); + continue; + } + let spec = null; + if (a === '--gate') spec = argv[++i]; + else if (a.startsWith('--gate=')) spec = a.slice('--gate='.length); + if (spec != null) { + const idx = spec.indexOf(':'); + if (idx < 0) { + console.error(`--gate expects :, got: ${spec}`); + process.exit(2); + } + const check = spec.slice(0, idx); + const re = new RegExp(spec.slice(idx + 1)); + if (!gateByCheck.has(check)) gateByCheck.set(check, []); + gateByCheck.get(check).push(re); + continue; + } + extraArgs.push(a); +} +const gated = gateByCheck.size > 0; + +const baselinePath = path.join(SCRIPT_DIR, 'baseline.json'); +if (!fs.existsSync(baselinePath)) { + console.log(JSON.stringify({ error: `no baseline.json at ${baselinePath} — run baseline.mjs first` }, null, 2)); + process.exit(0); +} +const baseline = JSON.parse(fs.readFileSync(baselinePath, 'utf8')); + +const tmpPath = path.join(os.tmpdir(), `doc-lint-current-${process.pid}.json`); +const r = spawnSync('node', [path.join(SCRIPT_DIR, 'baseline.mjs'), '--details', ...extraArgs, tmpPath], { encoding: 'utf8' }); +if (r.status !== 0 || !fs.existsSync(tmpPath)) { + console.log(JSON.stringify({ error: 'failed to generate a current snapshot', stderr: r.stderr }, null, 2)); + process.exit(0); +} +const current = JSON.parse(fs.readFileSync(tmpPath, 'utf8')); +fs.rmSync(tmpPath, { force: true }); + +let totalNew = 0; // omnibus: new findings across ALL checks (report semantics) +let anySkipped = false; // any check skipped at all +let gatedNew = 0; // new findings in gated checks matching a gate regex +let gatedSkipped = false; // a GATED check was skipped (can't verify => gate fails) +let gatedEmptied = false; // a GATED check reported ZERO findings against a non-empty baseline +const gatedFindings = []; // the specific gated new fingerprints (named in the log) +const emptiedGated = []; // the checks that tripped the emptiness rule +const report = {}; +for (const [check, currentCheck] of Object.entries(current.checks)) { + const gateRes = gateByCheck.get(check) || null; + // A skipped check (Vale broken, MrDocs couldn't run, ...) is NOT a clean pass — it + // means no comparison happened at all. Surface it loudly (stderr, outside the JSON blob) + // so it can't be mistaken for "0 new" in a log that only skims the summary line, and + // record it distinctly (newCount: null, not 0) in the JSON report too. A skip of a GATED + // check additionally fails the gate: an unverifiable gated rule is not a pass. + if (currentCheck.skipped) { + anySkipped = true; + if (gateRes) gatedSkipped = true; + console.error(`SKIPPED: ${check} (${currentCheck.reason}) — no-new-violations comparison NOT performed for this check.${gateRes ? ' [GATED — fails the gate]' : ''}`); + report[check] = { + skipped: true, reason: currentCheck.reason, gated: !!gateRes, + baselineCount: baseline.checks[check]?.count ?? 0, currentCount: currentCheck.count, + newCount: null, newFindings: [], + }; + continue; + } + const baseSet = new Set(baseline.checks[check]?.fingerprints || []); + const currentSet = currentCheck.fingerprints || []; + const newOnes = currentSet.filter((fp) => !baseSet.has(fp)); + const stillPresent = currentSet.filter((fp) => baseSet.has(fp)); + totalNew += newOnes.length; + const entry = { baselineCount: baseline.checks[check]?.count ?? 0, currentCount: currentCheck.count, newCount: newOnes.length, newFindings: newOnes }; + entry.stillPresent = stillPresent; + entry.details = currentCheck.details || {}; + if (gateRes) { + const gatedOnes = newOnes.filter((fp) => gateRes.some((re) => re.test(fp))); + entry.gated = true; + entry.gatedNewCount = gatedOnes.length; + entry.gatedNewFindings = gatedOnes; + gatedNew += gatedOnes.length; + for (const fp of gatedOnes) gatedFindings.push(`${check} :: ${fp}`); + + // A GATED check that reports ZERO findings where the committed baseline has + // some is treated as a check that did not run, until proven otherwise. This + // is the fail-open the `skipped` flag does NOT catch, and it is reachable: + // + // $ cd doc && vale --output=JSON lint/.nonexistent-corpus + // {} + // $ echo $? + // 0 + // + // baseline.mjs marks a Vale check skipped only on exit 2 or a non-object + // parse, so exit 0 plus `{}` yields `{count: 0, skipped: false}` — and this + // comparator then computes "zero new" from an empty current set and reports + // `gated: true, gatedNew: 0`, i.e. a gate that says it is gating while + // measuring nothing. Any renamed corpus path, crashed extractor, or + // `.vale.ini` edit that stops matching the corpus lands here. + // + // The rule is the same one baseline-diff.mjs applies to a reseed candidate, + // for the same reasons: emptiness rather than a removal-fraction threshold + // (a check that did not run produces exactly zero, never 40% fewer), and + // scoped to GATED checks, whose zero is the one that decides a merge. + // + // Deliberately WHOLE-CHECK, not per-gate-regex. The gated SLICE of + // vale_docstrings is legitimately empty today — zero Corosio.SimpleTense / + // NoFluff / Terminology on the docstring corpus is exactly what Phase 4 + // delivered — so a per-slice rule would fail the committed invocation on + // the phase's own success state. A whole-check zero cannot be produced by + // wording work: the residual Vale.Spelling/Google backlog on both corpora + // is not going to zero, so only a broken run gets there. + // + // The one legitimate whole-check zero, a gated backlog genuinely closing, + // is a milestone worth an explicit --allow-emptied . + if (currentSet.length === 0 && baseSet.size > 0 && !allowEmptied.has(check)) { + entry.gatedEmptied = true; + gatedEmptied = true; + emptiedGated.push(check); + } else if (currentSet.length === 0 && baseSet.size > 0) { + entry.gatedEmptiedAllowed = true; + } + } + report[check] = entry; +} + +if (anySkipped) { + console.error(`SKIPPED checks present — totalNew (${totalNew}) is only valid for the checks that actually ran.`); +} + +// The blocking condition: gated slice when --gate is present, omnibus otherwise. +const blockingNew = gated ? gatedNew : totalNew; +const blockingSkip = gated ? gatedSkipped : anySkipped; + +// --------------------------------------------------------------------------- +// Human-readable report. +// +// This used to print the whole comparison as JSON. On a real failure that was +// ~250 lines of which 8 mattered, and the 8 carried no line number and no +// quote — so the reader still had to grep the corpus to find out what broke. +// The rule now: say what is wrong, where, and show enough of it to recognise. +const isCI = !!process.env.GITHUB_ACTIONS; +const fmtLoc = (d, fp) => (d && d.file ? `${d.file}${d.line ? `:${d.line}` : ''}` : fp); + +// One finding, three lines at most: location, rule + message, excerpt. +function emit(level, check, fp, d) { + const loc = fmtLoc(d, fp); + const rule = d?.rule ? d.rule : check; + const msg = d?.message || fp; + console.error(`${level} ${loc}`); + console.error(` [${check} ${rule}] ${msg}`); + if (d?.excerpt) console.error(` "${d.excerpt}"`); + // GitHub annotations put the finding on the diff line itself. Only for + // findings that actually block: a non-blocking step annotating every new + // finding buries the handful that matter under a hundred that do not. + // + // Emitted as ::warning, not ::error, deliberately. The annotation's job is + // to locate the finding on the diff; the check status is what says the run + // failed, and it already does (exit 1). A red inline marker on prose nits + // reads as broken code to anyone skimming the Files-changed tab. + if (isCI && level === 'ERROR' && d?.file) { + const esc = (t) => String(t).replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A'); + console.log(`::warning file=${d.file}${d.line ? `,line=${d.line}` : ''}::${esc(`[${check} ${rule}] ${msg}`)}`); + } +} + +// Errors: the findings that actually block. Under --gate that is the gated +// slice; without --gate every new finding is reported as an error, because +// then there is no narrower thing to mean. +// Two independent questions, and conflating them has bitten twice: +// +// * WHICH findings deserve attention — the gated slice, when --gate is given. +// Those get called out and annotated on the diff. +// * WHETHER the run fails — --strict, and nothing else. +// +// Keying attention on `gated` alone made the un-gated report step announce all +// ~124 new findings as blocking and annotate every one. Keying it on `strict` +// instead then meant that dropping --strict silently removed the annotations +// too. The gate spec says what matters; --strict only says whether mattering is +// fatal. +const errorList = []; +const newNonBlocking = []; +for (const [check, e] of Object.entries(report)) { + if (e.skipped) continue; + const gatedSet = new Set(e.gatedNewFindings || []); + for (const fp of e.newFindings || []) { + (gated && gatedSet.has(fp) ? errorList : newNonBlocking).push([check, fp, e.details?.[fp]]); + } +} + +if (errorList.length) { + console.error(`\n${errorList.length} new gated violation(s) (${strict + ? 'these block the merge' + : 'reported, not blocking — fix them before this gate is promoted'}):\n`); + for (const [check, fp, d] of errorList) emit('ERROR', check, fp, d); +} + +if (newNonBlocking.length) { + console.error(`\n${newNonBlocking.length} other new finding(s) since the baseline ` + + `(${gated ? 'not in a gated slice' : 'report only'}):\n`); + for (const [check, fp, d] of newNonBlocking) emit('NEW ', check, fp, d); +} + +for (const check of emptiedGated) { + console.error(`\nERROR gated check '${check}' reports 0 findings but the committed baseline has ` + + `${baseline.checks[check]?.fingerprints?.length ?? 0} — a check that did not run looks exactly ` + + `like this. Verify it really ran; if the backlog is genuinely closed, re-run with ` + + `--allow-emptied ${check}.`); +} + +if (showBaseline) { + const live = []; + for (const [check, e] of Object.entries(report)) { + if (e.skipped) continue; + for (const fp of e.stillPresent || []) live.push([check, fp, e.details?.[fp]]); + } + console.error(`\n${live.length} grandfathered finding(s) still present — the remaining backlog:\n`); + for (const [check, fp, d] of live) emit('WARN ', check, fp, d); +} + +if (!errorList.length && !newNonBlocking.length && !emptiedGated.length && !anySkipped) { + console.error(`No new ${gated ? 'gated ' : ''}findings.` + + (showBaseline ? '' : ' Re-run with --show-baseline to list the remaining backlog.')); +} + +// --------------------------------------------------------------------------- +// GitHub job summary. Annotations land on the diff, but a fork PR cannot be +// commented on (the pull_request token is read-only), so the run page is the +// one place a full report is reachable from the check without extra machinery. +// Same pattern ci.yml and the reseed step already use. +if (process.env.GITHUB_STEP_SUMMARY) { + const md = []; + // Pipes and newlines would break the table; excerpts are prose and can hold both. + const cell = (t) => String(t ?? '').replace(/\|/g, '\\|').replace(/\r?\n/g, ' '); + + // The blocking findings are deliberately NOT tabulated here. They are already + // annotated on the diff, which is where you act on them; repeating them on the + // run page just means reading the same eight things twice, in the place that is + // one click further away. + if (errorList.length) { + md.push(`### ${strict ? '❌' : '⚠️'} ${errorList.length} new gated violation(s)` + + ' — see the annotations on the diff', ''); + } else if (!emptiedGated.length && gated) { + md.push('### ✅ No new gated violations', ''); + } + + for (const check of emptiedGated) { + md.push(`### ❌ Gated check \`${check}\` reported zero findings`, '', + `The committed baseline has ${baseline.checks[check]?.fingerprints?.length ?? 0}. ` + + 'A check that did not run looks exactly like this — verify it ran before believing it.', ''); + } + + if (newNonBlocking.length) { + const why = gated ? 'not in a gated slice' : 'report only'; + md.push(`
${newNonBlocking.length} new finding(s) since the baseline — ${why}`, '', '```'); + for (const [check, fp, d] of newNonBlocking.slice(0, 200)) md.push(`${fmtLoc(d, fp)} [${d?.rule || check}] ${d?.message || ''}`); + if (newNonBlocking.length > 200) md.push(`… and ${newNonBlocking.length - 200} more`); + md.push('```', '
', ''); + } + + if (showBaseline) { + const live = []; + for (const [check, e] of Object.entries(report)) { + if (e.skipped) continue; + for (const fp of e.stillPresent || []) live.push([check, fp, e.details?.[fp]]); + } + md.push(`
${live.length} grandfathered finding(s) still present — the remaining backlog`, '', '```'); + for (const [check, fp, d] of live) md.push(`${fmtLoc(d, fp)} [${d?.rule || check}] ${d?.message || ''}`); + md.push('```', '
', ''); + } + + for (const [check, e] of Object.entries(report)) { + if (e.skipped) md.push(`> ⚠️ \`${check}\` was **skipped** (${cell(e.reason)}) — not compared.`, ''); + } + + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, md.join('\n') + '\n'); +} + +if (jsonOut) { + console.log(JSON.stringify({ + totalNew, anySkipped, strict, + gated, gatedNew: gated ? gatedNew : undefined, gatedSkipped: gated ? gatedSkipped : undefined, + gatedEmptied: gated ? gatedEmptied : undefined, + emptiedGated: gated ? emptiedGated : undefined, + gatedFindings: gated ? gatedFindings : undefined, + checks: report, + }, null, 2)); +} +// process.exit() truncates buffered stdout when stdout is a pipe — it does not +// wait for the flush. That silently cut the --json payload mid-string. Setting +// exitCode lets Node drain normally and exit with the same status. +process.exitCode = strict && (blockingNew > 0 || blockingSkip || (gated && gatedEmptied)) ? 1 : 0; diff --git a/doc/lint/doc-lint.mjs b/doc/lint/doc-lint.mjs new file mode 100644 index 000000000..b1ddfdf31 --- /dev/null +++ b/doc/lint/doc-lint.mjs @@ -0,0 +1,306 @@ +#!/usr/bin/env node +// +// doc-lint.mjs — structural checks Vale cannot express (Style Guide Part F). +// Node built-ins only, no dependencies. Exit 0 always (warning mode, Task 2); +// findings are emitted as JSON on stdout for `baseline.json` / CI to consume. +// +// Checks: +// A1 — every page under pages/ declares :page-mode:, and the value is one +// of doc/STYLE_GUIDE.md Part A's four Diátaxis modes (tutorial, +// how-to, reference, explanation). Presence alone used to pass; a +// typo or a non-mode value (e.g. the former `concept`) slipped +// through and silently fell out of D2's scope (see below). Bite-test +// per style-guide F4: plant an invalid value, confirm A1 fails. +// A6 — quick-start is within the first 3 top-level nav.adoc entries +// B2 — no [source,] block, and no bare listing (`----` or +// `....`), holds raw code (must start with include::example$... or +// carry role=pseudocode/role=external — doc/STYLE_GUIDE.md B3). A +// bare listing whose attribute line carries role=output/role=figure +// is exempt from B2 outright — it is not code, but see SHAPE below, +// which still looks at it. Originally scoped to [source,cpp]/ +// [source,c++] only, which left [source,cmake]/[source,c]/ +// [source,bash] and bare listings holding real C++ invisible to the +// gate; widened once every such block in the corpus was classified +// (see git history for the audit). Delimiters are matched 4-or-more +// repeats of the character, closer length must equal opener length +// (AsciiDoc's own rule — `-----`/`....` are not `----`, and this is +// also how AsciiDoc nests a `----` inside a `-----`); a fixed +// 4-character match let a 5-dash listing hide code from the gate. +// The attribute list read above the delimiter walks consecutive +// `[source,...]`/`[role=...]` lines, not just the nearest one — +// AsciiDoc permits a block's attribute list to be split across +// adjacent lines (e.g. `[source,cpp]` then `[role=output]` on the +// next line) and Asciidoctor merges them; reading only the nearest +// line missed `[source,...]` set on an earlier line and let a +// highlighted C++ block through as an exempt bare listing. The walk +// deliberately stops at anything else `[...]`-shaped (a block anchor +// `[[id]]`, an admonition style `[NOTE]`, a quote attribution +// `[quote,...]`) — a first cut that merged any `[...]`-shaped line +// made one of those, sitting directly above an exempt +// `[source,...,role=pseudocode]` block, defeat that exemption. +// SHAPE — advisory only, NEVER gated (not in the summary the CI gate +// spec reads by rule prefix). A role=output/role=figure block is a +// permanent B2 exemption, so a block wrongly marked non-code would +// be permanently invisible; SHAPE runs a content heuristic +// (`#include`, `co_await`, `template<`, a brace-opened struct/class, +// a `;`-terminated line, `Name::member(`) over exactly the blocks B2 +// just exempted, and flags ones that look like code. The exemption +// stops being permanent invisibility: the gate still looks, it just +// doesn't block. +// ANCHOR — no prose writes a C++ attribute as `[[...]]` inside a code +// span. Asciidoctor's inline-anchor substitution runs inside a +// backtick span, so `` `[[nodiscard]]` `` is parsed as an anchor and +// renders as an EMPTY element -- the attribute name silently +// disappears from the page. Measured: `[[clang::coro_await_elidable]]` +// rendered as `
`. +// The defect is invisible in the source, which reads correctly, so it +// needs a machine check rather than a careful reader. The fix is a +// passthrough: `` `+[[nodiscard]]+` ``. Only +// prose is scanned -- inside a delimited block `[[` is literal and +// renders fine, which is why the check is line-based with a +// block-depth skip rather than a whole-file regex. Bite-test per +// style-guide F4: put `` `[[nodiscard]]` `` in a page and confirm +// ANCHOR fires. +// D2 — every page in a CONCEPT_DIRS chapter (or quick-start.adoc) has +// >=1 include::example$. D2's "concept page" is a pedagogical +// category, not a Diátaxis mode — deliberately independent of +// :page-mode:, so a page cannot leave D2's scope by declaring a +// different (even a legitimate) mode. See doc/STYLE_GUIDE.md's D2 +// entry for why landing pages (*.intro.adoc, mode: explanation) are +// outside this scope on purpose, not by omission. +// +import fs from 'node:fs'; +import path from 'node:path'; + +const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..'); +// An optional CLI arg overrides which pages tree gets walked, so a self-test +// can point this at a throwaway fixture tree instead of the real corpus. +// A6/D2's nav.adoc is unaffected — those checks are orthogonal to what a +// pages-tree override is for (exercising B2 in isolation). +const PAGES_DIR = process.argv[2] ? path.resolve(process.argv[2]) : path.join(ROOT, 'modules/ROOT/pages'); +const NAV_FILE = path.join(ROOT, 'modules/ROOT/nav.adoc'); + +// The four Diátaxis modes doc/STYLE_GUIDE.md Part A defines. A1 rejects +// anything else, including the legacy `concept` value (never a Diátaxis +// mode — it was D2's subject noun leaking into A1's value namespace). +const VALID_MODES = new Set(['tutorial', 'how-to', 'reference', 'explanation']); + +// Directories whose pages are concept/tutorial material for D2's heuristic. +// Deliberately NOT keyed off :page-mode: — see the D2 comment above. +// `2.networking-tutorial` is deliberately ABSENT: it teaches IP, TCP and UDP +// theory from first principles and introduces no Corosio type, so there is no +// type to show in an example. It is background material, the same carve-out +// Capy's `3a`-`3d` primer carries. Including it would file ~13 findings that no +// example can fix, and the fix for a D2 finding is never a decorative +// `include::example$` -- see doc/STYLE_GUIDE.md's D2 entry. +const CONCEPT_DIRS = [ + '3.tutorials', '4.guide', '5.testing', +]; +const TUTORIAL_FILES = new Set(['quick-start.adoc']); + +function walk(dir) { + let out = []; + for (const ent of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, ent.name); + if (ent.isDirectory()) out = out.concat(walk(p)); + else if (ent.name.endsWith('.adoc')) out.push(p); + } + return out; +} + +function pageMode(text) { + const m = text.match(/^:page-mode:\s*(\S+)/m); + return m ? m[1] : null; +} + +function isConceptOrTutorial(relPath) { + const top = relPath.split(path.sep)[0]; + return TUTORIAL_FILES.has(relPath) || CONCEPT_DIRS.includes(top); +} + +// SHAPE's content heuristic (see the header comment). Deliberately narrow — +// this only needs to catch a block that is CLEARLY code, not judge style. +const CODE_SHAPE_PATTERNS = [ + /#include\b/, + /\bco_await\b/, + /\btemplate\s* CODE_SHAPE_PATTERNS.some((re) => re.test(l))); +} + +// Walk every block delimited by 4-or-more repeats of `ch` (`-` for +// listing/source blocks, `.` for literal blocks). Returns B2 and SHAPE +// findings (kind-tagged; the caller routes them to the right bucket). +function scanBlocks(lines, ch) { + const delim = new RegExp(`^\\${ch}{4,}$`); + const out = []; + let openLen = null; + for (let i = 0; i < lines.length; i++) { + const trimmed = lines[i].trim(); + if (!delim.test(trimmed)) continue; + if (openLen !== null) { + // The closer must repeat `ch` exactly as many times as the opener did; + // a mismatched length is content (or a nested delimiter of a + // different length) and does not close this block. + if (trimmed.length === openLen) openLen = null; + continue; + } + openLen = trimmed.length; + const openerLine = i; + + // The attribute line(s) immediately above (skipping blank lines before + // the stack begins), if any. AsciiDoc permits a block's attribute list + // to be split across multiple adjacent `[...]` lines (no blank line + // between them) and Asciidoctor merges them into one; reading only the + // single nearest line missed a role= or [source,...] marker set on an + // earlier line in the stack, e.g.: + // [source,cpp] + // [role=output] + // ---- + // which used to read attr as just `[role=output]`, miss isSource, and + // fall into the bare-listing branch below instead of B2. Walk upward + // collecting consecutive lines, but ONLY ones that look like a + // continuation of THIS block's attribute list -- `[source,...]` or + // `[role=...]`, the only two shapes isSource/hasClearingRole/ + // hasNonCodeRole below ever inspect. A bare `/^\[.*\]$/` walk is too + // wide: a block anchor (`[[id]]`), an admonition style (`[NOTE]`), or a + // quote attribution (`[quote,...]`) can legitimately sit directly above + // a block with no blank line between, and merging one of those ahead of + // a real `[source,cpp,role=pseudocode]` line made the joined string no + // longer start with `[source`, wrongly flagging an exempt block as B2. + // Stopping the walk at the first non-source/non-role line excludes them. + const ATTR_CONTINUATION = /^\[(?:source\b|role=)/i; + let a = openerLine - 1; + while (a >= 0 && lines[a].trim() === '') a--; + const attrLineIdxs = []; + while (a >= 0 && ATTR_CONTINUATION.test(lines[a].trim())) { + attrLineIdxs.unshift(a); + a--; + } + const attr = attrLineIdxs.map((idx) => lines[idx].trim()).join(' '); + const attrTopLine = attrLineIdxs.length ? attrLineIdxs[0] : openerLine; + const isSource = /^\[source\s*,\s*[^,\]]+/i.test(attr); + const hasClearingRole = /role=(pseudocode|external)\b/.test(attr); + const hasNonCodeRole = /role=(output|figure)\b/.test(attr); + + // A [source,,role=pseudocode|external] block: not a B2 candidate. + // NB this is deliberately isSource-gated — role=output/role=figure must + // NOT clear a [source,*] block (that would make role=output a blanket + // exemption for real code); only pseudocode/external do that, and only + // on a [source,*] block. + if (isSource && hasClearingRole) continue; + + if (!isSource && hasNonCodeRole) { + // Bare listing explicitly marked as program output / a figure: not a + // B2 candidate, but SHAPE still looks at its content (advisory). + const body = []; + for (let m = openerLine + 1; m < lines.length; m++) { + const t = lines[m].trim(); + if (delim.test(t) && t.length === openLen) break; + body.push(lines[m]); + } + if (looksLikeCode(body)) { + out.push({ + kind: 'SHAPE', + line: openerLine + 1, + message: `role=output/role=figure block's content looks like code, not literal output/a figure (advisory, not gated)`, + }); + } + continue; + } + + // Everything else — any [source,] block without a clearing role, + // and any bare listing without a role=output/role=figure marker — must + // open on a compiled include, or it is raw code pasted into the page. + let k = openerLine + 1; + while (k < lines.length && lines[k].trim() === '') k++; + const first = (lines[k] || '').trim(); + if (!first.startsWith('include::example$')) { + const line = attr !== '' ? attrTopLine + 1 : openerLine + 1; + const message = isSource + ? 'raw code, not include::example$/role=pseudocode/role=external' + : 'raw code in a bare listing, not include::example$/role=output/role=figure — this block must not contain code'; + out.push({ kind: 'B2', line, message }); + } + } + return out; +} + +// Prose lines only: a `[[...]]` inside a delimited block is literal and safe. +// Tracks delimiter depth the same way scanBlocks does (4-or-more repeats, the +// closer must match the opener's length) so a `----` nested in a `-----` does +// not end the outer block early and expose its body to the scan. +function scanAnchors(lines) { + const out = []; + let openLen = null; + let openCh = null; + for (let i = 0; i < lines.length; i++) { + const trimmed = lines[i].trim(); + const d = /^([-.=_*+])\1{3,}$/.exec(trimmed); + if (d) { + if (openLen === null) { + openLen = trimmed.length; + openCh = d[1]; + } else if (d[1] === openCh && trimmed.length === openLen) { + openLen = null; + openCh = null; + } + continue; + } + if (openLen !== null) continue; + if (/`\[\[/.test(lines[i])) { + out.push({ + line: i + 1, + message: + 'attribute written as `[[...]]` in a code span renders as an empty ' + + '(Asciidoctor reads it as an inline anchor); use a passthrough `+[[...]]+`', + }); + } + } + return out; +} + +const findings = { A1: [], A6: [], B2: [], SHAPE: [], ANCHOR: [], D2: [] }; +const files = walk(PAGES_DIR); + +for (const file of files) { + const rel = path.relative(PAGES_DIR, file); + const text = fs.readFileSync(file, 'utf8'); + const mode = pageMode(text); + + if (!mode) { + findings.A1.push({ file: rel, message: 'no :page-mode: attribute' }); + } else if (!VALID_MODES.has(mode)) { + findings.A1.push({ file: rel, message: `invalid :page-mode: value '${mode}' (must be one of ${[...VALID_MODES].join(', ')})` }); + } + + // Walk every listing (`----`) and literal (`....`) delimited block — + // source or bare — for B2 and its SHAPE advisory sidecar. + const lines = text.split('\n'); + for (const b of [...scanBlocks(lines, '-'), ...scanBlocks(lines, '.')]) { + findings[b.kind].push({ file: rel, line: b.line, message: b.message }); + } + + for (const a of scanAnchors(lines)) { + findings.ANCHOR.push({ file: rel, line: a.line, message: a.message }); + } + + if (isConceptOrTutorial(rel) && !text.includes('include::example$')) { + findings.D2.push({ file: rel, message: 'tutorial/concept page has no include::example$' }); + } +} + +const navText = fs.readFileSync(NAV_FILE, 'utf8'); +const topEntries = navText.split('\n').filter(l => /^\* xref:/.test(l)); +const qsIndex = topEntries.findIndex(l => /quick-start/.test(l)); +if (qsIndex === -1 || qsIndex > 2) { + findings.A6.push({ file: 'nav.adoc', message: `quick-start at top-level position ${qsIndex + 1}, must be <= 3` }); +} + +const summary = Object.fromEntries(Object.entries(findings).map(([k, v]) => [k, v.length])); +console.log(JSON.stringify({ summary, findings }, null, 2)); +process.exit(0); diff --git a/doc/lint/extract-docstrings.mjs b/doc/lint/extract-docstrings.mjs new file mode 100644 index 000000000..c5283a693 --- /dev/null +++ b/doc/lint/extract-docstrings.mjs @@ -0,0 +1,290 @@ +#!/usr/bin/env node +// +// extract-docstrings.mjs — pulls Doxygen/MrDocs docstring prose out of +// `include/boost/corosio/**/*.hpp` into plain .adoc files so Vale (Style Guide +// Part F) can lint it, not only the Antora `.adoc` pages (Style Guide Part F, +// Task 2 Step 4b). Node built-ins only, no dependencies. +// +// For each header with at least one doc comment, writes a mirrored file under +// OUT_DIR (default doc/lint/.docstrings/, gitignored — generated output, not +// source) containing just the comment prose: `@code`/`@endcode` +// samples are dropped (not prose, and full of identifiers/punctuation that +// would drown real findings); `@param`/`@tparam`/`@return`/etc. tags have +// their tag keyword (and, for `@param`/`@tparam`, the parameter name) removed +// but keep their description text, since that's the part C.2/C.9/C.10 apply +// to. The file extension is .adoc so it picks up the same `[*.adoc]` section +// of doc/.vale.ini used for the Antora pages — no separate Vale config needed. +// +// The parameter name is dropped, not re-emitted as a label. Emitting it back +// as `name: description` — which this script did until 2026-07 — made every +// `@param`/`@tparam` in the library trip Google.Colons, whose token is +// `(? 0), so `li` is the whole set. +const LIST_ITEM = /^@li\b\s*/; +// `@par Some Title` is a SECTION TITLE, not the opening words of the paragraph +// under it, and it carries no terminal punctuation. Left as a bare line it was +// joined into the paragraph's first sentence and inflated its word count — the +// same class of defect as the bold run-in lead, but one no sentence-boundary +// rule can fix, because there is no boundary character to find. Exactly 7 of 202 +// C2 findings started on such a line and two of them were not violations +// (`ex/executor_ref.hpp` "Thread Safety" reported 26 for a real 24; +// `io/any_read_stream.hpp` "Immediate Completion" reported 27 for a real 24). +// So the title is emitted as its own paragraph. A bare `@par` with no title is +// Doxygen's plain paragraph break and contributes nothing. +const PAR_TITLE = /^@par\b\s*/; +// The TARGET is re-emitted as a code span, not as bare text. `@ref X`, `@p X` and +// `@c X` all render as a link or as monospace in the real reference, so a bare `X` +// in the corpus is a lie about the source AND a guaranteed Vale.Spelling false +// positive: the speller sees an identifier sitting in running prose. Measured: the +// bare form put 21 `@see` targets and 13 `@ref` targets into the docstring +// Vale.Spelling backlog, none of them defects, and backticking them in the HEADER +// instead would have broken the link or the parameter binding. `.vale.ini`'s +// TokenIgnores skips backtick spans, so the code-span form is both faithful and +// quiet. +const INLINE_REFS = /@(ref|p|c)\s+(\S+)/g; +// The capture is `\S+` so an odd target still loses its command word, but trailing +// punctuation must stay OUTSIDE the span: `@ref io_stream,` is a reference followed +// by a comma, and `` `io_stream,` `` would put the comma inside the symbol. +const INLINE_REFS_SUB = (_m, _cmd, target) => { + const m = /^([*&]*[A-Za-z_][A-Za-z0-9_:]*(?:\(\))?)([^\w)]*)$/.exec(target); + return m ? `\`${m[1]}\`${m[2]}` : `\`${target}\``; +}; +// `@see` takes a comma-separated list of symbols; each identifier-shaped item gets +// the same treatment. Prose in a @see line is left alone. +const seeList = (text) => text.replace( + /(^|,\s*)([A-Za-z_][A-Za-z0-9_:]*)(?=\s*(?:,|$))/g, + (_, sep, id) => `${sep}\`${id}\``); + +// A Doxygen `@li` item is a sentence, and the extractor used to hand Vale a run +// of them as consecutive lines with the `@li` keyword still in the text. Two +// defects followed, both fixture-confirmed: +// +// * Vale's sentence segmenter needs `. ` to break and will not break on +// `\n@li` (`@` is not a capital), so a run of PERIOD-LESS items collapsed +// into one pseudo-sentence. Five ten-word items produced exactly one +// Corosio.SentenceLength alert whose Match field was literally 'li'; the same +// five items with terminal periods produced none. C2 was therefore measuring +// missing Doxygen punctuation, not sentence length. +// * The surviving `li` keyword spent a phantom word of the 25-word budget, so +// a list item's real limit was 24. A hand-counted 25-word item alerted. +// +// Each item is now emitted as its own paragraph (blank-line delimited, which is +// what makes it a separate block to asciidoctor and so a separate sentence +// scope) with the keyword removed and continuation lines folded in. +function cleanBlock(raw) { + // Drop @code ... @endcode samples entirely — not prose. + const noCode = raw.replace(/@code\b[\s\S]*?@endcode\b/g, ''); + const lines = noCode.split('\n').map((l) => l.trim()); + const prose = []; + let item = null; // text of the `@li` item currently being accumulated + const flush = () => { if (item !== null) { prose.push(item, ''); item = null; } }; + const separate = () => { if (prose.length && prose[prose.length - 1] !== '') prose.push(''); }; + for (let line of lines) { + if (line === '') { + // The item's own trailing blank line stands in for this one. + if (item !== null) flush(); else prose.push(''); + continue; + } + const li = LIST_ITEM.exec(line); + if (li) { + if (item !== null) flush(); else separate(); + item = line.slice(li[0].length).replace(INLINE_REFS, INLINE_REFS_SUB); + continue; + } + const par = PAR_TITLE.exec(line); + if (par) { + flush(); + separate(); + const title = line.slice(par[0].length).replace(INLINE_REFS, INLINE_REFS_SUB).trim(); + // `@par !example ` is a DIRECTIVE for the reference-snippets extension, + // not a section title: the id names a compiled source under + // test/doc/reference and never reaches the reader as prose. Linting it put + // ids like `connect_and_read` and `bind_listen_accept` into the + // Vale.Spelling backlog as permanent, unfixable findings. + if (title && !title.startsWith('!')) prose.push(title, ''); + continue; + } + // A non-blank, non-tag line under an open item is its continuation. + if (item !== null) { + if (!line.startsWith('@')) { item += ` ${line.replace(INLINE_REFS, INLINE_REFS_SUB)}`; continue; } + flush(); + } + line = line.replace(NAMED_TAGS, ''); + { + const wasSee = /^@see\b/.test(line); + line = line.replace(BARE_TAGS, ''); + if (wasSee) line = seeList(line); + } + line = line.replace(INLINE_REFS, INLINE_REFS_SUB); + prose.push(line); + } + flush(); + return prose.join('\n').trim(); +} + +// Every `/* ... */` range in the file, so a `///` line sitting INSIDE one is not +// mistaken for a doc comment of its own. There is no such line in the tree today +// (`grep -rn '^[[:space:]]*\*.*///'` finds none), but the cost of being wrong is a +// commented-out doc comment silently entering the linted corpus. +// Byte ranges of every `namespace detail { ... }` block, brace-matched. A public +// header can carry detail helpers (run_async.hpp, when_all.hpp, when_any.hpp, +// executor_ref.hpp, buffers/asio.hpp all do), and mrdocs.yml marks +// `boost::corosio::detail` AND `boost::corosio::*::detail` implementation-defined — so +// those doc comments are no more published than the ones under detail/. +// Excluding the directory alone would leave 33 of them in the corpus. +function detailNamespaceRanges(text) { + const ranges = []; + for (const m of text.matchAll(/\bnamespace\s+detail\s*\{/g)) { + let depth = 0; + for (let j = m.end !== undefined ? m.end : m.index + m[0].length - 1; j < text.length; j++) { + if (text[j] === '{') depth++; + else if (text[j] === '}') { depth--; if (depth === 0) { ranges.push([m.index, j]); break; } } + } + } + return ranges; +} + +function blockCommentRanges(text) { + const ranges = []; + for (const m of text.matchAll(/\/\*[\s\S]*?\*\//g)) ranges.push([m.index, m.index + m[0].length]); + return ranges; +} + +// Doc comments in source order. `/** ... */` blocks come from a direct match; a +// run of consecutive `///` lines is collected into one block, ended by the first +// line that is not a `///` line (blank, code, or anything else) — the same rule +// Doxygen applies. `///<` (trailing member doc) and `////`-style separator rules +// are matched by neither pattern; the tree contains none of either. +function docComments(text) { + const found = []; + for (const m of text.matchAll(/\/\*\*([\s\S]*?)\*\//g)) found.push({ at: m.index, raw: m[1] }); + + const inDetail = detailNamespaceRanges(text); + const inBlock = blockCommentRanges(text); + const covered = (off) => inBlock.some(([a, b]) => off >= a && off < b); + const lineRe = /^[ \t]*\/\/\/(?!\/)(.*)$/; + let off = 0; + let run = null; // { at, lines: [] } + const flushRun = () => { if (run) { found.push({ at: run.at, raw: run.lines.join('\n') }); run = null; } }; + for (const line of text.split('\n')) { + const m = lineRe.exec(line); + if (m && !covered(off)) { + if (!run) run = { at: off, lines: [] }; + run.lines.push(m[1].replace(/^[ \t]/, '')); + } else { + flushRun(); + } + off += line.length + 1; + } + flushRun(); + + // Keep the byte offset so the caller can turn it into a source line. The + // extracted .adoc is a generated file nobody edits, so a finding reported + // against it is unactionable without a way back to the .hpp. See the + // sidecar written below. + const lineOf = (off) => text.slice(0, off).split('\n').length; // 1-based + return found + .filter((d) => !inDetail.some(([a, b]) => d.at >= a && d.at < b)) + .sort((a, b) => a.at - b.at) + .map((d) => ({ raw: d.raw, srcLine: lineOf(d.at) })); +} + +// Clear the output tree first. The generator never used to, so a header that +// stops producing output — renamed, deleted, or now excluded — left its last +// .adoc behind for Vale to keep linting. That is also the stale-corpus hazard +// baseline.mjs guards against from the other side. +fs.rmSync(OUT_DIR, { recursive: true, force: true }); + +let written = 0; +for (const file of walk(INCLUDE_ROOT)) { + const text = fs.readFileSync(file, 'utf8'); + const found = docComments(text) + .map((d) => ({ text: cleanBlock(d.raw), srcLine: d.srcLine })) + .filter((d) => d.text); + if (found.length === 0) continue; + + const rel = path.relative(INCLUDE_ROOT, file); + const outPath = path.join(OUT_DIR, `${rel}.adoc`); + fs.mkdirSync(path.dirname(outPath), { recursive: true }); + fs.writeFileSync(outPath, found.map((d) => d.text).join('\n\n') + '\n'); + + // Sidecar line map: output line (1-based, into the .adoc just written) -> + // the line in the ORIGINAL header where that doc comment starts. Written + // beside the .adoc rather than into it, so the extracted prose stays + // byte-identical and no baseline fingerprint moves. + // + // Granularity is per doc comment, not per sentence: cleanBlock() reflows + // `@li` items and folds continuation lines, so an output line has no single + // source line. The comment's start line plus the excerpt the reporter + // quotes is enough to land on the right block in a 200-line header. + const spans = []; + let out = 1; + for (const d of found) { + const n = d.text.split('\n').length; + spans.push({ from: out, to: out + n - 1, srcLine: d.srcLine }); + out += n + 1; // the '\n\n' join contributes one blank line between blocks + } + fs.writeFileSync(`${outPath}.lines.json`, JSON.stringify({ + source: path.relative(REPO_ROOT, file), spans, + })); + written++; +} + +console.log(JSON.stringify({ headersWithDocs: written, outDir: path.relative(REPO_ROOT, OUT_DIR) }, null, 2)); diff --git a/doc/lint/fixtures/include/detail/excluded.hpp b/doc/lint/fixtures/include/detail/excluded.hpp new file mode 100644 index 000000000..e6f33d955 --- /dev/null +++ b/doc/lint/fixtures/include/detail/excluded.hpp @@ -0,0 +1,22 @@ +// +// Copyright (c) 2026 Michael Vandeberg +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/corosio +// +// Fixture for selftest.mjs. Lives under detail/, so extract-docstrings.mjs must +// skip the whole file — covering the directory half of the exclusion, which the +// `namespace detail` fixture does not reach. +#ifndef BOOST_COROSIO_FIXTURE_DETAIL_EXCLUDED_HPP +#define BOOST_COROSIO_FIXTURE_DETAIL_EXCLUDED_HPP + +namespace boost::corosio::detail { + +/** This whole file is under detail/ and must not reach the linted corpus. */ +void excluded_by_directory(); + +} // namespace boost::corosio::detail + +#endif diff --git a/doc/lint/fixtures/include/slash_only.hpp b/doc/lint/fixtures/include/slash_only.hpp new file mode 100644 index 000000000..11017aba1 --- /dev/null +++ b/doc/lint/fixtures/include/slash_only.hpp @@ -0,0 +1,30 @@ +// +// Copyright (c) 2026 Michael Vandeberg +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/corosio +// +// Fixture for selftest.mjs. Its ONLY doc comments are `///` runs, so it produces +// no output at all unless extract-docstrings.mjs's `///` branch works. The real +// header tree no longer contains such a file (every `///`-only header lived under +// detail/, which is excluded), which is why this is a fixture and not a probe. +#ifndef BOOST_COROSIO_FIXTURE_SLASH_ONLY_HPP +#define BOOST_COROSIO_FIXTURE_SLASH_ONLY_HPP + +namespace boost::corosio { + +/// Reports whether the fixture extracted. +/// A second line, folded into the same doc comment. +void slash_only_public(); + +namespace detail { + +/// This one is inside namespace detail and must NOT be extracted. +void slash_only_detail(); + +} // namespace detail +} // namespace boost::corosio + +#endif diff --git a/doc/lint/fixtures/modules/ROOT/pages/2.networking-tutorial/advisory.adoc b/doc/lint/fixtures/modules/ROOT/pages/2.networking-tutorial/advisory.adoc new file mode 100644 index 000000000..30f025713 --- /dev/null +++ b/doc/lint/fixtures/modules/ROOT/pages/2.networking-tutorial/advisory.adoc @@ -0,0 +1,8 @@ += Advisory-slice fixture +:page-mode: concept + +// This page is under 9.design/, so its findings must be keyed `advisory-C2` and +// must NOT be reachable from a `--gate 'sentence_length:^C2:'` spec. +Advisory one two three four five six seven eight nine ten eleven twelve thirteen +fourteen fifteen sixteen seventeen eighteen nineteen twenty twentyone twentytwo +twentythree twentyfour twentyfive twentysix twentyseven. diff --git a/doc/lint/fixtures/modules/ROOT/pages/2.networking-tutorialish/lookalike.adoc b/doc/lint/fixtures/modules/ROOT/pages/2.networking-tutorialish/lookalike.adoc new file mode 100644 index 000000000..ad35a173e --- /dev/null +++ b/doc/lint/fixtures/modules/ROOT/pages/2.networking-tutorialish/lookalike.adoc @@ -0,0 +1,8 @@ += Look-alike directory fixture +:page-mode: concept + +// `9.designish/` is NOT `9.design/`. The advisory test matches a whole path +// segment, so this page's finding must stay in the HARD slice. +Lookalike one two three four five six seven eight nine ten eleven twelve thirteen +fourteen fifteen sixteen seventeen eighteen nineteen twenty twentyone twentytwo +twentythree twentyfour twentyfive twentysix twentyseven. diff --git a/doc/lint/fixtures/modules/ROOT/pages/4.guide/hard.adoc b/doc/lint/fixtures/modules/ROOT/pages/4.guide/hard.adoc new file mode 100644 index 000000000..a3b2e1103 --- /dev/null +++ b/doc/lint/fixtures/modules/ROOT/pages/4.guide/hard.adoc @@ -0,0 +1,42 @@ += Hard-slice fixtures +:page-mode: concept + +// BACKTICK GUARD. 30 words, with one STRAY backtick and one balanced span, so the +// block's backtick count is odd. If the guard breaks, the length-preserving mask +// pairs the stray backtick with the balanced one and collapses everything between +// them into a single word: this sentence measures 8, produces NO finding, and +// nothing says so. Expect: one finding at 30 words AND one BACKTICK diagnostic. +One two three four five six `seven eight nine ten eleven twelve thirteen fourteen +fifteen sixteen seventeen eighteen nineteen twenty twentyone twentytwo twentythree +twentyfour twentyfive twentysix twentyseven twentyeight twentynine `thirty`. + +// ELLIPSIS, the only known UNDER-reporting case. 34 words with a mid-sentence +// `...`. If the boundary guard breaks this splits into 16 + 18 and is missed. +// The ellipsis is kept off the start of a line on purpose: a leading `... ` is a +// legitimate AsciiDoc level-3 ordered-list marker, and treating it as one is +// correct, so a line-initial ellipsis would test the wrong thing. +Alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi ... omicron +pi rho sigma tau upsilon phi chi psi omega alef bet gimel dalet he vav zayin +het tet yod. + +// PARENTHESISED ABBREVIATION, the other under-reporting case. 30 words. +Aone atwo athree afour afive asix aseven aeight anine aten (e.g.) aeleven atwelve +athirteen afourteen afifteen asixteen aseventeen aeighteen anineteen atwenty +atwentyone atwentytwo atwentythree atwentyfour atwentyfive atwentysix +atwentyseven atwentyeight atwentynine athirty. + +// BOLD RUN-IN LEAD. The lead is its own sentence; the tail is 12 words. Neither +// is over the limit, so a merge would be visible as a spurious finding here. +*The library owns the handles.* Corosio creates and manages the buffer handles and the handle sequences. + +// READER WORD COUNTING. 30 tokens under the retired Vale token, 25 as a reader +// counts: five hyphen compounds, one possessive, one contraction, one slashed +// list of three, one dotted form and one qualified identifier. Expect NO finding. +The most-derived fine-grained copy-on-write single-threaded well-known caller's don't read/write/seek buffer_array.hpp this_coro::executor_tag alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu. + +// CODE BLOCKS ARE NOT PROSE. If this is ever linted, the identifier below makes +// it obvious in the finding text. +[source,cpp] +---- +auto zzq = utilize_and_leverage(one, two, three, four, five, six, seven, eight, nine, ten, eleven, twelve, thirteen, fourteen, fifteen, sixteen, seventeen, eighteen, nineteen, twenty, twentyone, twentytwo, twentythree, twentyfour, twentyfive, twentysix); +---- diff --git a/doc/lint/mrdocs-warnings.mjs b/doc/lint/mrdocs-warnings.mjs new file mode 100644 index 000000000..228656d4a --- /dev/null +++ b/doc/lint/mrdocs-warnings.mjs @@ -0,0 +1,258 @@ +#!/usr/bin/env node +// +// mrdocs-warnings.mjs — reference-surface gate (Style Guide Part F.0, +// "MrDocs-no-warnings"). Node built-ins only, no dependencies. +// +// MrDocs has no standalone CLI package: it runs inside +// @cppalliance/antora-cpp-reference-extension during `npx antora`. Scanning +// the *captured Antora build log* does not work — the extension's runCommand +// helper only forwards MrDocs's stderr to the console; MrDocs prints its +// per-symbol "undocumented"/"missing param doc" findings to stdout, and the +// extension swallows stdout into an internal buffer it never surfaces +// (lib/extension.js, runCommand: `output` is not set for the MrDocs +// invocation, so `ps.stdout` data goes to an array, not the console). +// Verified locally: an Antora build with mrdocs.yml's warning flags on +// produced 0 visible findings in the build log, while invoking the same +// MrDocs binary/config/args directly produced 208. +// +// So this script invokes MrDocs directly, with the same config file and CLI +// arguments the extension uses (mirrored from its debug log), then parses +// combined stdout+stderr itself. It mirrors the extension's own compiler +// preference (clang++/clang over g++/gcc — the extension does this because +// this MrDocs build crashes with a stray GCC-only header path; reproduced +// locally with GCC 16.1.1) so results match what a real Antora build would +// hit if not for the stdout-swallowing bug above. +// +// Non-blocking (Task 2): always exits 0. Findings are JSON on stdout. +// +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const DOC_DIR = path.resolve(SCRIPT_DIR, '..'); +const REPO_ROOT = path.resolve(DOC_DIR, '..'); +const CONFIG_PATH = path.join(DOC_DIR, 'mrdocs.yml'); + +function emit(payload) { + console.log(JSON.stringify(payload, null, 2)); + process.exit(0); +} + +// MRDOCS_ROOT, when set, is AUTHORITATIVE and no version check is applied to it. +// doc/build_antora.sh installs the reference-snippets extension into exactly one +// MrDocs and exports MRDOCS_ROOT (writing it to $GITHUB_ENV so it survives into +// later workflow steps). That install is the one the rendered reference was +// generated with, which is the invariant this check actually needs: measure the +// reference surface with the same MrDocs that produced it. +// +// It must not be version-matched, because the `develop-release` asset is a moving +// target. Measured: the same asset name reported `0.8.0+e31308f6c944` locally and +// `2026.9.5` in CI days apart. A pin against it fails CLOSED but uselessly — the +// check reports SKIPPED, and a reseed then tries to wipe the whole grandfathered +// mrdocs_warnings backlog. That is exactly what happened on the first CI reseed, +// and the baseline-diff safety net is what caught it. +// +// The pin survives only as a tiebreaker for the FALLBACK cache scan, where the +// reference-collector cache can hold several `mrdocs` binaries (the `develop` and +// `master` tags) and picking the first would be nondeterministic. Override with +// MRDOCS_VERSION. The resolved binary and its reported version are emitted in the +// payload either way, so a scheme change is visible instead of silent. +const PINNED_VERSION = process.env.MRDOCS_VERSION || '0.8.0'; + +function findOnPath(names) { + return findAllOnPath(names)[0] || null; +} + +function findAllOnPath(names) { + const found = []; + const dirs = (process.env.PATH || '').split(path.delimiter); + for (const name of names) { + for (const dir of dirs) { + const candidate = path.join(dir, name); + try { + fs.accessSync(candidate, fs.constants.X_OK); + found.push(candidate); + } catch { /* not here */ } + } + } + return found; +} + +// Search the Antora reference-collector cache the extension populates +// (getUserCacheDir('antora')/reference-collector/mrdocs///bin/mrdocs). +// Returns EVERY executable found so the caller can pick the pin-matching one. +function findMrDocsUnder(...bases) { + const found = []; + bases = bases.filter(Boolean); + for (const base of bases) { + if (!fs.existsSync(base)) continue; + const stack = [base]; + while (stack.length) { + const dir = stack.pop(); + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; } + for (const ent of entries) { + const p = path.join(dir, ent.name); + if (ent.isDirectory()) stack.push(p); + else if (ent.name === 'mrdocs' || ent.name === 'mrdocs.exe') { + try { fs.accessSync(p, fs.constants.X_OK); found.push(p); } catch { /* skip */ } + } + } + } + } + return found; +} + +// Base version (X.Y.Z, build metadata after `+` stripped) reported by a +// candidate binary, or null if it can't be run / parsed. +function mrdocsBaseVersion(exe) { + const r = spawnSync(exe, ['--version'], { encoding: 'utf8' }); + if (r.error || (r.status !== 0 && r.status !== null)) return null; + const out = `${r.stdout || ''}${r.stderr || ''}`; + const m = out.match(/MrDocs\s+version\s+(\S+)/i) || out.match(/(\d+\.\d+\.\d+)/); + return m ? m[1].split('+')[0] : null; +} + +// MRDOCS_ROOT wins outright when it is set; otherwise fall back to PATH and the +// reference-collector cache, where the pin disambiguates. +const rootCandidates = findMrDocsUnder(process.env.MRDOCS_ROOT); +const candidates = rootCandidates.length + ? rootCandidates + : [...findAllOnPath(['mrdocs', 'mrdocs.exe']), + ...findMrDocsUnder(path.join(os.homedir(), '.cache/antora/reference-collector/mrdocs'))]; +if (candidates.length === 0) { + emit({ + error: 'mrdocs executable not found (checked PATH and the Antora reference-collector cache). ' + + 'Run the Antora build first (it downloads MrDocs), then re-run this script.', + summary: { total: 0 }, + findings: [], + }); +} + +// From MRDOCS_ROOT: take it as-is. From the fallback scan: take the first whose +// BASE version (the `X.Y.Z` before any `+build` metadata) matches the pin. +let mrdocsExe = null; +let mrdocsVersion = null; +const inspected = []; +if (rootCandidates.length) { + mrdocsExe = rootCandidates[0]; + mrdocsVersion = mrdocsBaseVersion(mrdocsExe); + inspected.push(`${mrdocsExe} => ${mrdocsVersion ?? '(version unreadable)'} [MRDOCS_ROOT]`); + if (mrdocsVersion === null) { + // Unreadable means it cannot be run at all. Fail rather than scan with it: + // a skipped check is what wipes a gated backlog on the next reseed. + emit({ + error: `MRDOCS_ROOT names a MrDocs that will not report a version: ${mrdocsExe}`, + summary: { total: 0 }, + findings: [], + }); + } +} else { + for (const c of candidates) { + const v = mrdocsBaseVersion(c); + inspected.push(`${c} => ${v ?? '(version unreadable)'}`); + if (v === PINNED_VERSION) { mrdocsExe = c; mrdocsVersion = v; break; } + } + if (!mrdocsExe) { + emit({ + error: `no MrDocs binary matching pinned version ${PINNED_VERSION} found, and ` + + `MRDOCS_ROOT is not set (set MRDOCS_ROOT to the install the docs were ` + + `built with, or MRDOCS_VERSION to change the fallback pin). ` + + `Candidates inspected:\n ${inspected.join('\n ')}`, + summary: { total: 0 }, + findings: [], + }); + } +} + +if (!fs.existsSync(CONFIG_PATH)) { + emit({ error: `mrdocs.yml not found: ${CONFIG_PATH}`, summary: { total: 0 }, findings: [] }); +} + +// Mirror CppReferenceExtension.findCXXCompilers(): clang++/clang preferred over g++/gcc. +const cxx = findOnPath(['clang++']) || process.env.CXX_COMPILER || process.env.CXX || findOnPath(['g++']); +const cc = findOnPath(['clang']) || process.env.C_COMPILER || process.env.CC || findOnPath(['gcc']); + +const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mrdocs-warnings-')); +const args = [ + `--config=${CONFIG_PATH}`, + `--output=${outDir}`, + '--generator=adoc', + '--multipage=true', + '--tagfile=reference.tag.xml', +]; + +const result = spawnSync(mrdocsExe, args, { + cwd: REPO_ROOT, + env: { ...process.env, ...(cxx ? { CXX: cxx, CMAKE_CXX_COMPILER: cxx } : {}), ...(cc ? { CC: cc, CMAKE_C_COMPILER: cc } : {}) }, + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, +}); + +fs.rmSync(outDir, { recursive: true, force: true }); + +if (result.error || (result.status !== 0 && result.status !== null)) { + emit({ + error: `mrdocs exited with status ${result.status}: ${result.error?.message || '(see stderr)'}`, + stderrTail: (result.stderr || '').slice(-2000), + summary: { total: 0 }, + findings: [], + }); +} + +const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, ''); +const relIncludePath = (p) => p.replace(/^.*?(include\/boost\/corosio\/.*)$/, '$1'); +const combined = stripAnsi(`${result.stdout || ''}\n${result.stderr || ''}`); +const lines = combined.split('\n'); + +// MrDocs prints ":::" then indented "N) " items for +// that location; separately it prints bare "warning: ..." lines (e.g. +// unsupported HTML tags) with no location. CMake's own "CMake Warning" noise +// is excluded — it is a build-system warning, not a reference-surface one. +const LOC_RE = /^(\/\S+\.(?:hpp|cpp|ipp)):(\d+):(\d+):\s*$/; +const ITEM_RE = /^\s*\d+\)\s*(.+)$/; +const BARE_WARNING_RE = /^warning:\s*(.+)$/; + +// `boost`/`corosio` namespaces get flagged by warn-if-undocumented too, but a +// namespace isn't a documentable symbol in the sense this gate cares about +// (no @brief slot maps onto a namespace declaration the way it does onto a +// class/function), and the finding is fingerprint-unstable across +// environments (see the file header: local vs. CI MrDocs build/order +// differences). Drop these here so they never enter the gate's finding list, +// in both baseline generation and the live check (same script). All other +// MrDocs warning classes (undocumented symbol/param, broken refs, etc.) are +// left intact. +const NAMESPACE_UNDOCUMENTED_RE = /namespace is undocumented/; + +const findings = []; +let currentLoc = null; +for (const line of lines) { + const loc = line.match(LOC_RE); + if (loc) { + currentLoc = { file: relIncludePath(loc[1]), line: Number(loc[2]) }; + continue; + } + const item = line.match(ITEM_RE); + if (item && currentLoc) { + const message = item[1].trim(); + if (!NAMESPACE_UNDOCUMENTED_RE.test(message)) { + findings.push({ file: currentLoc.file, line: currentLoc.line, message }); + } + continue; + } + const bare = line.match(BARE_WARNING_RE); + if (bare) { + const message = bare[1].trim(); + if (!NAMESPACE_UNDOCUMENTED_RE.test(message)) { + findings.push({ file: null, line: null, message }); + } + } +} + +// Report which binary was used and what version it claims, so a develop-release +// version-scheme change is visible in the run log instead of silent. +emit({ mrdocs: { exe: mrdocsExe, version: mrdocsVersion, source: rootCandidates.length ? 'MRDOCS_ROOT' : 'pin' }, + summary: { total: findings.length }, findings }); diff --git a/doc/lint/selftest.mjs b/doc/lint/selftest.mjs new file mode 100644 index 000000000..1785e0825 --- /dev/null +++ b/doc/lint/selftest.mjs @@ -0,0 +1,516 @@ +#!/usr/bin/env node +// +// Copyright (c) 2026 Michael Vandeberg +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/corosio +// +// selftest.mjs — asserts that sentence-length.mjs and doc-lint.mjs's B2 check +// still detect what they claim to, against the checked-in corpus in +// lint/fixtures/ (sentence-length.mjs) or a throwaway fixture tree built at +// run time (doc-lint.mjs's B2 section). Node built-ins only. Exit 0 = all +// assertions hold; exit 1 = at least one broke, with the failure named. Run +// it after any edit to sentence-length.mjs or doc-lint.mjs. +// +// Why this exists. The C2 checker is on its way to becoming a merge-blocking +// gate, and the properties below are exactly the ones whose failure is SILENT: +// nothing in the real corpus exercises them, so a plausible refactor can retire +// a protection and every downstream number still looks reasonable. Two such +// refactors were demonstrated on the unbalanced-backtick guard alone — making +// the backtick pattern lenient returns the diagnostic count to 0 and makes a +// 30-word sentence vanish with no finding at all, and renaming the rule's `id` +// without updating maskBlock()'s skip string keeps the diagnostic but silently +// drops the count correction. Both pass every corpus-level check. Neither passes +// this file. +// +// Fixtures live in lint/fixtures/ and are NOT part of either linted corpus: +// Vale runs on `modules` and `lint/.docstrings` only, and Antora reads +// modules/ via antora.yml, so nothing else sees them. +// +// Usage: node doc/lint/selftest.mjs +// +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const DOC_DIR = path.resolve(SCRIPT_DIR, '..'); +const FIXTURES = path.join(SCRIPT_DIR, 'fixtures', 'modules'); + +const r = spawnSync('node', [path.join(SCRIPT_DIR, 'sentence-length.mjs'), FIXTURES], + { encoding: 'utf8', cwd: DOC_DIR, maxBuffer: 16 * 1024 * 1024 }); +if (r.status !== 0) { + console.error(`selftest: sentence-length.mjs exited ${r.status}\n${r.stderr || r.stdout}`); + process.exit(1); +} +const out = JSON.parse(r.stdout); +const hard = out.findings.C2; +const advisory = out.findings['advisory-C2']; +const backtick = out.findings.BACKTICK; + +// A finding is identified by the first word of its sentence, which is unique per +// fixture and survives a change to line numbering. +const lead = (f) => f.sentence.replace(/^[^A-Za-z`]*/, '').split(/[\s`]+/)[0]; +const find = (arr, word) => arr.filter((f) => lead(f) === word); + +const failures = []; +// Counted, not hand-written: a literal here goes stale silently, and a total +// nobody can trust is worse than no total. `check` tallies itself and the +// summary reports the tally. one()/none() delegate to check(), so they are +// counted there. +let assertionCount = 0; +function check(name, cond, detail) { + assertionCount++; + if (cond) return; + failures.push(`${name}${detail ? ` — ${detail}` : ''}`); +} +function one(name, arr, word, words) { + const hits = find(arr, word); + if (hits.length !== 1) { + assertionCount++; + failures.push(`${name} — expected exactly 1 finding leading with '${word}', got ${hits.length}`); + return; + } + check(name, hits[0].words === words, `'${word}' measured ${hits[0].words} words, expected ${words}`); +} +function none(name, arr, word) { + const hits = find(arr, word); + check(name, hits.length === 0, + `expected no finding leading with '${word}', got ${hits.length} (${hits.map((h) => `${h.words}w`).join(', ')})`); +} + +// 1. The unbalanced-backtick guard. Both halves matter: the diagnostic must be +// emitted AND the sentence must still be measured at its full length. A +// lenient backtick pattern loses both; a stale skip id loses only the second. +one('backtick guard: sentence measured at full length', hard, 'One', 30); +check('backtick guard: diagnostic emitted', backtick.length === 1, + `expected 1 BACKTICK finding, got ${backtick.length}`); +check('backtick guard: summary counts it', out.summary.unbalancedBackticks === 1, + `summary.unbalancedBackticks = ${out.summary.unbalancedBackticks}`); + +// 2. The two UNDER-reporting cases. These are the only known ways a real +// violation can slip through, so they are the assertions that protect the +// gate's floor rather than its ceiling. +one('mid-sentence ellipsis does not split the sentence', hard, 'Alpha', 34); +one('parenthesised abbreviation does not split the sentence', hard, 'Aone', 31); + +// 3. Bold run-in lead is its own sentence, so neither half is over the limit. +none('bold run-in lead does not merge into the next sentence', hard, 'The'); + +// 4. Reader word counting. 30 tokens under the retired Vale token, 25 as a +// reader counts, so any regression in the connector set makes this fire. +none('reader word counting keeps a 25-word sentence under the limit', hard, 'most-derived'); + +// 5. Code blocks are not prose. +check('code blocks are not linted as prose', + !JSON.stringify(out.findings).includes('utilize_and_leverage'), + 'a [source,cpp] block reached the linter'); + +// 6. The hard/advisory partition, in both directions, including the look-alike +// directory that must NOT be treated as an essay. +one('2.networking-tutorial/ findings are advisory', advisory, 'Advisory', 28); +none('2.networking-tutorial/ findings are not in the hard slice', hard, 'Advisory'); +one('a 2.networking-tutorialish/ look-alike stays in the hard slice', hard, 'Lookalike', 28); + +// 7. The gate-reachability property itself, tested on the rule keys rather than +// asserted in a comment: a `^C2:` spec must reach the hard key and nothing +// else, whatever the keys are renamed to. +const GATE = /^C2:/; +const keys = Object.keys(out.findings); +check('rule keys are C2 / advisory-C2 / BACKTICK', keys.join(',') === 'C2,advisory-C2,BACKTICK', + `got '${keys.join(',')}'`); +check('only the hard key is reachable from a ^C2: gate spec', + keys.filter((k) => GATE.test(`${k}:some/file.adoc:#1:message`)).join(',') === 'C2', + `reachable keys: '${keys.filter((k) => GATE.test(`${k}:f:#1:m`)).join(',')}'`); + +// 8. extract-docstrings.mjs covers BOTH Doxygen comment forms. `///` runs were +// invisible to every gate until 2026-08 — 86 published doc lines across 25 +// headers, and the gap surfaced only because a bite test happened to plant its +// first probe in a `///` comment. A tightened regex or a reverted branch would +// retire the coverage silently: the corpus just gets smaller, every count drops, +// and nothing reads as broken. The expectations below are DERIVED from the real +// header tree rather than written down, so they cannot go stale. +const FIXTURE_INCLUDE = path.join(SCRIPT_DIR, 'fixtures/include'); +const TMP_OUT = fs.mkdtempSync(path.join(os.tmpdir(), 'corosio-selftest-docstrings-')); +try { + // Run against a FIXTURE header tree, not the live one. The old form derived its + // sample from the real corpus — "headers with `///` and no `/** */`" — which + // silently depended on which headers happened to exist. Every such header lived + // under detail/, so excluding detail/ from extraction broke the assertion for a + // legitimate reason. A fixture cannot go stale that way. + const x = spawnSync('node', + [path.join(SCRIPT_DIR, 'extract-docstrings.mjs'), TMP_OUT, FIXTURE_INCLUDE], + { encoding: 'utf8', cwd: DOC_DIR, maxBuffer: 16 * 1024 * 1024 }); + check('extract-docstrings.mjs exits 0', x.status === 0, + `exited ${x.status}: ${(x.stderr || '').trim().slice(-200)}`); + + const out = path.join(TMP_OUT, 'slash_only.hpp.adoc'); + const got = fs.existsSync(out) ? fs.readFileSync(out, 'utf8') : ''; + check('`///` doc comments are extracted', got.includes('Reports whether the fixture extracted'), + 'the `///`-only fixture header produced no output — the `///` branch is not firing'); + check('`///` continuation lines fold into one comment', + got.includes('folded into the same doc comment'), 'second `///` line was dropped'); + // detail/ is excluded by directory; this covers the OTHER half — `namespace detail` + // inside an otherwise-public header, which 5 real headers have. + check('doc comments inside `namespace detail` are NOT extracted', + !got.includes('must NOT be extracted'), + 'a `namespace detail` doc comment reached the linted corpus'); + // The other half of the exclusion: a whole file under detail/. Covered + // separately because the `namespace detail` fixture cannot reach it — a + // mutation removing the directory skip passed until this fixture existed. + check('headers under detail/ are NOT extracted at all', + !fs.existsSync(path.join(TMP_OUT, 'detail/excluded.hpp.adoc')), + 'a header under detail/ produced output in the linted corpus'); +} finally { + fs.rmSync(TMP_OUT, { recursive: true, force: true }); +} + +// 9. doc-lint.mjs's B2 check ("no code block holds raw code") must reach +// every [source,] block, not just [source,cpp]/[source,c++] — that +// was the whole gap a prior widening closed — and must also reach bare +// `----`/`....` listings, while leaving role=pseudocode/external/ +// output/figure and include::example$ blocks alone. There was previously +// no self-test coverage for doc-lint.mjs at all, so a regex narrowed back +// to one language, or a bare-listing branch that stopped firing, would +// pass every corpus-level check silently. Exercised against a throwaway +// fixture tree (not lint/fixtures/, which only sentence-length.mjs reads) +// so a real corpus edit can't perturb these counts. +// +// flagged.adoc's `[source,cmake]` case is a weaker property than its name +// suggests: narrowing isSource back to cpp/c++-only does NOT clear that +// finding, because a de-recognized [source,cmake] block still falls into +// the bare-listing branch and gets flagged there instead (same result, +// different code path). The real proof that isSource covers every +// language lives on the CLEARING side, in clear.adoc: a +// [source,cmake,role=pseudocode] block is invisible to B2 only if +// isSource recognizes cmake — if it doesn't, that block falls into the +// bare-listing branch too, where role=pseudocode is NOT a recognized +// non-code role, and it lights up clear.adoc instead. That is where an +// isSource regression actually surfaces; flagged.adoc's cmake case is +// kept only because catching the "still gets flagged, for the wrong +// reason" case is itself worth asserting. +{ + const DOCLINT_TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'corosio-selftest-doclint-')); + try { + const write = (rel, body) => { + const fp = path.join(DOCLINT_TMP, rel); + fs.mkdirSync(path.dirname(fp), { recursive: true }); + fs.writeFileSync(fp, body); + }; + // One [source,cmake] block with no clearing role (see the comment + // above — flagged via isSource OR the bare-listing fallback, either + // way); one bare `----` block with no role=output/role=figure marker + // and real code in it (bare listings were invisible to any + // [source,...] regex before B2 was widened); and one [source,cpp, + // role=output] block — role=output/role=figure must clear ONLY a bare + // listing, never a [source,*] block. A mutation that ORs hasClearingRole + // and hasNonCodeRole together (ignoring isSource) makes role=output a + // blanket exemption for real C++ and this block stops being flagged. + write('flagged.adoc', [ + ':page-mode: how-to', + '', + '= Flagged', + '', + '[source,cmake]', + '----', + 'add_executable(x x.cpp)', + '----', + '', + '----', + 'int x = 1;', + '----', + '', + '[source,cpp,role=output]', + '----', + 'int y = 2;', + '----', + '', + ].join('\n')); + // Every exemption B2 recognizes, one of each, all in a single page that + // must produce zero findings. role=pseudocode and role=external are + // BOTH tested here deliberately: they are two different alternatives in + // the same regex, and dropping either one independently keeps this + // fixture passing for the OTHER unless both are exercised (dropping + // `external` alone was a measured miss — the corpus is cleared mostly + // by `external`, not `pseudocode`, e.g. 9k/9l/9n/9o/5d). + write('clear.adoc', [ + ':page-mode: how-to', + '', + '= Clear', + '', + '[source,cmake,role=pseudocode]', + '----', + 'add_executable(x x.cpp)', + '----', + '', + '[source,cpp,role=external]', + '----', + 'task async_work();', + '----', + '', + '[role=output]', + '----', + 'build succeeded', + '----', + '', + '[role=figure]', + '----', + '[A] --> [B]', + '----', + '', + '[source,cpp]', + '----', + 'include::example$foo.cpp[tag=bar]', + '----', + '', + ].join('\n')); + // SHAPE: a role=output block whose content looks like code is a + // permanent B2 blind spot by design (that is what role=output is FOR), + // so SHAPE must still flag it — advisory, not gated. A genuine output + // block (no code-shaped line) must not trip SHAPE at all. + write('shape.adoc', [ + ':page-mode: how-to', + '', + '= Shape', + '', + '[role=output]', + '----', + 'int z = 3;', + '----', + '', + '[role=output]', + '----', + 'Hello from Corosio!', + '----', + '', + ].join('\n')); + // I1/I2: a 5-dash listing must not hide code from B2 (closer length + // must match opener length, not just be >=4), and a `....` literal + // block is a second bare-listing syntax B2 must also reach. The + // 5-dash case above pairs a matching 5-dash closer with its 5-dash + // opener, which does NOT exercise the closer-length check at all — a + // mutation loosening it from `===` to `>=` still passes that case. + // The role=figure block below is the real regression this fixture was + // missing (measured against the real corpus, 9b.Separation.adoc's CCD + // diagram): its body contains a dash-only line LONGER than its own + // 4-dash opener. Under `===` this is correctly just content, and the + // real closer below it ends the block with zero findings. Under `>=` + // the long dash-only line is wrongly accepted as an early closer, and + // the real closing `----` is then misread as a brand-new, attribute-less + // opener with nothing after it — a false B2 finding on a block that + // never contained code. + write('delimiters.adoc', [ + ':page-mode: how-to', + '', + '= Delimiters', + '', + '-----', + 'int five_dash = 1;', + '-----', + '', + '....', + 'int four_dot = 1;', + '....', + '', + '[role=figure]', + '----', + '-------------------', + 'CCD = 5', + '----', + '', + ].join('\n')); + + // G1 (final-review fix): AsciiDoc permits a block's attribute list to be + // split across multiple adjacent `[...]` lines, and Asciidoctor merges + // them into one. scanBlocks() used to read only the single nearest + // `[...]` line above the delimiter, so a `[source,cpp]` marker one line + // further up was invisible: `isSource` came back false, the block took + // the bare-listing branch, and role=output cleared it as non-code — B2:0. + // SHAPE, which still looks at bare-listing content, ALSO missed it: its + // `;\s*$` pattern is defeated by the trailing `// running sum` comment, + // which is this corpus's own annotation idiom for [role=output] blocks. + // The result was a highlighted C++ source block invisible to both B2 and + // SHAPE. Confirmed to reproduce against the pre-fix scanBlocks() (attr + // read from the single nearest line only) before landing the fix above. + write('split-attr.adoc', [ + ':page-mode: how-to', + '', + '= Split Attr', + '', + '[source,cpp]', + '[role=output]', + '----', + 'int total = 0; // running sum', + '----', + '', + ].join('\n')); + + // G1 review-round-2 fix: widening the attribute walk to ANY consecutive + // `[...]`-shaped line (the first cut of the fix above) created a false + // positive. A block anchor (`[[id]]`), an admonition style (`[NOTE]`), + // or a quote attribution (`[quote,...]`) can legitimately sit directly + // above a block with no blank line between; merging one of those ahead + // of a real `[source,cpp,role=pseudocode]` line made the joined string + // no longer start with `[source`, so `isSource` went false and a + // legitimately-exempt pseudocode block was wrongly flagged as B2. The + // walk must stop at the first line that is not itself a `[source,...]` + // or `[role=...]` continuation. Confirmed to reproduce against the + // review-round-1 fix (any `[...]`-shaped line merged) before landing + // the ATTR_CONTINUATION restriction above. + write('anchor-above-pseudocode.adoc', [ + ':page-mode: how-to', + '', + '= Anchor Above Pseudocode', + '', + '[[my-anchor]]', + '[source,cmake,role=pseudocode]', + '----', + 'add_executable(x x.cpp)', + '----', + '', + '[NOTE]', + '[source,cpp,role=pseudocode]', + '----', + 'int y = 2;', + '----', + '', + '[quote,Someone]', + '[source,cpp,role=external]', + '----', + 'task async_work();', + '----', + '', + ].join('\n')); + + // ANCHOR fixture. Line numbers are asserted below, so keep them stable: + // 3 = the prose violation, 5-8 = the in-block negative, 10 = the + // passthrough negative. + write('attr-anchor.adoc', [ + '= Attr', // 1 + ':page-mode: explanation', // 2 + 'The wrapper is `[[nodiscard]]` here.', // 3 <- must fire + '', // 4 + '[source,cpp]', // 5 + '----', // 6 + 'struct `[[nodiscard]]` s;', // 7 <- must NOT fire + '----', // 8 + '', // 9 + 'Fixed as `+[[nodiscard]]+` instead.', // 10 <- must NOT fire + '', + ].join('\n')); + + const d = spawnSync('node', [path.join(SCRIPT_DIR, 'doc-lint.mjs'), DOCLINT_TMP], + { encoding: 'utf8', cwd: DOC_DIR, maxBuffer: 16 * 1024 * 1024 }); + check('doc-lint.mjs exits 0 against the B2 fixture tree', d.status === 0, + `exited ${d.status}: ${(d.stderr || '').trim().slice(-200)}`); + let dOut = null; + try { dOut = JSON.parse(d.stdout); } catch { /* reported below */ } + check('doc-lint.mjs prints parseable JSON', dOut !== null, `stdout: ${d.stdout.slice(0, 200)}`); + if (dOut) { + const flaggedHits = dOut.findings.B2.filter((f) => f.file === 'flagged.adoc'); + check('B2 catches an unmarked [source,cmake] block (directly, or via the bare-listing fallback)', + flaggedHits.length === 3, `flagged.adoc B2 findings: ${JSON.stringify(flaggedHits)}`); + check('B2 catches a bare `----` block holding real code with no role marker', + flaggedHits.some((f) => f.line === 10), + `expected a finding at flagged.adoc:10 (the bare block); got: ${JSON.stringify(flaggedHits)}`); + check('role=output does NOT clear a [source,cpp] block holding real code', + flaggedHits.some((f) => f.line === 14), + `expected a finding at flagged.adoc:14 ([source,cpp,role=output]); got: ${JSON.stringify(flaggedHits)}`); + const clearHits = dOut.findings.B2.filter((f) => f.file === 'clear.adoc'); + check('B2 leaves role=pseudocode/external/output/figure and include::example$ alone', + clearHits.length === 0, `clear.adoc should have 0 B2 findings, got: ${JSON.stringify(clearHits)}`); + + const shapeB2 = dOut.findings.B2.filter((f) => f.file === 'shape.adoc'); + check('SHAPE-worthy blocks stay OUT of B2 (role=output is a real exemption, just not a silent one)', + shapeB2.length === 0, `shape.adoc should have 0 B2 findings, got: ${JSON.stringify(shapeB2)}`); + const shapeHits = dOut.findings.SHAPE.filter((f) => f.file === 'shape.adoc'); + check('SHAPE flags a role=output block whose content looks like code', + shapeHits.some((f) => f.line === 6), + `expected a SHAPE finding at shape.adoc:6; got: ${JSON.stringify(shapeHits)}`); + check('SHAPE leaves a genuine role=output block alone', + !shapeHits.some((f) => f.line === 11), + `shape.adoc:11 is real output text, should not be SHAPE-flagged; got: ${JSON.stringify(shapeHits)}`); + + const delimHits = dOut.findings.B2.filter((f) => f.file === 'delimiters.adoc'); + check('B2 reaches a 5-dash (`-----`) listing, not just exactly `----`', + delimHits.some((f) => f.line === 5), + `expected a finding at delimiters.adoc:5 (the ----- block); got: ${JSON.stringify(delimHits)}`); + check('B2 reaches a `....` literal block, not just `----`', + delimHits.some((f) => f.line === 9), + `expected a finding at delimiters.adoc:9 (the .... block); got: ${JSON.stringify(delimHits)}`); + // The closer-length check is `===`, not `>=`: a role=figure block + // whose body contains a dash-only line LONGER than its own 4-dash + // opener must not be misread as closing early there (measured + // against 9b.Separation.adoc's real CCD diagram, which does exactly + // this). Exactly 2 findings total (the two above) — a third means + // the mismatched-length body line either got flagged directly or + // caused the real closer below it to be misread as a new opener. + check('B2 does not misfire on a body dash-run whose length differs from its own delimiter\'s', + delimHits.length === 2, `expected exactly 2 delimiters.adoc findings (5-dash, dot), got: ${JSON.stringify(delimHits)}`); + + // G1: a [source,cpp] block whose attribute list is split across two + // adjacent `[...]` lines must still be recognized as source, not fall + // through to the bare-listing/SHAPE branch. + const splitAttrB2 = dOut.findings.B2.filter((f) => f.file === 'split-attr.adoc'); + check('B2 catches a [source,cpp] block whose attribute list is split across two lines', + splitAttrB2.length === 1 && splitAttrB2[0].line === 5, + `expected exactly 1 split-attr.adoc B2 finding at line 5 (the [source,cpp] line), got: ${JSON.stringify(splitAttrB2)}`); + const splitAttrShape = dOut.findings.SHAPE.filter((f) => f.file === 'split-attr.adoc'); + check('split-attribute [source,cpp] block is caught by B2, not diverted into SHAPE', + splitAttrShape.length === 0, + `split-attr.adoc should have 0 SHAPE findings (B2 should catch it directly), got: ${JSON.stringify(splitAttrShape)}`); + + // G1 review-round-2: a block anchor, an admonition style, or a quote + // attribution sitting directly above a [source,...,role=pseudocode/ + // external] block (no blank line between) must NOT be merged into + // that block's attribute string -- only [source,...]/[role=...] + // continuation lines may merge. All three blocks here are otherwise + // properly exempt and must produce zero B2 findings. + const anchorHits = dOut.findings.B2.filter((f) => f.file === 'anchor-above-pseudocode.adoc'); + check('a [[anchor]]/[NOTE]/[quote,...] line above an exempt [source,...] block does not defeat its exemption', + anchorHits.length === 0, + `anchor-above-pseudocode.adoc should have 0 B2 findings, got: ${JSON.stringify(anchorHits)}`); + + // ANCHOR: a C++ attribute in a prose code span. Asciidoctor's + // inline-anchor substitution runs inside a backtick span, so + // `` `[[nodiscard]]` `` renders as an EMPTY -- the attribute + // name vanishes from the page while the source still reads correctly. + // That is why this needs a machine check: the defect is invisible to + // a human reading the .adoc. + // Both directions matter. A rule that only fired would be satisfied + // by a whole-file regex, which would then flag every legitimate + // `[[nodiscard]]` inside a code block -- so the in-block negative and + // the passthrough negative are asserted too, or the block-skip could + // be dropped in a refactor with the positive still passing. + const anchorRule = dOut.findings.ANCHOR.filter((f) => f.file === 'attr-anchor.adoc'); + check('ANCHOR catches an attribute written as `[[...]]` in prose (renders as an empty )', + anchorRule.some((f) => f.line === 3), + `expected an ANCHOR finding at attr-anchor.adoc:3; got: ${JSON.stringify(anchorRule)}`); + check('ANCHOR ignores `[[...]]` inside a delimited block (literal there, renders fine)', + !anchorRule.some((f) => f.line >= 5 && f.line <= 8), + `attr-anchor.adoc:5-8 is inside a block and must not fire; got: ${JSON.stringify(anchorRule)}`); + check('ANCHOR accepts the passthrough form `+[[...]]+` as the fix', + !anchorRule.some((f) => f.line === 10), + `attr-anchor.adoc:10 is the passthrough fix and must not fire; got: ${JSON.stringify(anchorRule)}`); + check('ANCHOR finds exactly the one planted prose violation', + anchorRule.length === 1, `expected exactly 1 ANCHOR finding, got: ${JSON.stringify(anchorRule)}`); + } + } finally { + fs.rmSync(DOCLINT_TMP, { recursive: true, force: true }); + } +} + +if (failures.length) { + console.error(`selftest: ${failures.length} assertion(s) FAILED`); + for (const f of failures) console.error(` - ${f}`); + process.exit(1); +} +console.log(JSON.stringify({ + ok: true, + assertions: assertionCount, + fixtureSummary: out.summary, +}, null, 2)); diff --git a/doc/lint/sentence-length.mjs b/doc/lint/sentence-length.mjs new file mode 100644 index 000000000..43885bc9d --- /dev/null +++ b/doc/lint/sentence-length.mjs @@ -0,0 +1,415 @@ +#!/usr/bin/env node +// +// Copyright (c) 2026 Michael Vandeberg +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/corosio +// +// sentence-length.mjs — the authority for Style Guide C2 ("no sentence over 25 +// words"), replacing Vale's `Corosio.SentenceLength` on both documentation +// surfaces. Node built-ins only, no dependencies. JSON on stdout, in the same +// shape doc-lint.mjs uses, so baseline.mjs can fingerprint it. +// +// Why this exists rather than a Vale rule +// --------------------------------------- +// `Corosio.SentenceLength` is `extends: occurrence`, `scope: sentence`, and it runs +// AFTER doc/.vale.ini's `TokenIgnores` blanks inline code spans. Two defects +// follow from that, both measured on this branch and neither fixable inside a +// Vale rule: +// +// 1. UNDER-COUNTING. A blanked span contributes ZERO words where a reader +// counts at least one, so the rule's 25-word budget is not the reader's. +// Two independent Vale-side measurements agree on the size of it: task +// P4-prereq got 140 -> 170 (+30) by rewriting `TokenIgnores` on the +// pre-BlockIgnores-fix config, and a re-measurement on today's corpus +// (every backtick span and `cpp:` macro outside code blocks replaced by one +// word) got 135 -> 164 (+29). +// 2. MIS-ATTRIBUTION, which is worse. The blanking corrupts Vale's position +// mapping for `scope: sentence` rules, so an alert can be reported against +// the wrong block — which makes a genuinely over-limit block look +// unreported, and it produces no alert of its own to chase. Iterating +// `extract + vale` to a fixpoint does NOT find it. Cleanest real case, +// hand-verified: `5.buffers/5b.types.adoc` holds two over-limit sentences in +// list items and Vale reports NONE. +// +// So this checker does its own segmentation and its own counting, and never +// asks Vale where anything is. `TokenIgnores` in .vale.ini is deliberately left +// alone: 557 `cpp:` macros depend on it for the rules that SHOULD ignore symbol +// text. The fix is to stop depending on Vale's position mapping for C2, not to +// remove the ignores. +// +// How a code span is counted +// -------------------------- +// As the one word a reader sees. A construct is masked with U+0001 runs of the +// SAME LENGTH as the original, leaving a single `x` behind, so that +// +// * the offsets of everything after it stay valid, which is what lets a +// finding quote the real source text and name the real line, and +// * the word counter sees it exactly once. +// +// `xref:`/link macros are masked around their bracketed text instead: a reader +// sees the link text, so the link text is what gets counted. +// +// Output +// ------ +// Two rule keys, because C2 is hard in API docs and soft in essays — see +// ADVISORY_DIRS below. `C2` is the hard slice and the one a gate binds; +// `advisory-C2` is the design essays, measured and reported but never blocking. +// A third key, `BACKTICK`, reports blocks whose inline code spans are unbalanced. +// +// Usage: node doc/lint/sentence-length.mjs [--max N] [corpusDir ...] +// Corpora default to `modules` (the Antora pages) and `lint/.docstrings` (the +// header docstrings, produced by extract-docstrings.mjs), both relative to +// doc/. A missing or empty corpus is a HARD ERROR (exit 1), never a clean +// zero: this branch has eight recorded instances of a check that looked +// healthy while checking less than it appeared to, and "the corpus silently +// scanned no files" is the cheapest way to add a ninth. +// +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const DOC_DIR = path.resolve(SCRIPT_DIR, '..'); +const DEFAULT_CORPORA = ['modules', 'lint/.docstrings']; + +const argv = process.argv.slice(2); +let max = 25; +const corpora = []; +for (let i = 0; i < argv.length; i++) { + if (argv[i] === '--max') max = Number(argv[++i]); + else if (argv[i].startsWith('--max=')) max = Number(argv[i].slice('--max='.length)); + else corpora.push(argv[i]); +} +if (!Number.isInteger(max) || max < 1) { + console.error(`--max expects a positive integer, got: ${max}`); + process.exit(2); +} +if (corpora.length === 0) corpora.push(...DEFAULT_CORPORA); + +function walk(dir) { + let out = []; + for (const ent of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, ent.name); + if (ent.isDirectory()) out = out.concat(walk(p)); + else if (ent.name.endsWith('.adoc')) out.push(p); + } + return out; +} + +// --- prose extraction ------------------------------------------------------ +// +// Delimited blocks whose content is NOT prose. Their content is skipped +// wholesale — handing code to a prose linter is the `BlockIgnores` mistake +// .vale.ini documents at length, and it is not repeated here. Note that +// `====` (example), `____` (quote) and `****` (sidebar) blocks DO hold prose +// and are only unit boundaries; `--` open blocks do not occur in this corpus. +const OPAQUE_OPEN = /^(-{4,}|\.{4,}|\+{4,}|\/{4,})$/; +const TABLE_DELIM = /^[|,!]===$/; + +// Lines that carry no prose of their own. Headings are included: a heading is +// not a sentence, and the longest in the corpus is nowhere near 25 words. +const SKIP_LINE = new RegExp([ + '^//', // line comment + '^:[^\\s:]+:', // document attribute + '^\\[.*\\]$', // block attribute, or [[anchor]] + '^(include|ifdef|ifndef|ifeval|endif|image|video|audio|toc)::', + '^=+\\s', // heading + '^(={4,}|_{4,}|\\*{4,}|\'{3,})$', // prose-block delimiters and thematic break + '^\\+$', // list continuation +].join('|')); + +// A leading list, callout or ordered-list marker. The item after it is its own +// block to asciidoctor, hence its own sentence scope. +const LIST_MARKER = /^(?:[*.]{1,5}|-|\d+\.|<\d+>|<\.>)\s+/; + +// Collect the prose blocks of one file as { line, text }, where `line` is the +// 1-based line the block starts on and `text` is its lines joined with a single +// space. `map` records where each source line begins inside `text`, so a +// sentence found mid-block can still be reported against the line it starts on. +function proseBlocks(text) { + const lines = text.split('\n'); + const out = []; + let cur = null; + let opaque = null; // RegExp that closes the opaque block we are inside + let inTable = false; + + const open = (lineNo, s) => { cur = { line: lineNo, text: s, map: [{ at: 0, line: lineNo }] }; }; + const append = (lineNo, s) => { + if (cur === null) return open(lineNo, s); + cur.text += ' '; + cur.map.push({ at: cur.text.length, line: lineNo }); + cur.text += s; + }; + const flush = () => { if (cur && cur.text.trim()) out.push(cur); cur = null; }; + + for (let i = 0; i < lines.length; i++) { + const lineNo = i + 1; + const t = lines[i].trim(); + if (opaque !== null) { if (opaque.test(t)) opaque = null; continue; } + if (t === '') { flush(); continue; } + if (TABLE_DELIM.test(t)) { flush(); inTable = !inTable; continue; } + if (OPAQUE_OPEN.test(t)) { + flush(); + // Close on the same delimiter character; asciidoctor wants matching + // lengths, but being lenient here cannot swallow the rest of the file. + opaque = new RegExp(`^\\${t[0]}{4,}$`); + continue; + } + if (SKIP_LINE.test(t)) { flush(); continue; } + if (inTable && t.startsWith('|')) { + // Every `|` on the row opens a cell, and a cell is its own prose block. + // The last cell stays open so a cell continued on the next line folds in. + flush(); + const cells = t.split('|').slice(1); + for (let k = 0; k < cells.length; k++) { + const cell = cells[k].trim(); + if (!cell) continue; + if (k === cells.length - 1) open(lineNo, cell); + else out.push({ line: lineNo, text: cell, map: [{ at: 0, line: lineNo }] }); + } + continue; + } + const marker = LIST_MARKER.exec(t); + if (marker) { flush(); open(lineNo, t.slice(marker[0].length)); continue; } + append(lineNo, t); + } + flush(); + return out; +} + +// --- masking --------------------------------------------------------------- +// +// Each rule replaces its match with a same-length string. `keep: false` leaves +// one `x` (the single word a reader sees); `keep: true` leaves capture group 1 +// (a link's visible text) and hides the rest. +// +// Order matters: the macros run first so that a macro's target — which may +// itself contain brackets, colons or backticks — is consumed as one unit rather +// than being carved up by a later rule, and an already-masked region cannot +// re-match because U+0001 appears in none of the patterns. Note that `keep: true` +// deliberately PRESERVES the link text, including any backticks in it, so those +// backticks stay visible to the backtick rule and to the unbalanced-backtick +// guard below. That is intended — link text is prose — and it is also how a +// backtick can pair across constructs, which is what the guard catches. +const SPANS = [ + { re: /\b(?:xref|link|kbd|btn|menu|footnote):[^\s[]*\[([^\]]*)\]/g, keep: true }, + { re: /\bhttps?:\/\/\S*?\[([^\]]*)\]/g, keep: true }, + { re: /\b(?:cpp|image|icon|pass):[^\s[]*\[[^\]]*\]/g, keep: false }, + { re: /``[^`]+``|`[^`\n]+`/g, keep: false, id: 'backtick' }, + { re: /\{[a-z][\w-]*\}/g, keep: false }, // attribute reference, e.g. {cpp} +]; + +const hide = (n) => ''.repeat(n); + +function mask(s, skip = null) { + let out = s; + for (const { re, keep, id } of SPANS) { + if (id !== undefined && id === skip) continue; + out = out.replace(re, (m, g1) => { + if (keep && g1) { + const at = m.indexOf(g1); + return hide(at) + g1 + hide(m.length - at - g1.length); + } + return `x${hide(m.length - 1)}`; + }); + } + return out; +} + +// A residual backtick in the masked text means the block held an UNBALANCED +// inline code span, and the mask has already done damage: the stray backtick +// pairs with an unrelated one and, because the mask preserves length, every word +// between them collapses into a single `x`. Measured on a fixture, one stray +// backtick turns a 30-word sentence into 6 — silent UNDER-reporting in a check +// meant to feed a merge-blocking gate, i.e. the exact failure shape this script +// exists to remove. So it is made visible instead: the block is re-masked with +// the backtick rule disabled, which counts the span text as full prose +// (over-reporting, the safe direction), and a `BACKTICK` finding names the file +// and line. The rule's own `\n` guard is not enough because proseBlocks() joins a +// block's lines with a space, so a stray backtick on one line can reach a +// backtick on another. `BACKTICK` findings fingerprint as +// `BACKTICK:file:#N:message`, which does NOT match a `^C2:` gate spec, so they +// are visible without being blocking. +function maskBlock(text) { + const masked = mask(text); + if (!masked.includes('`')) return { masked, unbalanced: false }; + return { masked: mask(text, 'backtick'), unbalanced: true }; +} + +// --- segmentation ---------------------------------------------------------- +// +// Terminal punctuation followed by whitespace or end of block, on the MASKED +// text — so a `.` inside a code span or a URL cannot open a sentence boundary. +// +// The inline-formatting marks are part of the boundary, not after it: AsciiDoc's +// bold run-in lead (`*The library owns the handles.* Corosio creates ...`) and the +// same idiom in docstrings (`... `run_async(ex)(task)`.** The wrapper's ...`) +// put `*` or `_` between the period and the space. Requiring whitespace +// immediately after the period merged those leads into the following sentence and +// over-reported its length — two confirmed false positives. +// +// The two UNDER-reporting cases, both fixed here. Every other known miscount in +// this script over-reports, which is the safe direction for a length limit; these +// two let a real violation through, which a merge-blocking gate cannot afford. +// Both were pre-existing (the pre-round code splits identically), and both were +// found by adversarial fixtures rather than by the corpus. +// +// * A mid-sentence ELLIPSIS. `...` ends with a period followed by a space, so a +// 34-word sentence containing one was segmented 16 + 18 and missed at 25. The +// `(? (s.match(WORD) || []).length; + +// --- hard versus advisory slice (maintainer ruling) ------------------------- +// +// doc/STYLE_GUIDE.md Part C2 makes the limit "hard in API docs, soft in essays". +// The flat 25 stays — a 20-word instruction limit was rejected as unimplementable, +// since nothing classifies instruction-versus-descriptive prose reliably — but the +// OUTPUT is split so a gate can bind only the hard part: +// +// hard the extracted `include/**` docstrings, plus every .adoc page NOT in +// the two essay directories below. This is the number that must reach +// zero, and the slice a `--gate 'sentence_length:^C2:'` spec binds. +// advisory doc/modules/ROOT/pages/2.networking-tutorial/. Measured and +// reported, never blocking. That chapter is essay-style prose +// teaching protocol theory, which is exactly the material C2 +// relaxes for, and it holds 72 of the 143 page-level hits measured +// at the port -- so gating it would make the hard slice mostly +// essays. Docstrings are always hard, whatever directory they came +// from. +// +// The advisory rule key deliberately does NOT begin with `C2`, so that even a +// mis-written head-anchored spec (`^C2` without the colon) cannot reach the +// essays. Keep it that way. +const ADVISORY_DIRS = [ + 'modules/ROOT/pages/2.networking-tutorial/', +]; +// Matched as a whole path SEGMENT sequence, with the trailing slash, so a +// look-alike directory (`2.networking-tutorialish/`) stays in the hard slice. The `/`-prefixed +// form is also tested because an ad-hoc invocation with an absolute corpus path +// outside doc/ makes `rel` a `../`-walk-up, which no prefix test would match — +// that silently put every essay finding in the HARD slice. baseline.mjs always +// passes the relative defaults, so this only ever affected manual runs. +const ruleFor = (rel) => (ADVISORY_DIRS.some((d) => rel.startsWith(d) || rel.includes(`/${d}`)) + ? 'advisory-C2' : 'C2'); + +// --- run ------------------------------------------------------------------- + +const byRule = { C2: [], 'advisory-C2': [], BACKTICK: [] }; +const scanned = {}; +for (const corpus of corpora) { + const root = path.resolve(DOC_DIR, corpus); + if (!fs.existsSync(root)) { + console.error(`sentence-length.mjs: corpus '${corpus}' does not exist at ${root}. ` + + "For 'lint/.docstrings', run `node lint/extract-docstrings.mjs` first. " + + 'Refusing to report zero findings for a corpus that was never read.'); + process.exit(1); + } + const files = walk(root); + if (files.length === 0) { + console.error(`sentence-length.mjs: corpus '${corpus}' contains no .adoc files. ` + + 'Refusing to report zero findings for an empty corpus.'); + process.exit(1); + } + scanned[corpus] = files.length; + for (const file of files) { + // Keyed on the path relative to DOC_DIR, never a basename: `concept/read_stream.hpp` + // and `test/read_stream.hpp` are different files, and two verification scripts on + // this branch silently conflated exactly that pair. + const rel = path.relative(DOC_DIR, file).split(path.sep).join('/'); + const rule = ruleFor(rel); + for (const block of proseBlocks(fs.readFileSync(file, 'utf8'))) { + const { masked, unbalanced } = maskBlock(block.text); + const lineOf = (at) => { + let line = block.line; + for (const e of block.map) if (e.at <= at) line = e.line; + return line; + }; + if (unbalanced) { + byRule.BACKTICK.push({ + file: rel, + line: block.line, + message: 'unbalanced backtick in block; inline code spans in it are counted as prose', + sentence: block.text.slice(0, 200).trim(), + }); + } + for (const [from, to] of sentenceRanges(masked)) { + const words = countWords(masked.slice(from, to)); + if (words <= max) continue; + byRule[rule].push({ + file: rel, + line: lineOf(from), + words, + // The message is deliberately fixed text: baseline.mjs fingerprints a + // doc_lint-shaped finding as `rule:file:#N:message`, and folding the + // word count or the sentence into it would re-mint the fingerprint on + // every reword — a gate that fails because a contributor rephrased an + // already-over-limit sentence teaches contributors to distrust it. + message: `sentence over ${max} words`, + sentence: block.text.slice(from, to).trim(), + }); + } + } + } +} + +console.log(JSON.stringify({ + summary: { + hard: byRule.C2.length, + advisory: byRule['advisory-C2'].length, + unbalancedBackticks: byRule.BACKTICK.length, + max, + scanned, + advisoryDirs: ADVISORY_DIRS, + }, + findings: byRule, +}, null, 2)); +process.exit(0); diff --git a/doc/modules/ROOT/nav.adoc b/doc/modules/ROOT/nav.adoc index 621adcc26..2005b0d12 100644 --- a/doc/modules/ROOT/nav.adoc +++ b/doc/modules/ROOT/nav.adoc @@ -1,4 +1,5 @@ * xref:index.adoc[Introduction] +* xref:quick-start.adoc[Quick Start] * xref:2.networking-tutorial/2.intro.adoc[Networking Tutorial] ** xref:2.networking-tutorial/2a.how-you-connect.adoc[What Happens When You Connect] ** xref:2.networking-tutorial/2b.internet-addresses.adoc[Addressing Machines on the Internet] @@ -45,5 +46,4 @@ ** xref:5.testing/5c.patterns.adoc[Testing Patterns] * xref:benchmark-report.adoc[Benchmarks] * xref:glossary.adoc[Glossary] -* xref:quick-start.adoc[Quick Start] * xref:reference:boost/corosio.adoc[Reference] diff --git a/doc/modules/ROOT/pages/2.networking-tutorial/2.intro.adoc b/doc/modules/ROOT/pages/2.networking-tutorial/2.intro.adoc index 9dd132b70..2d27d839d 100644 --- a/doc/modules/ROOT/pages/2.networking-tutorial/2.intro.adoc +++ b/doc/modules/ROOT/pages/2.networking-tutorial/2.intro.adoc @@ -8,11 +8,12 @@ // = Networking Tutorial +:page-mode: explanation Every network application starts with a conversation between two programs. One program asks a question, the other answers, and useful work happens across the wire. This section walks you through that conversation from the ground up, building your understanding of networked programming with Corosio one concept at a time. -You will begin with the fundamentals: what happens when your program opens a connection, how bytes travel between machines, and how the operating system manages all of it on your behalf. From there you will progress to writing real I/O code -- sending data, receiving responses, and handling the inevitable errors that arise when communicating over an unreliable medium. +You begin with the fundamentals: what happens when your program opens a connection, how bytes travel between machines, and how the operating system manages all of it on your behalf. From there you progress to writing real I/O code -- sending data, receiving responses, and handling the inevitable errors that arise when communicating over an unreliable medium. -As your confidence grows, the material advances into the patterns that distinguish production-quality network code from toy examples. You will see how asynchronous I/O lets a single thread juggle thousands of connections without blocking, how coroutines make that concurrency feel sequential and natural, and how the event loop ties it all together. +As your confidence grows, the material advances into the patterns that distinguish production-quality network code from toy examples. You see how asynchronous I/O lets a single thread juggle thousands of connections without blocking, how coroutines make that concurrency feel sequential and natural, and how the event loop ties it all together. -By the end, you will have a practical understanding of TCP networking sufficient to build clients, servers, and everything in between -- using modern C++ and the coroutine-first abstractions that Corosio provides. +By the end, you have a practical understanding of TCP networking sufficient to build clients, servers, and everything in between -- using modern C++ and the coroutine-first abstractions that Corosio provides. diff --git a/doc/modules/ROOT/pages/2.networking-tutorial/2a.how-you-connect.adoc b/doc/modules/ROOT/pages/2.networking-tutorial/2a.how-you-connect.adoc index 91ea8ae02..ff126cc5c 100644 --- a/doc/modules/ROOT/pages/2.networking-tutorial/2a.how-you-connect.adoc +++ b/doc/modules/ROOT/pages/2.networking-tutorial/2a.how-you-connect.adoc @@ -8,6 +8,7 @@ // = What Happens When You Connect +:page-mode: explanation Your program calls `connect`, and a moment later bytes arrive on a machine halfway around the world. Between those two events, a handful of protocols cooperate to make it happen. Understanding what each one does -- and where your code fits in the picture -- is the foundation for everything that follows. diff --git a/doc/modules/ROOT/pages/2.networking-tutorial/2b.internet-addresses.adoc b/doc/modules/ROOT/pages/2.networking-tutorial/2b.internet-addresses.adoc index c5ec2a05e..fb6b23077 100644 --- a/doc/modules/ROOT/pages/2.networking-tutorial/2b.internet-addresses.adoc +++ b/doc/modules/ROOT/pages/2.networking-tutorial/2b.internet-addresses.adoc @@ -8,6 +8,7 @@ // = Addressing Machines on the Internet +:page-mode: explanation Every packet traveling the internet carries two addresses: where it came from and where it is going. These addresses are not arbitrary labels. They have internal structure that routers use to make forwarding decisions, and understanding that structure explains why networks behave the way they do. @@ -15,11 +16,12 @@ Every packet traveling the internet carries two addresses: where it came from an An IPv4 address is a 32-bit number, written as four decimal values separated by dots. Each value represents one byte, so it ranges from 0 to 255: +[role=figure] ---- 192.168.1.42 ---- -That is the human-readable form. Under the hood it is just 32 bits: `11000000.10101000.00000001.00101010`. Four billion possible addresses sounds like a lot until you consider that every phone, laptop, thermostat, and security camera on the planet needs one. The world ran out of fresh IPv4 addresses years ago, and workarounds like network address translation (NAT) keep things functioning -- but the shortage is real. +That is the human-readable form. Under the hood it is just 32 bits: `11000000.10101000.00000001.00101010`. Four billion possible addresses sounds like a lot until you consider that every phone, laptop, thermostat, and security camera on the planet needs one. The world ran out of fresh IPv4 addresses years ago, and workarounds like network address translation (NAT) keep things functioning. The shortage is real. == Structure Inside the Address @@ -33,6 +35,7 @@ This division is what makes routing scalable. The internet has billions of devic The *subnet mask* tells you where the network portion ends and the host portion begins. It is a 32-bit value where all the network bits are set to 1 and all the host bits are set to 0: +[role=figure] ---- Address: 192.168.1.42 Subnet mask: 255.255.255.0 @@ -48,7 +51,7 @@ Subnet masks also let a single organization divide its address space into smalle Several address ranges have reserved meanings: -`127.0.0.1` (loopback):: Traffic sent here never leaves the machine. It goes down through the protocol stack, turns around, and comes back up. This is how your program talks to a server running on the same computer. The entire `127.0.0.0/8` range is reserved for loopback, but `127.0.0.1` is the one you will see in practice. +`127.0.0.1` (loopback):: Traffic sent here never leaves the machine. It goes down through the protocol stack, turns around, and comes back up. This is how your program talks to a server running on the same computer. The entire `127.0.0.0/8` range is reserved for loopback, but `127.0.0.1` is the one you see in practice. `0.0.0.0`:: When used as a source address, it means "this machine, but I don't know my address yet." When used to bind a socket, it means "listen on every available network interface." @@ -65,12 +68,14 @@ Broadcast:: The address `255.255.255.255` sends a packet to every machine on the IPv6 replaces the 32-bit address with a 128-bit one, written as eight groups of four hexadecimal digits separated by colons: +[role=figure] ---- 2001:0db8:85a3:0000:0000:8a2e:0370:7334 ---- Leading zeros within a group can be dropped, and a single run of consecutive all-zero groups can be replaced with `::`: +[role=figure] ---- 2001:db8:85a3::8a2e:370:7334 ---- diff --git a/doc/modules/ROOT/pages/2.networking-tutorial/2c.domain-name-system.adoc b/doc/modules/ROOT/pages/2.networking-tutorial/2c.domain-name-system.adoc index c84e8b9b5..135990802 100644 --- a/doc/modules/ROOT/pages/2.networking-tutorial/2c.domain-name-system.adoc +++ b/doc/modules/ROOT/pages/2.networking-tutorial/2c.domain-name-system.adoc @@ -8,6 +8,7 @@ // = Turning Names into Addresses +:page-mode: explanation Nobody memorizes `93.184.215.14`. People remember `www.example.com`. But the network stack operates on IP addresses, not names. Something has to translate between the two, and that something is the Domain Name System -- a distributed database spread across thousands of servers worldwide, answering billions of queries a day. @@ -43,7 +44,7 @@ CNAME:: An alias. It says "this name is actually another name -- go look up that MX:: Identifies the mail servers responsible for a domain. When someone sends email to `user@mycompany.org`, the sending mail server queries the MX records for `mycompany.org` to find out where to deliver it. -TXT:: A free-form text record. Often used for domain verification, email authentication (SPF, DKIM), and other administrative purposes. +TXT:: A free-form text record. Often used for domain verification, email authentication schemes such as SPF (Sender Policy Framework) and DKIM (DomainKeys Identified Mail), and other administrative purposes. NS:: Identifies the authoritative name servers for a domain. These are the servers the resolver contacts in the final step of resolution. @@ -63,7 +64,7 @@ The flip side is that changes do not take effect immediately. If a domain's addr Most DNS queries travel over UDP. A typical query and response fit within a single datagram, making UDP the natural fit: fast, no connection setup, minimal overhead. Port 53 is the standard port for DNS traffic. -When a response is too large for a single UDP datagram -- which can happen with domains that have many records or with DNSSEC signatures -- the server sets a flag indicating truncation. The client then retries the query over TCP, which can handle arbitrarily large responses by streaming the data. +When a response is too large for a single UDP datagram -- which can happen with domains that have many records or with DNSSEC (DNS Security Extensions) signatures -- the server sets a flag indicating truncation. The client then retries the query over TCP, which can handle arbitrarily large responses by streaming the data. The choice between UDP and TCP is handled automatically by the DNS resolver. Your application never needs to think about it. diff --git a/doc/modules/ROOT/pages/2.networking-tutorial/2d.urls.adoc b/doc/modules/ROOT/pages/2.networking-tutorial/2d.urls.adoc index b2d1a1047..35f6a1039 100644 --- a/doc/modules/ROOT/pages/2.networking-tutorial/2d.urls.adoc +++ b/doc/modules/ROOT/pages/2.networking-tutorial/2d.urls.adoc @@ -8,6 +8,7 @@ // = URLs and Resource Identification +:page-mode: explanation A URL packs everything your program needs to reach a resource into a single string: which protocol to speak, which machine to contact, and what to ask for once connected. You see URLs constantly -- in browser address bars, configuration files, API documentation, and log messages. Understanding their structure turns an opaque string into a set of actionable instructions. @@ -15,6 +16,7 @@ A URL packs everything your program needs to reach a resource into a single stri Consider this URL: +[role=figure] ---- https://api.weather.co:443/v2/forecast?city=tokyo&days=5#summary ---- @@ -39,17 +41,18 @@ The host and optional port together form the *authority* of the URL. In the exam When the host is a domain name, your program resolves it through DNS (as described in the previous section) to obtain an IP address. When the host is a literal IPv6 address, it must be enclosed in square brackets to avoid ambiguity with the colons: +[role=figure] ---- http://[2001:db8::1]:8080/status ---- -The authority can also include user credentials in the form `user:password@host`, but this is deprecated for security reasons and you should not rely on it. +The authority can also include user credentials in the form `user:password@host`. This is deprecated for security reasons, and you should not rely on it. == How a URL Drives a Connection A URL is a recipe, and following it produces a network connection. The steps are: -. *Parse the scheme* to determine the protocol. `https` means you will need a TLS handshake after connecting. +. *Parse the scheme* to determine the protocol. `https` means you need a TLS handshake after connecting. . *Resolve the host* through DNS. `api.weather.co` becomes an IP address, or possibly a list of addresses. . *Connect to the port*. If the URL specifies one, use it. Otherwise, use the default for the scheme. . *Send the request*. For HTTP, this means sending the method, path, query string, and headers. For other protocols, the format differs. @@ -82,14 +85,15 @@ URL parsing libraries handle encoding and decoding for you. The important thing == URLs vs. URIs -You will sometimes see the term URI (Uniform Resource Identifier) used alongside or instead of URL. The distinction is mostly academic: a URI is the broader category, and a URL is a URI that also tells you *how* to access the resource (via the scheme). A URN (Uniform Resource Name) is a URI that names a resource without providing a location, like an ISBN for a book. +You sometimes see the term URI (Uniform Resource Identifier) used alongside or instead of URL. The distinction is mostly academic: a URI is the broader category, and a URL is a URI that also tells you *how* to access the resource (via the scheme). A URN (Uniform Resource Name) is a URI that names a resource without providing a location, like an ISBN for a book. -In practice, nearly every URI you encounter is a URL. The terms are used interchangeably in most documentation and APIs, and treating them as equivalent will not cause problems. +In practice, nearly every URI you encounter is a URL. The terms are used interchangeably in most documentation and APIs, and treating them as equivalent does not cause problems. == Relative URLs Not every URL contains all six components. A *relative URL* omits the scheme and authority and is interpreted relative to some base URL. If you are already connected to `https://api.weather.co`, the relative URL `/v2/forecast?city=london` resolves to: +[role=figure] ---- https://api.weather.co/v2/forecast?city=london ---- diff --git a/doc/modules/ROOT/pages/2.networking-tutorial/2e.client-server-model.adoc b/doc/modules/ROOT/pages/2.networking-tutorial/2e.client-server-model.adoc index 7f19e2633..b2dfdd140 100644 --- a/doc/modules/ROOT/pages/2.networking-tutorial/2e.client-server-model.adoc +++ b/doc/modules/ROOT/pages/2.networking-tutorial/2e.client-server-model.adoc @@ -8,6 +8,7 @@ // = Clients, Servers, and Ports +:page-mode: explanation Every network conversation has two sides. One side initiates the connection -- that is the *client*. The other side waits for someone to connect -- that is the *server*. This asymmetry shapes how you write networked code, and port numbers are the mechanism that keeps it all organized. @@ -37,18 +38,21 @@ Ephemeral ports (49152-65535):: Assigned temporarily by the operating system. Wh A single TCP connection is uniquely identified by four values: +[role=figure] ---- (source IP, source port, destination IP, destination port) ---- Consider a laptop at address `10.0.0.5` connecting to a web server at `203.0.113.80` on port `443`. The operating system assigns ephemeral port `51234` to the client side. The connection's four-tuple is: +[role=figure] ---- (10.0.0.5, 51234, 203.0.113.80, 443) ---- If the same laptop opens a second connection to the same server, the OS assigns a different ephemeral port -- say `51235`. The second connection's four-tuple is: +[role=figure] ---- (10.0.0.5, 51235, 203.0.113.80, 443) ---- @@ -99,7 +103,7 @@ Fire and forget:: The client sends data without expecting a response. Some loggi == Why This Matters to You -The client-server model and port numbers are the foundation of every connection your program makes. When you see "connection refused," it means no process was listening on the target port. When you see "address already in use," it means another process (or a lingering socket in TIME_WAIT) is already bound to the port you requested. +The client-server model and port numbers are the foundation of every connection your program makes. When you see "connection refused," it means no process was listening on the target port. When you see "address already in use," it means another process (or a lingering socket in TIME_WAIT, a brief post-close cleanup state) is already bound to the port you requested. Understanding the four-tuple explains why a server can accept many connections on a single port, and why the OS assigns ephemeral ports automatically on the client side. Understanding the socket interface tells you what system calls your networking library wraps on your behalf. diff --git a/doc/modules/ROOT/pages/2.networking-tutorial/2f.internet-protocol.adoc b/doc/modules/ROOT/pages/2.networking-tutorial/2f.internet-protocol.adoc index 976aca598..877a8ec97 100644 --- a/doc/modules/ROOT/pages/2.networking-tutorial/2f.internet-protocol.adoc +++ b/doc/modules/ROOT/pages/2.networking-tutorial/2f.internet-protocol.adoc @@ -8,8 +8,9 @@ // = IP: Moving Packets Across Networks +:page-mode: explanation -IP is the workhorse of the internet. Every piece of data your program sends -- whether over TCP or UDP -- travels inside an IP packet. IP's job is limited but essential: take a packet, figure out where it needs to go, and forward it one hop closer to its destination. It makes no promises about whether the packet arrives, whether it arrives once or twice, or whether it arrives before or after the packet sent before it. That deliberate minimalism is what makes the internet scalable. +Every piece of data your program sends -- whether over TCP or UDP -- travels inside an IP packet. IP's job is limited but essential: take a packet, figure out where it needs to go, and forward it one hop closer to its destination. It makes no promises about whether the packet arrives, whether it arrives once or twice, or whether it arrives before or after the packet sent before it. That deliberate minimalism is what makes the internet scalable. == What IP Does @@ -54,7 +55,7 @@ Your program sends a packet destined for `203.0.113.80`. The packet does not tra No single router knows the entire path. Each one knows only its immediate neighbors and which networks are reachable through each neighbor. This distributed, hop-by-hop design is what allows the internet to scale to billions of devices without a central authority coordinating every path. -Routes can change in real time. If a link between two routers goes down, routing protocols detect the failure and recalculate paths within seconds. Your packet might take a different route than the one before it, and neither your program nor the destination will notice -- IP treats every packet independently. +Routes can change in real time. If a link between two routers goes down, routing protocols detect the failure and recalculate paths within seconds. Your packet might take a different route than the one before it, and neither your program nor the destination notices -- IP treats every packet independently. == MTU: Maximum Transmission Unit @@ -76,7 +77,7 @@ Fragmentation has costs. If any single fragment is lost, the entire original pac == Why This Matters to You -As an application developer, you rarely interact with IP directly. TCP and UDP sit between your code and IP, and the operating system handles routing and fragmentation transparently. But IP's behavior explains several things you will encounter: +As an application developer, you rarely interact with IP directly. TCP and UDP sit between your code and IP, and the operating system handles routing and fragmentation transparently. But IP's behavior explains several things you encounter: * *Why packets can arrive out of order*: each IP packet is routed independently, and different packets may take different paths. * *Why packets can be lost*: routers discard packets when queues overflow, checksums fail, or TTL expires. There is no notification to the sender. diff --git a/doc/modules/ROOT/pages/2.networking-tutorial/2g.udp.adoc b/doc/modules/ROOT/pages/2.networking-tutorial/2g.udp.adoc index a861eaca9..24a43a3f2 100644 --- a/doc/modules/ROOT/pages/2.networking-tutorial/2g.udp.adoc +++ b/doc/modules/ROOT/pages/2.networking-tutorial/2g.udp.adoc @@ -8,8 +8,9 @@ // = UDP: Fast, Simple, Unreliable +:page-mode: explanation -UDP is the simplest transport protocol you will encounter. It takes your data, slaps on a small header with source and destination port numbers, and hands it to IP. No connection setup. No acknowledgment. No retransmission. No ordering guarantees. If you want any of those things, you build them yourself. +UDP is the simplest transport protocol you encounter. It takes your data, slaps on a small header with source and destination port numbers, and hands it to IP. It delivers each datagram best-effort, with no connection setup, acknowledgment, retransmission, or ordering guarantees. If you want any of those things, you build them yourself. That sounds like a limitation, and it is -- but it is also the point. UDP exists for situations where the overhead of reliability is worse than the occasional lost packet. @@ -21,7 +22,7 @@ Port numbers:: Just like TCP, UDP uses 16-bit source and destination port number Checksum:: A checksum covers the UDP header and payload. If the data was corrupted in transit, the operating system discards the datagram silently. (In IPv4 the checksum is optional, though universally used in practice. In IPv6 it is mandatory.) -That is the full list. UDP does not establish a connection, does not track what has been sent, and does not guarantee that anything arrives. Each `send` call produces one datagram. Each `recv` call consumes one datagram. The datagrams are independent. +That is the full list. UDP does not establish a connection, does not track what it sent, and does not guarantee that anything arrives. Each `send` call produces one datagram. Each `recv` call consumes one datagram. The datagrams are independent. == The UDP Header @@ -58,7 +59,7 @@ For protocols where message framing matters -- where each datagram is a self-con == Fragmentation and UDP -A UDP datagram can theoretically be up to 65,535 bytes (the maximum IP packet size, minus the IP and UDP headers). In practice, sending anything close to this size is a bad idea. +A UDP datagram can theoretically be up to 65,535 bytes -- UDP's own 16-bit length field cannot express more. In practice, sending anything close to this size is a bad idea. When a UDP datagram exceeds the path MTU, IP fragments it into smaller pieces. These fragments travel independently through the network. If every fragment arrives, the destination reassembles the original datagram and delivers it to your application. If *any* fragment is lost, the entire datagram is discarded. Your application receives nothing -- not even the fragments that did arrive. @@ -96,7 +97,7 @@ File transfers, database queries, HTTP requests, and any protocol where data int == Why This Matters to You -UDP is the faster, leaner transport protocol, and understanding it helps you make informed decisions about when to use it. But its simplicity is a double-edged sword: it gives you freedom and leaves you responsible for everything TCP would handle automatically. +UDP is the faster, leaner transport protocol, and understanding it helps you make informed decisions about when to use it. But its simplicity gives you freedom and leaves you responsible for everything TCP would handle automatically. For most application developers, TCP is the default choice. UDP is the right tool for specific problems: latency-sensitive media, lightweight query-response protocols, and situations where the application knows better than TCP what "reliable" means. diff --git a/doc/modules/ROOT/pages/2.networking-tutorial/2h.tcp-fundamentals.adoc b/doc/modules/ROOT/pages/2.networking-tutorial/2h.tcp-fundamentals.adoc index 629cc41ed..967cabb9f 100644 --- a/doc/modules/ROOT/pages/2.networking-tutorial/2h.tcp-fundamentals.adoc +++ b/doc/modules/ROOT/pages/2.networking-tutorial/2h.tcp-fundamentals.adoc @@ -8,6 +8,7 @@ // = TCP: Reliable Byte Streams +:page-mode: explanation TCP is the protocol that makes the internet useful for most applications. It takes the unreliable, unordered packet delivery that IP provides and builds something much more powerful on top: a reliable, ordered stream of bytes that flows in both directions between two machines. Your program writes bytes into one end, and they come out at the other end in exactly the same order, even if the underlying packets were lost, duplicated, reordered, or delayed. @@ -64,7 +65,7 @@ Window size (16 bits):: The number of bytes the sender is willing to accept. Thi Checksum (16 bits):: Covers the TCP header, the payload, and a pseudo-header derived from the IP addresses and protocol number. If the checksum fails, the segment is discarded silently. -The sequence and acknowledgment numbers are the core of TCP's reliability. By tracking which bytes have been sent and which have been acknowledged, TCP can detect gaps (lost packets) and fill them with retransmissions. +The sequence and acknowledgment numbers are the core of TCP's reliability. By tracking which bytes it sent and which the peer acknowledged, TCP can detect gaps (lost packets) and fill them with retransmissions. == TCP vs. UDP: Choosing diff --git a/doc/modules/ROOT/pages/2.networking-tutorial/2i.tcp-connections.adoc b/doc/modules/ROOT/pages/2.networking-tutorial/2i.tcp-connections.adoc index bb4fc33df..eac349a48 100644 --- a/doc/modules/ROOT/pages/2.networking-tutorial/2i.tcp-connections.adoc +++ b/doc/modules/ROOT/pages/2.networking-tutorial/2i.tcp-connections.adoc @@ -8,8 +8,9 @@ // = Opening and Closing TCP Connections +:page-mode: explanation -A TCP connection is not a physical wire. It is an agreement between two machines to track a shared conversation. That agreement begins with a handshake, ends with a teardown, and passes through a series of well-defined states in between. Knowing these states explains errors you will encounter in every networked application you write. +A TCP connection is not a physical wire. It is an agreement between two machines to track a shared conversation. That agreement begins with a handshake, ends with a teardown, and passes through a series of well-defined states in between. Knowing these states explains errors you encounter in every networked application you write. == The Three-Way Handshake @@ -19,6 +20,7 @@ Before any data flows, TCP establishes the connection with three segments: . *SYN-ACK*: The server responds with both the SYN and ACK flags set. It acknowledges the client's ISN (by setting the acknowledgment number to the client's ISN + 1) and provides its own ISN. This says "I accept, I acknowledge your starting number, and my byte numbering starts here." . *ACK*: The client sends a final acknowledgment of the server's ISN. At this point both sides have agreed on initial sequence numbers, and the connection is established. +[role=figure] ---- Client Server | | @@ -39,7 +41,7 @@ The initial sequence numbers are chosen randomly (not starting from zero) to pre What happens when the server does not respond to the SYN? The client waits, retransmits the SYN after a short delay, and retransmits again with increasingly longer delays. After several retries spanning a total of roughly 30 to 75 seconds (depending on the operating system), the connection attempt gives up and your application receives a timeout error. -This is the error you see when connecting to a host that is unreachable, firewalled, or simply not running a server on the target port. The long delay before the error appears is TCP being patient -- giving the network every chance to deliver the SYN. +This is the error you see when connecting to a host that is unreachable, firewalled, or not running a server on the target port. TCP retries the SYN with increasing delays, giving the network every chance to deliver it before the error appears. If the server is reachable but nothing is listening on the port, the response is faster: the server immediately sends a RST (reset) segment, and your application gets a "connection refused" error within milliseconds. @@ -52,6 +54,7 @@ Closing a TCP connection takes four segments because each direction of the strea . *FIN*: The other side sends its own FIN when it, too, has finished sending. . *ACK*: The first side acknowledges the second FIN. +[role=figure] ---- Client Server | | @@ -90,7 +93,7 @@ ESTABLISHED:: The handshake is complete. Both sides can send and receive data. T FIN_WAIT_1:: This side has sent a FIN and is waiting for an acknowledgment. -FIN_WAIT_2:: The FIN has been acknowledged, but the other side has not yet sent its own FIN. This side is waiting for the remote FIN. +FIN_WAIT_2:: The other side acknowledged the FIN, but has not yet sent its own FIN. This side is waiting for the remote FIN. CLOSE_WAIT:: This side has received a FIN from the remote end but has not yet sent its own FIN. If your server has many connections in CLOSE_WAIT, it means the application is not closing sockets promptly after the remote end has disconnected. @@ -104,14 +107,14 @@ After both sides have exchanged FINs and ACKs, you might expect the connection t TIME_WAIT exists for two reasons: -. *Reliable termination*: If the final ACK is lost, the remote side will retransmit its FIN. The TIME_WAIT state ensures that the local side is still around to re-acknowledge it. -. *Preventing stale segments*: If a new connection is established on the same four-tuple immediately after closing, delayed segments from the old connection might arrive and be misinterpreted as belonging to the new one. TIME_WAIT ensures that enough time passes for any lingering segments to expire. +. *Reliable termination*: If the final ACK is lost, the remote side retransmits its FIN. The TIME_WAIT state ensures that the local side is still around to re-acknowledge it. +. *Preventing stale segments*: If a new connection is established on the same four-tuple (source address, source port, destination address, destination port) immediately after closing, delayed segments from the old connection might arrive and be misinterpreted as belonging to the new one. TIME_WAIT ensures that enough time passes for any lingering segments to expire. The practical consequence is the dreaded "address already in use" error. If your server shuts down and immediately restarts, it cannot bind to the same port because the old connections are still in TIME_WAIT. The standard solution is to set the `SO_REUSEADDR` socket option before binding, which tells the OS to allow binding to a port that has connections in TIME_WAIT. == Reset Segments -A RST (reset) segment is the emergency stop. It terminates the connection immediately, without the graceful FIN exchange. The receiving side sees an error on the socket -- typically "connection reset by peer." +A RST (reset) segment terminates a connection immediately, without a graceful FIN exchange. The receiving side sees an error on the socket -- typically "connection reset by peer." RST is sent in several situations: @@ -137,11 +140,11 @@ The *listening socket* is bound to a well-known port and calls `accept()` in a l The listening socket is never used for data transfer. It exists solely to create connected sockets. The connected socket carries all the data for a single client conversation. When the conversation ends, the connected socket is closed, but the listening socket remains, ready for the next client. -A server handling multiple clients simultaneously needs a strategy for managing many connected sockets. Options include spawning a thread per connection, using an event loop with non-blocking I/O, or -- as Corosio provides -- using coroutines that `co_await` on socket operations. The mechanism differs, but the pattern is the same: one listening socket, many connected sockets, each managed independently. +A server handling multiple clients simultaneously needs a strategy for managing many connected sockets. Options include one thread per connection, an event loop with non-blocking I/O, or -- as Corosio provides -- using coroutines that `co_await` on socket operations. The mechanism differs, but the pattern is the same: one listening socket, many connected sockets, each managed independently. == Why This Matters to You -The TCP connection lifecycle is not academic trivia. It is the explanation behind errors you will see regularly: +The TCP connection lifecycle is not academic trivia. It is the explanation behind errors you see regularly: * "Connection refused" means RST came back immediately -- nothing is listening on the port. * "Connection timed out" means no response to the SYN -- the host is unreachable or firewalled. diff --git a/doc/modules/ROOT/pages/2.networking-tutorial/2j.tcp-data-flow.adoc b/doc/modules/ROOT/pages/2.networking-tutorial/2j.tcp-data-flow.adoc index c92f3c970..8cba7870b 100644 --- a/doc/modules/ROOT/pages/2.networking-tutorial/2j.tcp-data-flow.adoc +++ b/doc/modules/ROOT/pages/2.networking-tutorial/2j.tcp-data-flow.adoc @@ -8,6 +8,7 @@ // = How TCP Moves Your Data +:page-mode: explanation A naive approach to reliable delivery would be: send one segment, wait for the acknowledgment, send the next. That works, but it is painfully slow. If the round trip between your machine and the server takes 50 milliseconds, you can only send 20 segments per second -- regardless of how much bandwidth the network has. Most of the time is spent waiting. @@ -34,6 +35,7 @@ Imagine the sender has 10,000 bytes to transmit and the window size is 4,000 byt . An ACK arrives acknowledging bytes 0-999. The window slides forward: the sender can now send bytes 4000-4999. . Another ACK arrives acknowledging bytes 1000-1999. The window slides again: bytes 5000-5999 become sendable. +[role=figure] ---- Sent and ACKed | Sent, not ACKed | Sendable | Not yet sendable [ window ] @@ -51,7 +53,7 @@ If the receiver's application is reading data quickly, the buffer stays mostly e If the receiver's application falls behind -- maybe it is busy processing a previous request -- the buffer fills up and the advertised window shrinks. The sender slows down in response. If the window reaches zero, the sender stops entirely and waits for the receiver to free up space. -This feedback loop prevents a fast sender from flooding a slow receiver. It operates automatically, requiring no action from your application code. But it has a practical consequence: if your server reads from the socket slowly, the client's send calls will eventually stall. TCP is applying backpressure through the window mechanism. +This feedback loop prevents a fast sender from flooding a slow receiver. It operates automatically, requiring no action from your application code. But it has a practical consequence: if your server reads from the socket slowly, the client's send calls eventually stall. TCP is applying backpressure through the window mechanism. == Delayed Acknowledgments diff --git a/doc/modules/ROOT/pages/2.networking-tutorial/2k.tcp-reliability.adoc b/doc/modules/ROOT/pages/2.networking-tutorial/2k.tcp-reliability.adoc index 8b302f6db..11fbdc031 100644 --- a/doc/modules/ROOT/pages/2.networking-tutorial/2k.tcp-reliability.adoc +++ b/doc/modules/ROOT/pages/2.networking-tutorial/2k.tcp-reliability.adoc @@ -8,6 +8,7 @@ // = When Packets Go Missing +:page-mode: explanation TCP promises reliable delivery, but the network underneath makes no such promise. Routers drop packets when their queues overflow. Links fail mid-transmission. Interference corrupts data. TCP's job is to detect these failures and recover from them -- transparently, without your application ever knowing a packet was lost. @@ -45,6 +46,7 @@ If segment 4 is lost and segments 5, 6, and 7 arrive, the receiver sends three d TCP treats the arrival of three duplicate ACKs as strong evidence that a specific segment was lost. Rather than waiting for the retransmission timeout, it immediately retransmits the missing segment. This is *fast retransmit*, and it recovers from loss in roughly one round trip instead of waiting for the full RTO. +[role=figure] ---- Sender Receiver | | @@ -87,9 +89,9 @@ Flow control, described in the previous section, allows the receiver to advertis But what if the receiver frees up buffer space and sends an updated window advertisement, and that ACK is lost? The sender would wait forever, believing the window is still zero. The receiver would wait forever, believing it already told the sender to resume. -The *persist timer* breaks this deadlock. When the sender sees a zero window, it starts a timer. When the timer fires, the sender transmits a tiny *window probe* -- a segment with one byte of data. If the receiver's window has opened, the ACK will contain the updated window size and the sender resumes. If the window is still zero, the receiver re-advertises zero and the sender sets the timer again. +The *persist timer* breaks this deadlock. When the sender sees a zero window, it starts a timer. When the timer fires, the sender transmits a tiny *window probe* -- a segment with one byte of data. If the receiver's window has opened, the ACK carries the updated window size and the sender resumes. If the window is still zero, the receiver re-advertises zero and the sender sets the timer again. -Persist probes use exponential backoff, starting at the RTO value and increasing up to a maximum (typically 60 seconds). The sender will probe indefinitely -- it never gives up on a zero-window connection. +Persist probes use exponential backoff, starting at the RTO value and increasing up to a maximum (typically 60 seconds). The sender probes indefinitely -- it never gives up on a zero-window connection. == Silly Window Syndrome @@ -106,7 +108,7 @@ Together, these rules ensure that data flows in reasonably-sized chunks even whe What happens when a TCP connection is idle -- no data flowing in either direction? The answer is: nothing. TCP sends no packets during idle periods. The connection can sit open for hours, days, or weeks with no traffic. -This is usually fine, but it creates a problem: if the remote machine crashes, reboots, or loses network connectivity during an idle period, the local side has no way to discover it. The connection appears healthy, but the first attempt to send data will fail -- possibly after a long timeout. +This is usually fine, but it creates a problem: if the remote machine crashes, reboots, or loses network connectivity during an idle period, the local side has no way to discover it. The connection appears healthy, but the first attempt to send data fails -- possibly after a long timeout. *Keepalive* probes address this. When enabled, TCP periodically sends a tiny probe on idle connections -- typically every two hours. If the remote side responds, the connection is healthy. If no response arrives after several probes, TCP declares the connection dead and reports an error to the application. @@ -114,7 +116,7 @@ Keepalive is not enabled by default on most systems. Applications that need it s == Why This Matters to You -TCP's reliability mechanisms run inside the kernel, invisible to your application. But understanding them explains behaviors you will observe: +TCP's reliability mechanisms run inside the kernel, invisible to your application. But understanding them explains behaviors you observe: * *Brief stalls during data transfer* are often fast retransmit and recovery in action. A single lost packet causes the sender to pause momentarily while it detects the loss and retransmits. * *Sudden throughput drops* happen when TCP detects congestion and halves its sending rate. The sawtooth pattern of congestion avoidance is normal, not a bug. diff --git a/doc/modules/ROOT/pages/2.networking-tutorial/2l.tcp-performance.adoc b/doc/modules/ROOT/pages/2.networking-tutorial/2l.tcp-performance.adoc index ea6399fde..09b96c044 100644 --- a/doc/modules/ROOT/pages/2.networking-tutorial/2l.tcp-performance.adoc +++ b/doc/modules/ROOT/pages/2.networking-tutorial/2l.tcp-performance.adoc @@ -8,10 +8,11 @@ // = Making TCP Fast +:page-mode: explanation TCP was designed when networks ran at kilobits per second and the entire internet fit on a single backbone. The core protocol -- sequence numbers, acknowledgments, sliding windows -- scales remarkably well, but some of its original parameters do not. A 16-bit window field caps the amount of data in flight at 65,535 bytes. Sequence numbers wrap around on fast links. RTT measurements lose precision when segments fly faster than the clock ticks. -Over the decades, a set of extensions has been added to TCP to remove these bottlenecks. They are negotiated during the handshake and are transparent to your application code, but understanding them tells you where TCP performance comes from and where the limits still lie. +Over the decades, TCP gained a set of extensions that remove these bottlenecks. They are negotiated during the handshake and are transparent to your application code, but understanding them tells you where TCP performance comes from and where the limits still lie. == Path MTU Discovery @@ -33,15 +34,16 @@ The maximum throughput of a TCP connection is limited by how much data can be in Consider a 100 Mbps link with a 50-millisecond RTT. The bandwidth-delay product is: +[role=figure] ---- 100,000,000 bits/sec × 0.050 sec = 5,000,000 bits = 625,000 bytes ---- -To fully utilize this link, the sender must have 625,000 bytes of data in flight simultaneously. If the TCP window is smaller than the BDP, the sender will finish transmitting its window and then sit idle waiting for ACKs, leaving bandwidth unused. +To use this link fully, the sender must have 625,000 bytes of data in flight simultaneously. If the TCP window is smaller than the BDP, the sender finishes transmitting its window and then sits idle waiting for ACKs, leaving bandwidth unused. Networks with high bandwidth and high latency -- satellite links, transcontinental fiber, data center interconnects -- have large BDPs. These are sometimes called *long fat networks*, and they expose the original TCP window's 65,535-byte limit as a severe bottleneck. -On a transoceanic link at 10 Gbps with a 100-millisecond RTT, the BDP is 125 megabytes. A 64 KB window would utilize less than 0.05% of the available bandwidth. Without the window scale extension, such a link would be essentially unusable for a single TCP connection. +On a transoceanic link at 10 Gbps with a 100-millisecond RTT, the BDP is 125 megabytes. A 64 KB window would use about 0.05% of the available bandwidth. Without the window scale extension, such a link would be unusable for a single TCP connection. == Window Scaling diff --git a/doc/modules/ROOT/pages/3.tutorials/3.intro.adoc b/doc/modules/ROOT/pages/3.tutorials/3.intro.adoc index 4f0cccc4e..9dfd7474b 100644 --- a/doc/modules/ROOT/pages/3.tutorials/3.intro.adoc +++ b/doc/modules/ROOT/pages/3.tutorials/3.intro.adoc @@ -8,18 +8,20 @@ // = Tutorials +:page-mode: explanation -Networked applications come alive when you build them yourself. Reading -about sockets, protocols, and concurrency only goes so far — the real +Networked applications are easier to understand once you build them yourself. Reading +about sockets, protocols, and concurrency only goes so far. The real understanding comes from writing code that opens connections, sends data, and handles the responses. These tutorials give you that hands-on experience with Corosio. Each tutorial produces a complete, runnable program. You start with source code, compile it, and interact with the result. Along the way you encounter -the patterns that recur throughout real networking code: listening for -incoming connections, connecting to remote services, formatting and parsing -protocol messages, and layering encryption on top of a transport. +the patterns that recur throughout real networking code. Those are +listening for incoming connections, connecting to remote services, +formatting and parsing protocol messages, and layering encryption on top of +a transport. The progression moves from straightforward client-server interactions toward more involved topics. Early tutorials focus on raw TCP communication — reading @@ -28,7 +30,7 @@ protocols where message framing and request-response semantics matter. Security appears naturally as you add TLS to protect data in transit, covering certificate management and encrypted streams. -Throughout the tutorials you will see how Corosio's coroutine-based model +Throughout the tutorials you see how Corosio's coroutine-based model keeps asynchronous code readable. Operations that would require callbacks or state machines in traditional networking code read as sequential steps in a coroutine body. Error handling follows consistent patterns whether diff --git a/doc/modules/ROOT/pages/3.tutorials/3a.echo-server.adoc b/doc/modules/ROOT/pages/3.tutorials/3a.echo-server.adoc index beb6a7668..6da170b18 100644 --- a/doc/modules/ROOT/pages/3.tutorials/3a.echo-server.adoc +++ b/doc/modules/ROOT/pages/3.tutorials/3a.echo-server.adoc @@ -8,8 +8,9 @@ // = Echo Server Tutorial +:page-mode: tutorial -This tutorial builds a production-quality echo server using the `tcp_server` +This tutorial builds a production-quality echo server using the cpp:tcp_server[] framework. We'll explore worker pools, connection lifecycle, and the launcher pattern. @@ -27,16 +28,16 @@ include::example$echo-server/echo_server.cpp[tag=assume] An echo server accepts TCP connections and sends back whatever data clients send. While simple, this pattern demonstrates core concepts: -* Using `tcp_server` for connection management -* Implementing workers with `worker_base` -* Launching session coroutines with `launcher` +* Using cpp:tcp_server[] for connection management +* Implementing workers with cpp:tcp_server::worker_base[worker_base] +* Starting session coroutines with cpp:tcp_server::launcher[launcher] * Reading and writing data with sockets == Architecture -The `tcp_server` framework uses a worker pool pattern: +The cpp:tcp_server[] framework uses a worker pool pattern: -1. Derive from `tcp_server` and define your worker type +1. Derive from cpp:tcp_server[] and define your worker type 2. Preallocate workers during construction 3. The framework accepts connections and dispatches them to idle workers 4. Workers run session coroutines and return to the pool when done @@ -45,7 +46,7 @@ This avoids allocation during operation and limits resource usage. == Worker Implementation -Workers derive from `tcp_server::worker_base` and implement two methods: +Workers derive from cpp:tcp_server::worker_base[worker_base] and implement two methods: [source,cpp] ---- @@ -54,10 +55,10 @@ include::example$echo-server/echo_server.cpp[tag=worker_class] Each worker: -* Stores a reference to the `io_context` for executor access +* Stores a reference to the cpp:io_context[] for executor access * Owns its socket (returned via `socket()`) * Owns any per-connection state (like the buffer) -* Implements `run()` to launch the session coroutine +* Implements `run()` to start the session coroutine == Session Coroutine @@ -100,7 +101,7 @@ include::example$echo-server/echo_server.cpp[tag=main] === Why tcp_server? -The `tcp_server` framework provides: +The cpp:tcp_server[] framework provides: * **Automatic pool management**: Workers cycle between idle and active states * **Safe lifecycle**: The launcher ensures workers return to the pool @@ -108,9 +109,7 @@ The `tcp_server` framework provides: === Why Worker Pooling? -* **Bounded memory**: Fixed number of connections -* **No allocation**: Sockets and buffers preallocated -* **Simple accounting**: Framework tracks worker availability +See "Why a Worker Pool?" in xref:../4.guide/4k.tcp-server.adoc[the TCP Server guide] for the rationale. === Why Composed Write? @@ -144,7 +143,7 @@ include::example$snippets/3a_echo_server.cpp[tag=exceptions_eof,indent=0] Start the server: -[source,bash] +[role=output] ---- $ ./echo_server 8080 10 Echo server listening on port 8080 with 10 workers @@ -152,7 +151,7 @@ Echo server listening on port 8080 with 10 workers Connect with netcat: -[source,bash] +[role=output] ---- $ nc localhost 8080 Hello diff --git a/doc/modules/ROOT/pages/3.tutorials/3b.http-client.adoc b/doc/modules/ROOT/pages/3.tutorials/3b.http-client.adoc index 32b086576..1c04a1df4 100644 --- a/doc/modules/ROOT/pages/3.tutorials/3b.http-client.adoc +++ b/doc/modules/ROOT/pages/3.tutorials/3b.http-client.adoc @@ -8,6 +8,7 @@ // = HTTP Client Tutorial +:page-mode: tutorial This tutorial builds a simple HTTP client that connects to a server, sends a GET request, and reads the response. You'll learn socket connection, @@ -72,7 +73,7 @@ include::example$client/http_client.cpp[tag=run_client] ---- `connect()` opens the socket automatically. We pass the socket as an -`io_stream&` to `do_request`, so the same function works with any plain +cpp:io_stream[] to `do_request`, so the same function works with any plain socket. TLS streams have a different type and need their own overload, as shown below. @@ -104,7 +105,7 @@ This: This example uses exceptions because: -* Connection errors are fatal—we want to abort +* Connection errors are fatal. We want to abort. * The code is more linear without error checks Compare structured bindings: @@ -128,7 +129,7 @@ bindings when errors are expected (like EOF during reading). First, find an IP address for a website: -[source,bash] +[role=output] ---- $ nslookup www.example.com ... @@ -137,7 +138,7 @@ Address: 93.184.215.14 Then run the client: -[source,bash] +[role=output] ---- $ ./http_client 93.184.215.14 80 HTTP/1.1 200 OK @@ -151,9 +152,9 @@ Content-Type: text/html; charset=UTF-8 == Adding TLS Support -To make HTTPS requests, wrap the connected socket in a `wolfssl_stream`. -A `wolfssl_stream` is not an `io_stream`, so it needs its own -`do_request` overload taking `corosio::tls_stream&`: +To make HTTPS requests, wrap the connected socket in a cpp:wolfssl_stream[]. +A cpp:wolfssl_stream[] is not an cpp:io_stream[], so it needs its own +`do_request` overload taking cpp:tls_stream[]: [source,cpp] ---- @@ -163,7 +164,7 @@ include::example$https-client/https_client.cpp[tag=tls_client] ---- The TLS overload mirrors the plain one: `capy::read` and `capy::write` -work with `tls_stream` exactly as they do with `io_stream`. Only the +work with cpp:tls_stream[] exactly as they do with cpp:io_stream[]. Only the parameter type and the surrounding handshake/shutdown differ. == Next Steps diff --git a/doc/modules/ROOT/pages/3.tutorials/3c.dns-lookup.adoc b/doc/modules/ROOT/pages/3.tutorials/3c.dns-lookup.adoc index e5fc1af7f..9912b8305 100644 --- a/doc/modules/ROOT/pages/3.tutorials/3c.dns-lookup.adoc +++ b/doc/modules/ROOT/pages/3.tutorials/3c.dns-lookup.adoc @@ -8,6 +8,7 @@ // = DNS Lookup Tutorial +:page-mode: tutorial This tutorial builds a command-line DNS lookup tool similar to `nslookup`. You'll learn to use the asynchronous resolver to convert hostnames to IP @@ -25,7 +26,7 @@ include::example$nslookup/nslookup.cpp[tag=assume] == Overview DNS resolution converts a hostname like `www.example.com` to one or more IP -addresses. The `resolver` class performs this asynchronously: +addresses. The cpp:resolver[] class performs this asynchronously: [source,cpp] ---- @@ -62,37 +63,20 @@ include::example$nslookup/nslookup.cpp[tag=main] == Resolver Flags -The resolver accepts optional flags to control behavior: +The resolver accepts optional flags to control behavior. See the +xref:../4.guide/4j.resolver.adoc[Resolver Guide] for the full flag reference. [source,cpp] ---- include::example$snippets/3c_dns_lookup.cpp[tag=resolve_with_flags,indent=0] ---- -Available flags: - -[cols="1,3"] -|=== -| Flag | Description - -| `passive` -| Return endpoints suitable for binding (server use) - -| `numeric_host` -| Host is a numeric address string, skip DNS - -| `numeric_service` -| Service is a port number string - -| `address_configured` -| Only return addresses if configured on the system - -| `v4_mapped` -| Return IPv4-mapped IPv6 addresses if no IPv6 found - -| `all_matching` -| With `v4_mapped`, return all matching addresses -|=== +[NOTE] +==== +`v4_mapped` and `all_matching` are currently inert. The resolver always +queries with `ai_family = AF_UNSPEC`, but the underlying `AI_V4MAPPED` and +`AI_ALL` behavior only takes effect for `AF_INET6`-family queries. +==== == Connecting to Resolved Addresses @@ -105,7 +89,7 @@ include::example$snippets/3c_dns_lookup.cpp[tag=connect_to_host] == Running the Lookup Tool -[source,bash] +[role=output] ---- $ ./nslookup www.google.com https Results for www.google.com:https @@ -115,7 +99,7 @@ Results for www.google.com:https Total: 2 addresses ---- -[source,bash] +[role=output] ---- $ ./nslookup localhost 8080 Results for localhost:8080 @@ -126,18 +110,19 @@ Total: 1 addresses == Cancellation -Resolver operations support cancellation via `std::stop_token`: +Resolver operations support cancellation. Explicit cancellation looks like this: [source,cpp] ---- include::example$snippets/3c_dns_lookup.cpp[tag=resolver_cancel,indent=0] ---- -A cancelled resolution completes with `capy::cond::canceled` (the -underlying value is `capy::error::canceled`), so test for it with -`ec == capy::cond::canceled`. +A cancelled resolution completes with `capy::cond::canceled`, so test for it +with `ec == capy::cond::canceled`. The underlying value depends on the path: +`cancel()` reports `capy::error::canceled`, and a stop-token request reports +the generic `std::errc::operation_canceled`. -Or through the affine awaitable protocol, which propagates the stop token from `capy::run_async` to every awaitable in the chain. +Or through the xref:../4.guide/4b.concurrent-programming.adoc[affine awaitable protocol], which propagates the stop token from `capy::run_async` to every awaitable in the chain. == Next Steps diff --git a/doc/modules/ROOT/pages/3.tutorials/3d.tls-context.adoc b/doc/modules/ROOT/pages/3.tutorials/3d.tls-context.adoc index 6c179c23c..2a5260b6e 100644 --- a/doc/modules/ROOT/pages/3.tutorials/3d.tls-context.adoc +++ b/doc/modules/ROOT/pages/3.tutorials/3d.tls-context.adoc @@ -9,9 +9,10 @@ // = TLS Context Configuration +:page-mode: tutorial This tutorial covers how to configure TLS contexts for secure connections. -A `tls_context` stores certificates, keys, and settings that define how +A cpp:tls_context[] stores certificates, keys, and settings that define how TLS connections are established and verified. [NOTE] @@ -25,7 +26,7 @@ include::example$snippets/3d_tls_context.cpp[tag=assume] == Introduction -The `tls_context` class is a portable abstraction for TLS configuration that +The cpp:tls_context[] class is a portable abstraction for TLS configuration that works across multiple TLS backends (WolfSSL, OpenSSL, mbedTLS, etc.). It handles: @@ -35,25 +36,30 @@ handles: * Configuring certificate verification behavior * Managing revocation checking (CRLs) -Use `tls_context` to configure TLS settings once, then pass it to TLS streams +Use cpp:tls_context[] to configure TLS settings once, then pass it to TLS streams for establishing secure connections. [NOTE] ==== -Two implemented features depend on how WolfSSL was built: +Two implemented features depend on how WolfSSL was built. `set_default_verify_paths()` needs `WOLFSSL_SYS_CA_CERTS` to load the system -store, and `set_verify_callback()` needs `WOLFSSL_ALWAYS_VERIFY_CB` -(otherwise installing a callback fails the handshake with -`std::errc::function_not_supported` rather than failing open). Both are +store. `set_verify_callback()` needs `WOLFSSL_ALWAYS_VERIFY_CB`; without it, +installing a callback fails the handshake with +`std::errc::function_not_supported` rather than failing open. Both are enabled by `--enable-opensslextra`. ==== == Construction -A `tls_context` is a shared handle to an opaque implementation. Copies share +A cpp:tls_context[] is a shared handle to an opaque implementation. Copies share the same underlying state, making it easy to pass contexts by value and share them across multiple TLS streams. +IMPORTANT: Don't modify a context after creating a stream from it. The +configuration is captured when the first stream is constructed, so later +changes reach neither existing nor new streams. If you need a different +configuration, create a separate context. + [source,cpp] ---- include::example$snippets/3d_tls_context.cpp[tag=construction,indent=0] @@ -137,7 +143,7 @@ loading. See <> for details. == Trust Anchors Trust anchors are root CA certificates used to verify peer certificates. -Without trust anchors, certificate verification will fail. +Without trust anchors, certificate verification fails. === Using System Trust Store @@ -221,8 +227,8 @@ include::example$snippets/3d_tls_context.cpp[tag=version_bounds,indent=0] ---- NOTE: On WolfSSL the ceiling is enforced by selecting a version-specific -method (no native set-max call exists); a window whose minimum exceeds its -maximum yields a context that fails the handshake. +method, because no native set-max call exists. A window whose minimum +exceeds its maximum yields a context that fails the handshake. === Cipher Suites @@ -322,9 +328,9 @@ include::example$snippets/3d_tls_context.cpp[tag=verify_callback,indent=0] ==== Which certificates the callback sees depends on the backend: -* OpenSSL — invoked for every certificate, including ones that passed, so - it can both relax verification (accept a rejected cert) and tighten it - (reject an otherwise-valid cert, e.g. pinning). +* OpenSSL — invoked for every certificate, including ones that passed. It + can therefore both relax verification (accept a rejected cert) and + tighten it (reject an otherwise-valid cert, as pinning does). * WolfSSL with `WOLFSSL_ALWAYS_VERIFY_CB` (via `--enable-opensslextra`) — same as OpenSSL. * WolfSSL without it — the library invokes the callback only on failure, diff --git a/doc/modules/ROOT/pages/3.tutorials/3e.hash-server.adoc b/doc/modules/ROOT/pages/3.tutorials/3e.hash-server.adoc index 2d0dfb946..ec3fdd2a1 100644 --- a/doc/modules/ROOT/pages/3.tutorials/3e.hash-server.adoc +++ b/doc/modules/ROOT/pages/3.tutorials/3e.hash-server.adoc @@ -8,10 +8,11 @@ // = Hash Server Tutorial +:page-mode: tutorial This tutorial builds a TCP server that reads data from clients, computes a hash on a thread pool, and sends the result back. You'll learn how to combine -an `io_context` for network I/O with a `thread_pool` for CPU-bound work, +an cpp:io_context[] for network I/O with a `thread_pool` for CPU-bound work, switching between them mid-coroutine with `capy::run()`. [NOTE] @@ -26,20 +27,20 @@ include::example$hash-server/hash_server.cpp[tag=assume] == Overview Most servers spend their time waiting on the network. When the work between -reads and writes is cheap, a single-threaded `io_context` handles thousands -of connections without breaking a sweat. But some operations — cryptographic +reads and writes is cheap, a single-threaded cpp:io_context[] handles thousands +of connections. But some operations — cryptographic hashes, compression, image processing — consume real CPU time. Running those inline blocks the event loop and starves every other connection. -The solution is to keep I/O on the `io_context` and offload heavy computation -to a `thread_pool`. Capy's `run()` function makes this seamless: a single +The solution is to keep I/O on the cpp:io_context[] and offload heavy computation +to a `thread_pool`. Capy's `run()` function makes this seamless. A single `co_await` switches the coroutine to the pool, runs the work, and resumes back on the original executor when it finishes. This tutorial demonstrates: -* Accepting connections with `tcp_acceptor` -* Spawning independent session coroutines with `run_async` +* Accepting connections with cpp:tcp_acceptor[] +* Starting independent session coroutines with `run_async` * Switching executors with `capy::run()` for CPU-bound work * The trampoline that returns the coroutine to its home executor @@ -69,13 +70,13 @@ include::example$hash-server/hash_server.cpp[tag=session] Three things happen in sequence, but on two different executors: -1. **Read** — runs on the `io_context` thread. The socket awaitable suspends +1. **Read** — runs on the cpp:io_context[] thread. The socket awaitable suspends the coroutine until data arrives from the kernel. 2. **Hash** — `capy::run( pool.get_executor() )` posts `compute_fnv1a` to the - thread pool. The coroutine suspends on the `io_context` and resumes on a + thread pool. The coroutine suspends on the cpp:io_context[] and resumes on a pool thread. When the task completes, a trampoline posts the - coroutine back to the `io_context`. -3. **Write** — back on the `io_context` thread, the hex result is sent to the + coroutine back to the cpp:io_context[]. +3. **Write** — back on the cpp:io_context[] thread, the hex result is sent to the client. The executor switch is invisible at the call site — it reads like straight-line @@ -96,20 +97,20 @@ Behind the scenes: 2. On `co_await`, the awaitable's `await_suspend` posts the inner task through `pool_executor.post(task_handle)` — the task is queued for a worker thread. -3. The calling coroutine suspends (the `io_context` is free to process other +3. The calling coroutine suspends (the cpp:io_context[] is free to process other connections). 4. A pool thread picks up the task and runs it to completion. 5. The task's `final_suspend` resumes a trampoline, which calls `io_context_executor.post(caller_handle)` to post the caller back - to the `io_context`. -6. The caller resumes on the `io_context` thread with the hash result. + to the cpp:io_context[]. +6. The caller resumes on the cpp:io_context[] thread with the hash result. The key insight: the caller's executor is captured before the switch and restored automatically after. You never need to manually post back. == Accept Loop -The accept loop creates a socket per connection and spawns a session: +The accept loop creates a socket per connection and starts a session: [source,cpp] ---- @@ -117,7 +118,7 @@ include::example$hash-server/hash_server.cpp[tag=accept] ---- `run_async` is fire-and-forget — each session runs independently on the -`io_context`. The accept loop immediately continues waiting for the next +cpp:io_context[]. The accept loop immediately continues waiting for the next connection. == Main Function @@ -127,7 +128,7 @@ connection. include::example$hash-server/hash_server.cpp[tag=main] ---- -The `io_context` drives all network I/O on the main thread. The thread pool +The cpp:io_context[] drives all network I/O on the main thread. The thread pool runs four worker threads for hash computation. `pool.join()` waits for any in-flight pool work after the event loop exits. @@ -149,7 +150,7 @@ These two functions serve different purposes: caller on its original executor |=== -In this example, `run_async` launches the accept loop from `main`, and +In this example, `run_async` starts the accept loop from `main`, and `run` switches individual hash computations to the thread pool from within a session coroutine. @@ -157,7 +158,7 @@ a session coroutine. Start the server: -[source,bash] +[role=output] ---- $ ./hash_server 8080 Hash server listening on port 8080 @@ -165,7 +166,7 @@ Hash server listening on port 8080 Send data with netcat: -[source,bash] +[role=output] ---- $ echo "hello world" | nc -q1 localhost 8080 782e1488cd5a68b7 diff --git a/doc/modules/ROOT/pages/3.tutorials/3f.reconnect.adoc b/doc/modules/ROOT/pages/3.tutorials/3f.reconnect.adoc index ce34c1120..c6ca20a14 100644 --- a/doc/modules/ROOT/pages/3.tutorials/3f.reconnect.adoc +++ b/doc/modules/ROOT/pages/3.tutorials/3f.reconnect.adoc @@ -8,6 +8,7 @@ // = Reconnect with Exponential Backoff +:page-mode: tutorial This tutorial builds a TCP client that connects to a server and automatically reconnects with exponential backoff when the connection fails. You'll learn @@ -58,8 +59,8 @@ With an initial delay of 500ms and a 30s cap, calling `next()` produces: 500, 1000, 2000, 4000, 8000, 16000, 30000, 30000, ... Keeping the policy separate from the `delay()` call means it can be reused -in any context (synchronous retries, tests, or logging) without pulling in -async machinery. +in any context. Synchronous retries, tests, and logging all work without +pulling in async machinery. == Session Coroutine @@ -112,7 +113,7 @@ When the stop source is signaled: 1. The loop only inspects the `delay()` call for cancellation, so that is where the stop is observed: `delay()` returns `cond::canceled`. A stop - requested mid-`connect()` is not checked there; it simply falls through to + requested mid-`connect()` is not checked there; it falls through to the next `delay()` call, which then returns `cond::canceled`. Either way the coroutine exits cleanly. 2. The coroutine checks the error and executes `co_return`. @@ -164,7 +165,7 @@ returns. Start an echo server on one terminal: -[source,bash] +[role=output] ---- $ ./echo_server 8080 10 Echo server listening on port 8080 with 10 workers @@ -172,7 +173,7 @@ Echo server listening on port 8080 with 10 workers Run the reconnect client on another: -[source,bash] +[role=output] ---- $ ./reconnect 127.0.0.1 8080 Connected on attempt 1 @@ -180,7 +181,7 @@ Connected on attempt 1 Stop the server and watch the client retry: -[source,bash] +[role=output] ---- Attempt 1 failed: Connection refused Retrying in 500ms @@ -194,7 +195,7 @@ Restart the server, and the client reconnects on the next attempt. To test the no-server case, point the client at a port with nothing listening: -[source,bash] +[role=output] ---- $ ./reconnect 127.0.0.1 19999 Attempt 1 failed: Connection refused diff --git a/doc/modules/ROOT/pages/4.guide/4.intro.adoc b/doc/modules/ROOT/pages/4.guide/4.intro.adoc index 96aa33a30..3631f202c 100644 --- a/doc/modules/ROOT/pages/4.guide/4.intro.adoc +++ b/doc/modules/ROOT/pages/4.guide/4.intro.adoc @@ -8,25 +8,26 @@ // = Guide +:page-mode: explanation This guide provides a comprehensive reference for Corosio's components and the -concepts behind them. By the time you finish, you will have a deep understanding -of how each piece works, when to use it, and how the pieces fit together to -build robust networked applications. +concepts behind them. By the time you finish, you understand how each piece +works and when to use it. You also understand how the pieces fit together +to build robust networked applications. -The guide begins with the foundations of event-driven I/O. You will learn how -operating systems handle asynchronous operations, how the event loop processes -work, and how coroutines provide concurrency without the complexity of threads. -These foundational topics establish the mental model you need to reason about -asynchronous code confidently. - -From there, the guide moves into networking primitives. You will explore how TCP +The guide begins with networking primitives. You explore how TCP connections are established and managed, how addresses and ports identify communicating processes, and how data flows through sockets as byte streams. Each component is covered in detail so you understand not just the API, but the underlying mechanics that inform correct usage. -More advanced topics build on these foundations. You will encounter patterns for +From there, the guide moves into the foundations of event-driven I/O. You learn how +operating systems handle asynchronous operations, how the event loop processes +work, and how coroutines provide concurrency without the complexity of threads. +These foundational topics establish the mental model you need to reason about +asynchronous code confidently. + +More advanced topics build on these foundations. You encounter patterns for building scalable servers, managing connection lifecycles, composing operations for reliable data transfer, and securing connections with encryption. The guide also covers practical concerns like error handling strategies, delays and @@ -37,6 +38,6 @@ to be read independently. If you already understand a particular area, you can skip ahead to the topics that interest you most. The guide complements the tutorials with thorough API coverage and in-depth -design explanations. Where the tutorials focus on building complete working -programs, the guide provides the detailed understanding you need to adapt those -patterns to your own applications and troubleshoot issues when they arise. +design explanations. The tutorials focus on building complete working programs. +The guide provides the detailed understanding you need to adapt those patterns +to your own applications, and to troubleshoot issues when they arise. diff --git a/doc/modules/ROOT/pages/4.guide/4a.tcp-networking.adoc b/doc/modules/ROOT/pages/4.guide/4a.tcp-networking.adoc index 00964cd3f..4362103ca 100644 --- a/doc/modules/ROOT/pages/4.guide/4a.tcp-networking.adoc +++ b/doc/modules/ROOT/pages/4.guide/4a.tcp-networking.adoc @@ -8,6 +8,7 @@ // = TCP/IP Networking +:page-mode: how-to This chapter introduces the networking concepts you need to understand before using Corosio. If you're already comfortable with TCP/IP, sockets, and the @@ -15,9 +16,9 @@ client-server model, you can skip to xref:4.guide/4c.io-context.adoc[I/O Context == What is a Network? -A network is simply computers talking to each other. Your laptop sending a -request to a web server, two game consoles playing together, a phone streaming -video—all involve computers exchanging data over a network. +A network is computers talking to each other. Consider a laptop sending a +request to a web server, two game consoles playing together, or a phone +streaming video. All involve computers exchanging data over a network. === Local vs Remote Communication @@ -148,6 +149,7 @@ abstractions. When you send data, each layer wraps it with its own header: +[role=figure] ---- Application Data: "Hello" ↓ @@ -204,7 +206,7 @@ Networks are divided into subnets using a _netmask_. The netmask defines which bits of the address identify the network versus the host. CIDR (Classless Inter-Domain Routing) notation combines the address and prefix -length: `192.168.1.0/24` means the first 24 bits identify the network, leaving +length. `192.168.1.0/24` means the first 24 bits identify the network, leaving 8 bits (256 addresses) for hosts. Common prefix lengths: @@ -300,20 +302,10 @@ contrasts with UDP, which just sends packets without setup. === Three-Way Handshake -TCP connections begin with a three-way handshake: - ----- -Client Server - | | - |------ SYN seq=x -------->| "I want to connect, my sequence starts at x" - | | - |<-- SYN-ACK seq=y ack=x+1-| "OK, my sequence starts at y, I got your x" - | | - |------ ACK ack=y+1 ------>| "I got your y, connection established" - | | ----- - -After the handshake, both sides can send and receive data. +TCP connections begin with a three-way handshake. After the handshake, both +sides can send and receive data. For the segment exchange, see +xref:2.networking-tutorial/2i.tcp-connections.adoc[Opening and Closing TCP +Connections]. === Sequence Numbers and Acknowledgments @@ -327,22 +319,17 @@ in sequence and discard duplicates. === Flow Control -The receiver advertises a _window size_: how many bytes it can buffer. The -sender must not have more unacknowledged bytes in flight than the receiver's -window allows. This prevents a fast sender from overwhelming a slow receiver. - -The window size dynamically adjusts. When the receiver processes data, it opens -the window; when it falls behind, the window shrinks. +The receiver advertises a _window size_: how many bytes it can buffer. This +prevents a fast sender from overwhelming a slow receiver. For the +sliding-window mechanism, see +xref:2.networking-tutorial/2j.tcp-data-flow.adoc[How TCP Moves Your Data]. === Congestion Control -TCP also limits sending rate to avoid overwhelming the network itself. Algorithms -like slow start, congestion avoidance, and fast retransmit probe for available -bandwidth. - -When packet loss occurs (detected by missing acknowledgments), TCP assumes -network congestion and reduces its sending rate. This is why TCP throughput can -vary significantly depending on network conditions. +TCP also limits sending rate to avoid overwhelming the network itself. This is +why TCP throughput can vary significantly depending on network conditions. For +slow start, congestion avoidance, and fast retransmit, see +xref:2.networking-tutorial/2k.tcp-reliability.adoc[When Packets Go Missing]. === Retransmission @@ -357,6 +344,7 @@ multiple times before giving up. TCP connections close with a four-way handshake: +[role=figure] ---- Client Server | | @@ -378,39 +366,9 @@ handshake, used when something goes wrong. === TCP States -A TCP connection moves through states: - -[cols="1,3"] -|=== -| State | Meaning - -| LISTEN -| Server waiting for connections - -| SYN_SENT -| Client has sent SYN, waiting for response - -| SYN_RECEIVED -| Server has received SYN, sent SYN-ACK - -| ESTABLISHED -| Connection open, data can flow - -| FIN_WAIT_1 -| Sent FIN, waiting for ACK - -| FIN_WAIT_2 -| FIN acknowledged, waiting for peer's FIN - -| CLOSE_WAIT -| Received peer's FIN, waiting for application to close - -| TIME_WAIT -| Waiting to ensure peer received final ACK - -| CLOSED -| Connection fully terminated -|=== +A TCP connection moves through a series of states. For the full state machine, +see xref:2.networking-tutorial/2i.tcp-connections.adoc[Opening and Closing TCP +Connections]. The TIME_WAIT state lasts 2× the maximum segment lifetime (typically 1-2 minutes). This prevents old packets from a closed connection being mistaken @@ -471,7 +429,7 @@ TCP is appropriate for most applications where correctness matters. UDP includes a checksum covering the header and data. The receiver verifies the checksum and discards corrupted packets. Unlike TCP, UDP doesn't -retransmit—the data is simply lost. +retransmit—the data is lost. Corosio supports TCP, UDP, and Unix domain sockets. See xref:4.guide/4p.unix-sockets.adoc[Unix Domain Sockets] for local inter-process @@ -616,7 +574,7 @@ database. DNS responses often include multiple addresses (for load balancing or redundancy). Your application should try each address until one succeeds. -Corosio provides the `resolver` class for asynchronous DNS lookups: +Corosio provides the cpp:resolver[] class for asynchronous DNS lookups: [source,cpp] ---- @@ -684,16 +642,16 @@ When a socket closes, its address may remain in TIME_WAIT state for minutes. This is essential for servers that restart—without it, the restart fails with "address already in use" until TIME_WAIT expires. -The flag means something different on Windows: there it grants a socket the +The flag means something different on Windows. There it grants a socket the right to bind over an address another socket actively holds, silently -splitting incoming connections between the two. Windows servers that want -the POSIX contract set `SO_EXCLUSIVEADDRUSE` instead — the `tcp_acceptor` +splitting incoming connections between the two. Windows servers that want the +POSIX contract set `SO_EXCLUSIVEADDRUSE` instead. The cpp:tcp_acceptor[] convenience constructor does this automatically, so a second listener fails with `errc::address_in_use` on every platform. === SO_REUSEPORT -`SO_REUSEPORT` (available on some operating systems) allows multiple sockets +`SO_REUSEPORT` (not available on Windows) allows multiple sockets to bind to the same address and port. The operating system distributes incoming connections among them. @@ -704,14 +662,14 @@ listening socket. Corosio wraps the complexity of TCP programming in a coroutine-friendly API: -* **tcp_socket** — Connect to servers, send and receive data over TCP -* **udp_socket** — Send and receive datagrams over UDP -* **tcp_acceptor** — Listen for and accept incoming TCP connections -* **local_stream_socket** — Stream-oriented Unix domain sockets for local IPC -* **local_datagram_socket** — Datagram-oriented Unix domain sockets for local IPC -* **resolver** — Translate hostnames to IP addresses -* **endpoint** — Represent IP addresses and ports -* **local_endpoint** — Represent Unix socket paths +* cpp:tcp_socket[] — Connect to servers, send and receive data over TCP +* cpp:udp_socket[] — Send and receive datagrams over UDP +* cpp:tcp_acceptor[] — Listen for and accept incoming TCP connections +* cpp:local_stream_socket[] — Stream-oriented Unix domain sockets for local IPC +* cpp:local_datagram_socket[] — Datagram-oriented Unix domain sockets for local IPC +* cpp:resolver[] — Translate hostnames to IP addresses +* cpp:endpoint[] — Represent IP addresses and ports +* cpp:local_endpoint[] — Represent Unix socket paths All operations are asynchronous and return awaitables. You don't manage raw socket handles or deal with platform-specific APIs directly. diff --git a/doc/modules/ROOT/pages/4.guide/4b.concurrent-programming.adoc b/doc/modules/ROOT/pages/4.guide/4b.concurrent-programming.adoc index ecbe7118e..6b8dc8648 100644 --- a/doc/modules/ROOT/pages/4.guide/4b.concurrent-programming.adoc +++ b/doc/modules/ROOT/pages/4.guide/4b.concurrent-programming.adoc @@ -8,6 +8,7 @@ // = Concurrent Programming +:page-mode: how-to Network servers often handle many clients simultaneously. This chapter explains how Corosio supports concurrency using C++20 coroutines and the strand pattern @@ -34,8 +35,8 @@ response, another client's request is being sent. Concurrency increases throughput. A single-threaded server handling one connection at a time might manage 100 requests per second. The same server with -concurrency might handle 10,000—not because any single request is faster, but -because the server overlaps waiting time with useful work. +concurrency might handle 10,000. No single request is faster; the server +overlaps waiting time with useful work. === Concurrency vs Parallelism @@ -189,7 +190,7 @@ handling. === The Reactor Pattern Corosio uses the reactor pattern: register interest in I/O events, wait for -events, dispatch handlers. The `io_context::run()` method implements this loop. +events, dispatch handlers. The cpp:io_context[] `run()` method implements this loop. The reactor is efficient because it waits for any of many events simultaneously, rather than polling each socket individually. @@ -197,8 +198,8 @@ rather than polling each socket individually. == C++20 Coroutines A coroutine is a function that can suspend and resume execution. Unlike threads, -coroutines don't block the thread when waiting—they yield control to a -scheduler. +coroutines don't block the thread when waiting—they yield control to an +executor. === Language Mechanics @@ -285,7 +286,7 @@ necessarily the thread that started the operation. include::example$snippets/4b_concurrent_programming.cpp[tag=executor_affinity,indent=0] ---- -If `io_context::run()` is called from one thread, resumptions happen on that +If cpp:io_context[] `run()` is called from one thread, resumptions happen on that thread. With multiple threads calling `run()`, resumptions happen on whichever thread is available. @@ -300,6 +301,7 @@ happens automatically—you don't need explicit dispatch calls. A _strand_ guarantees that handlers posted to it don't run concurrently. Even with multiple threads, strand operations execute one at a time. +[role=figure] ---- ┌───────────────┐ Thread A│ │ @@ -371,7 +373,7 @@ sequential `co_await`s naturally serialize: include::example$snippets/4b_concurrent_programming.cpp[tag=strand_session] ---- -With a single-threaded `io_context`, coroutines sharing that executor can +With a single-threaded cpp:io_context[], coroutines sharing that executor can safely access shared state without locks. == Scaling Strategies @@ -380,7 +382,7 @@ Different applications need different concurrency strategies. === Single-Threaded: One Thread, Many Coroutines -The simplest model: one thread runs `io_context::run()`, handling all events. +The simplest model: one thread runs cpp:io_context[] `run()`, handling all events. Coroutines provide concurrency without threads. Advantages: @@ -439,7 +441,7 @@ include::example$snippets/4b_concurrent_programming.cpp[tag=accept_loop] ---- Each `handle_client` coroutine runs independently. The accept loop continues -immediately after spawning. +immediately after starting. This works well when: @@ -458,7 +460,7 @@ include::example$snippets/4b_concurrent_programming.cpp[tag=worker_pool] include::example$snippets/4b_concurrent_programming.cpp[tag=worker_pool_use,indent=0] ---- -Corosio's `tcp_server` class implements this pattern—see +Corosio's cpp:tcp_server[] class implements this pattern—see xref:4.guide/4k.tcp-server.adoc[TCP Server] for details. === Pipelines @@ -488,14 +490,14 @@ from running. === Dangling References in Async Code -Spawned coroutines must not hold references to destroyed objects: +Started coroutines must not hold references to destroyed objects: [source,cpp] ---- include::example$snippets/4b_concurrent_programming.cpp[tag=dangling_reference,indent=0] ---- -A coroutine may outlive the scope that spawned it. Ensure captured data lives +A coroutine may outlive the scope that started it. Ensure captured data lives long enough. === Cross-Executor Access diff --git a/doc/modules/ROOT/pages/4.guide/4c.io-context.adoc b/doc/modules/ROOT/pages/4.guide/4c.io-context.adoc index 6ce8f629d..71652b2e7 100644 --- a/doc/modules/ROOT/pages/4.guide/4c.io-context.adoc +++ b/doc/modules/ROOT/pages/4.guide/4c.io-context.adoc @@ -8,8 +8,9 @@ // = I/O Context +:page-mode: how-to -The `io_context` class is the heart of Corosio. It's an event loop that +The cpp:io_context[] class is the event loop every Corosio program needs: it processes asynchronous I/O operations, manages timers, and coordinates coroutine execution. @@ -24,14 +25,14 @@ include::example$snippets/4c_io_context.cpp[tag=assume] == Overview -Every Corosio program needs at least one `io_context`: +Every Corosio program needs at least one cpp:io_context[]: [source,cpp] ---- include::example$snippets/4c_io_context.cpp[tag=overview,indent=0] ---- -The `io_context`: +The cpp:io_context[]: * Owns the platform-specific I/O backend (IOCP on Windows) * Maintains a queue of pending work items @@ -47,9 +48,9 @@ The `io_context`: include::example$snippets/4c_io_context.cpp[tag=construct_default,indent=0] ---- -Creates an `io_context` with a concurrency hint of +Creates an cpp:io_context[] with a concurrency hint of `std::max(1u, std::thread::hardware_concurrency())` and the default, fully -thread-safe locking tier. Thread safety is governed by the `locking` option +thread-safe locking tier. Thread safety is governed by the cpp:io_context_options::locking[locking] option (see xref:4.guide/4c2.configuration.adoc#single-threaded-mode[Locking Tiers]). === With Concurrency Hint @@ -68,11 +69,12 @@ are expected to call `run()`. It tunes: * On Windows, it is passed to `CreateIoCompletionPort` as `NumberOfConcurrentThreads`, bounding how many completion threads may run concurrently. It is not a library-managed thread pool, and it is distinct - from `thread_pool_size`. + from cpp:io_context_options::thread_pool_size[thread_pool_size]. -The hint does not affect thread safety, which is governed by the `locking` -tier. To reduce synchronization overhead in a single-threaded program, select a -lockless `locking` tier (see +The hint does not affect thread safety, which is governed by the cpp:io_context_options::locking[locking] +tier. A lockless cpp:io_context_options::locking[locking] tier (`unsafe_io` or `unsafe`) overrides +the effective hint to 1 regardless of the value passed. To reduce synchronization overhead in a +single-threaded program, select a lockless cpp:io_context_options::locking[locking] tier (see xref:4.guide/4c2.configuration.adoc#single-threaded-mode[Locking Tiers]). == Running the Event Loop @@ -143,7 +145,7 @@ queued but won't be processed. === stopped() -Check if the context has been stopped: +Check whether the context stopped: [source,cpp] ---- @@ -202,7 +204,7 @@ include::example$programs/4c_io_context_typical.cpp[tag=full] == Thread Safety -The `io_context` is thread-safe by default, regardless of the concurrency hint: +The cpp:io_context[] is thread-safe by default, regardless of the concurrency hint: multiple threads may call `run()` concurrently, and any thread may `post()` work into it. @@ -211,9 +213,9 @@ into it. include::example$snippets/4c_io_context.cpp[tag=multithreaded,indent=0] ---- -Multiple threads can call `run()` concurrently. The `io_context` distributes -work across threads. The one exception is a lockless `locking` tier: constructing -with `io_context_options::locking` set to `unsafe_io` or `unsafe` drops these +Multiple threads can call `run()` concurrently. The cpp:io_context[] distributes +work across threads. The one exception is a lockless cpp:io_context_options::locking[locking] tier: constructing +with cpp:io_context_options::locking[locking] set to `unsafe_io` or `unsafe` drops these guarantees (see xref:4.guide/4c2.configuration.adoc#single-threaded-mode[Locking Tiers]). @@ -222,15 +224,15 @@ Don't access the same socket from multiple threads without synchronization. == Lifetime and Teardown -The `io_context` must outlive every operation posted or dispatched through its -executor, and no thread may still be inside a `run()` call when the context is +The cpp:io_context[] must outlive every operation posted or dispatched through its +executor. No thread may still be inside a `run()` call when the context is destroyed. Posting to the context — from a thread it does not track — concurrently with, or after, its destruction is undefined behavior. -Work launched with `capy::run` / `capy::run_async` is work-tracked, so a normal +Work started with `capy::run` / `capy::run_async` is work-tracked, so a normal `run()` completion already waits for it to finish. The safe teardown pattern is -therefore: stop submitting new work, let every `run()` return, join the threads -that ran the loop, and only then destroy the context. +therefore to stop submitting new work and let every `run()` return. Join the +threads that ran the loop, and only then destroy the context. [source,cpp] ---- @@ -243,7 +245,7 @@ behavior — join those threads first. == Inheritance from execution_context -`io_context` inherits from `capy::execution_context`, providing service +cpp:io_context[] inherits from `capy::execution_context`, providing service management: [source,cpp] @@ -251,21 +253,21 @@ management: include::example$snippets/4c_io_context.cpp[tag=services,indent=0] ---- -Services are destroyed when the `io_context` is destroyed. +Services are destroyed when the cpp:io_context[] is destroyed. == Platform Details === Windows (IOCP) -On Windows, the `io_context` uses I/O Completion Ports: +On Windows, the cpp:io_context[] uses I/O Completion Ports: * Scalable to thousands of concurrent connections * Efficient thread pool utilization -* Native async I/O with zero-copy potential +* Native async I/O === Linux (epoll) -On Linux, the `io_context` uses epoll: +On Linux, the cpp:io_context[] uses epoll: * Scalable to large numbers of file descriptors * Edge-triggered notifications @@ -281,7 +283,7 @@ Linux also provides an io_uring backend: === macOS / FreeBSD (kqueue) -On macOS and FreeBSD, the `io_context` uses kqueue: +On macOS and FreeBSD, the cpp:io_context[] uses kqueue: * Efficient event notification * File descriptor monitoring diff --git a/doc/modules/ROOT/pages/4.guide/4c2.configuration.adoc b/doc/modules/ROOT/pages/4.guide/4c2.configuration.adoc index aacc4d191..507265b71 100644 --- a/doc/modules/ROOT/pages/4.guide/4c2.configuration.adoc +++ b/doc/modules/ROOT/pages/4.guide/4c2.configuration.adoc @@ -1,20 +1,21 @@ = Configuration +:page-mode: how-to :navtitle: Configuration -The `io_context_options` struct provides runtime tuning knobs for the +The cpp:io_context_options[] struct provides runtime tuning knobs for the I/O context and its backend scheduler. The defaults listed in the table below are the struct field defaults — the values you get from a -freshly default-constructed `io_context_options`. +freshly default-constructed cpp:io_context_options[]. [IMPORTANT] ==== The inline budget defaults in the table are *not* what a -default-constructed `io_context` runs with on a multi-core machine. +default-constructed cpp:io_context[] runs with on a multi-core machine. When `concurrency_hint > 1` and the inline budgets are still at their -struct defaults, the constructor overrides them to `(0, 0, 0)`, -disabling the inline fast path (see the note under -<>). Pass an explicit -`io_context_options` with non-default budgets to keep the fast path +struct defaults, the constructor overrides them to `(0, 0, 0)`. That +disables the inline fast path; see the note under +<>. Pass an explicit +cpp:io_context_options[] with non-default budgets to keep the fast path enabled in multi-threaded mode. ==== @@ -25,7 +26,7 @@ include::example$snippets/4c2_configuration.cpp[tag=options_basic_include] include::example$snippets/4c2_configuration.cpp[tag=options_basic,indent=0] ---- -Both `io_context` and `native_io_context` accept options: +Both cpp:io_context[] and cpp:native_io_context[] accept options: [source,cpp] ---- @@ -40,56 +41,79 @@ include::example$snippets/4c2_configuration.cpp[tag=options_native,indent=0] |=== | Option | Default | Backends | Description -| `max_events_per_poll` +| cpp:io_context_options::max_events_per_poll[max_events_per_poll] | 128 | epoll, kqueue | Number of events fetched per reactor poll call. Larger values reduce syscall frequency under high load; smaller values improve fairness between connections. -| `inline_budget_initial` +| cpp:io_context_options::inline_budget_initial[inline_budget_initial] | 2 | epoll, kqueue, select | Starting inline completion budget per handler chain. After a posted handler executes, the reactor grants this many speculative inline completions before forcing a re-queue. -| `inline_budget_max` +| cpp:io_context_options::inline_budget_max[inline_budget_max] | 16 | epoll, kqueue, select | Hard ceiling on adaptive inline budget ramp-up. The budget doubles each cycle it is fully consumed, up to this limit. -| `unassisted_budget` +| cpp:io_context_options::unassisted_budget[unassisted_budget] | 4 | epoll, kqueue, select | Inline budget when no other thread is running the event loop. Prevents a single-threaded context from starving connections. -| `thread_pool_size` +| cpp:io_context_options::thread_pool_size[thread_pool_size] | 1 | POSIX (epoll, kqueue, select) | Number of worker threads in the shared thread pool used for blocking file I/O and DNS resolution. Ignored on IOCP where file I/O uses native overlapped I/O. -| `locking` +| cpp:io_context_options::locking[locking] | `locking_mode::safe` | all | Locking-safety tier: `safe` (default, full thread safety), `unsafe_io` (per-descriptor I/O locks off), or `unsafe` (all locks off). Governs the thread-safety contract. See <> for the tiers and their restrictions. + +| cpp:io_context_options::enable_sqpoll[enable_sqpoll] +| false +| io_uring +| Enable `IORING_SETUP_SQPOLL`. The kernel busy-polls the submission + ring, so submitting becomes a userspace-only memory store and the + `io_uring_enter` syscall leaves the submit path. Most useful for + sustained traffic. + +| cpp:io_context_options::sq_thread_idle_ms[sq_thread_idle_ms] +| 0 +| io_uring +| Idle timeout of the SQ-poll kernel thread, in milliseconds. After + that many milliseconds without submissions the thread sleeps, and the + next submit re-wakes it. `0` selects the kernel default (1{nbsp}ms). + Ignored unless `enable_sqpoll` is true. + +| cpp:io_context_options::sq_thread_cpu[sq_thread_cpu] +| -1 +| io_uring +| CPU to pin the SQ-poll kernel thread to; `-1` leaves it unpinned for + the kernel scheduler to place. Ignored unless `enable_sqpoll` is + true. |=== Options that do not apply to the active backend are silently ignored. -The one exception is `thread_pool_size`, which is always validated: +The one exception is cpp:io_context_options::thread_pool_size[thread_pool_size], which is always validated: a value less than `1` causes construction to throw `std::invalid_argument`. == Tuning Guidelines -=== Event Buffer Size (`max_events_per_poll`) +=== Event Buffer Size (cpp:io_context_options::max_events_per_poll[max_events_per_poll]) The event buffer controls how many I/O events are fetched in a single `epoll_wait()` or `kevent()` call. @@ -111,40 +135,40 @@ a re-queue through the scheduler. * *Request-response workloads* (HTTP, RPC): keep at 16 to prevent one connection from monopolizing a thread. * *Single-threaded contexts*: - `unassisted_budget` caps the budget when only one thread is + cpp:io_context_options::unassisted_budget[unassisted_budget] caps the budget when only one thread is running the event loop, preserving fairness. -* *Disable the fast path entirely*: set all three options to 0 to - force a re-queue on every completion (useful as a baseline or - when a workload is dominated by cross-thread work-stealing). +* *Disable the fast path entirely*: set all three options to 0 to force + a re-queue on every completion. This is useful as a baseline, or when + a workload is dominated by cross-thread work-stealing. [NOTE] ==== -When `io_context` is constructed with `concurrency_hint > 1` and all -three budget fields are at their struct defaults `(2, 16, 4)` — which -is the case for a default-constructed context on a multi-core machine -— the constructor overrides them to `(0, 0, 0)`, disabling the inline -fast path. Multi-thread workloads benefit from cross-thread +The struct defaults are `(2, 16, 4)`, which is what a +default-constructed context on a multi-core machine carries. +Constructing an `io_context` with `concurrency_hint > 1` while all three +budget fields still hold those defaults overrides them to `(0, 0, 0)`. +That disables the inline fast path. Multi-thread workloads benefit from +cross-thread work-stealing, which "post-everything" mode enables. Setting any budget field to a non-default value disables the override. ==== -=== Thread Pool Size (`thread_pool_size`) +=== Thread Pool Size (cpp:io_context_options::thread_pool_size[thread_pool_size]) -On POSIX platforms, file I/O (`stream_file`, `random_access_file`) +On POSIX platforms, file I/O (cpp:stream_file[], cpp:random_access_file[]) and DNS resolution use a shared thread pool. * *Concurrent file operations*: increase to match expected - parallelism (e.g. 4 for four concurrent file reads). The whole set - starts at once, on the first file or resolver call, so a larger pool - makes that one call more expensive and no other. -* *No file I/O*: leave at 1; the pool is created with the context, but + parallelism (e.g. 4 for four concurrent file reads). The whole set starts at once on the first file or resolver call. A larger pool + therefore makes that one call more expensive and no other. +* *No file I/O*: leave at 1. The pool is created with the context, but its workers start on the first file or resolver operation, so a pool nothing uses costs nothing. [#single-threaded-mode] -=== Locking Tiers (`locking`) +=== Locking Tiers (cpp:io_context_options::locking[locking]) -The `locking` option selects which internal locks the scheduler and +The cpp:io_context_options::locking[locking] option selects which internal locks the scheduler and reactor elide, trading thread-safety guarantees for reduced synchronization overhead. It governs the thread-safety contract, independently of the `concurrency_hint` (see @@ -181,13 +205,14 @@ Violating them is undefined behavior. * **Posting work from another thread** is undefined behavior. * **Signal sets** should not be shared across contexts. * **Delay and timeout cancellation via `stop_token`** from another thread - is not permitted: the cancel path posts into the scheduler, whose locking - these tiers disable. Cross-thread cancellation requires the `safe` tier. + is not permitted. The cancel path posts into the scheduler, and these + tiers permit no cross-thread posting. Cross-thread cancellation requires + the `safe` tier. The `unsafe` tier additionally makes: * **DNS resolution** return `operation_not_supported`. -* **POSIX file I/O** (`stream_file`, `random_access_file`) return +* **POSIX file I/O** (cpp:stream_file[], cpp:random_access_file[]) return `operation_not_supported` on `open()`. These two services stay available under `unsafe_io` because they rely @@ -204,7 +229,7 @@ from the `concurrency_hint`. ==== Concurrency hint and locking tier The `concurrency_hint` is a performance tuning value indicating how many -threads are expected to call `run()`; it tunes the reactor inline-completion -budget defaults and, on IOCP, the completion-port concurrency. A lockless +threads are expected to call `run()`. It tunes the reactor inline-completion +budget defaults and, on IOCP, the completion-port concurrency. A lockless tier is single-threaded, so its effective hint for that tuning is `1` regardless of the value passed. diff --git a/doc/modules/ROOT/pages/4.guide/4d.sockets.adoc b/doc/modules/ROOT/pages/4.guide/4d.sockets.adoc index 0b4e1c34e..2f7692184 100644 --- a/doc/modules/ROOT/pages/4.guide/4d.sockets.adoc +++ b/doc/modules/ROOT/pages/4.guide/4d.sockets.adoc @@ -8,8 +8,9 @@ // = Sockets +:page-mode: how-to -The `tcp_socket` class provides asynchronous TCP networking. It supports +The cpp:tcp_socket[] class provides asynchronous TCP networking. It supports connecting to servers, reading and writing data, and graceful connection management. @@ -194,7 +195,7 @@ include::example$snippets/4d_sockets.cpp[tag=read_eof,indent=0] EOF is signaled only by `ec == capy::cond::eof`. A successful read with `n == 0 && !ec` is not EOF: it occurs when the buffer has zero length, and -the request simply completes with nothing to do. +the request completes with nothing to do. === Reading Exact Amounts @@ -276,20 +277,9 @@ NOTE: After a move, the destination uses the source's execution context. == The io_stream Interface -`tcp_socket` inherits from `io_stream`, which provides: - -[source,cpp,role=pseudocode] ----- -class io_stream : public io_object -{ -public: - template - auto read_some(MutableBufferSequence const& buffers); - - template - auto write_some(ConstBufferSequence const& buffers); -}; ----- +cpp:tcp_socket[] inherits from cpp:io_stream[], which combines +cpp:io_read_stream[] and cpp:io_write_stream[]. The `read_some` and +`write_some` operations come from those two base classes. This enables polymorphic use: diff --git a/doc/modules/ROOT/pages/4.guide/4e.tcp-acceptor.adoc b/doc/modules/ROOT/pages/4.guide/4e.tcp-acceptor.adoc index 365459803..eef401e10 100644 --- a/doc/modules/ROOT/pages/4.guide/4e.tcp-acceptor.adoc +++ b/doc/modules/ROOT/pages/4.guide/4e.tcp-acceptor.adoc @@ -8,8 +8,9 @@ // = Acceptors +:page-mode: how-to -The `tcp_acceptor` class listens for incoming TCP connections and accepts them +The cpp:tcp_acceptor[] class listens for incoming TCP connections and accepts them into socket objects. It's the foundation for building TCP servers. [NOTE] @@ -72,7 +73,7 @@ listener on an occupied endpoint throws `errc::address_in_use`. === bind() and listen() For explicit error handling, construct the acceptor, then bind and listen as -separate steps. Both return a `std::error_code` and are marked `[[nodiscard]]` +separate steps. Both return a `std::error_code` and are marked `+[[nodiscard]]+` to prevent accidentally ignoring errors: [source,cpp] @@ -136,7 +137,7 @@ include::example$snippets/4e_tcp_acceptor.cpp[tag=accept_returning,indent=0] ---- Prefer the returning overload for the common case: it is simpler and guarantees -the acceptor and socket use the same `io_context`. Use the +the acceptor and socket use the same cpp:io_context[]. Use the `accept(tcp_socket&)` form when you pre-allocate or recycle sockets and want to manage their lifetime yourself. @@ -180,8 +181,8 @@ All outstanding `accept()` operations complete with an error matching === Stop Token Cancellation -Accept operations support `std::stop_token` through the affine awaitable -protocol: +Accept operations support `std::stop_token` through the +xref:4.guide/4b.concurrent-programming.adoc[affine awaitable protocol]: [source,cpp] ---- @@ -279,7 +280,7 @@ provides: * Multi-port support * Automatic coroutine lifecycle -The `tcp_acceptor` class is the lower-level primitive that `tcp_server` builds +The cpp:tcp_acceptor[] class is the lower-level primitive that cpp:tcp_server[] builds upon. == Next Steps diff --git a/doc/modules/ROOT/pages/4.guide/4f.endpoints.adoc b/doc/modules/ROOT/pages/4.guide/4f.endpoints.adoc index 44eedb33d..a89525e81 100644 --- a/doc/modules/ROOT/pages/4.guide/4f.endpoints.adoc +++ b/doc/modules/ROOT/pages/4.guide/4f.endpoints.adoc @@ -8,8 +8,9 @@ // = Endpoints +:page-mode: how-to -The `endpoint` class represents a network endpoint: an IP address (IPv4 or +The cpp:endpoint[] class represents a network endpoint: an IP address (IPv4 or IPv6) combined with a port number. Endpoints are used for connecting sockets and binding acceptors. @@ -138,7 +139,7 @@ include::example$snippets/4f_endpoints.cpp[tag=make_addresses,indent=0] ---- You can also create an endpoint from a full `address:port` string -using `make_endpoint()`, or the `endpoint` constructor that accepts a +using `make_endpoint()`, or the cpp:endpoint[] constructor that accepts a `std::string_view` and throws on failure: [source,cpp] @@ -197,8 +198,11 @@ The endpoint stores: * Port number (16-bit, host byte order) * Address type flag (is_v4) +[NOTE] +==== Both address types are stored to avoid needing a union or variant. The `is_v4` flag indicates which address is active. +==== == Thread Safety diff --git a/doc/modules/ROOT/pages/4.guide/4g.composed-operations.adoc b/doc/modules/ROOT/pages/4.guide/4g.composed-operations.adoc index a7cca443d..e6b0dc900 100644 --- a/doc/modules/ROOT/pages/4.guide/4g.composed-operations.adoc +++ b/doc/modules/ROOT/pages/4.guide/4g.composed-operations.adoc @@ -8,6 +8,7 @@ // = Composed Operations +:page-mode: how-to Corosio provides composed operations that build on the primitive `read_some()` and `write_some()` functions to provide higher-level guarantees. @@ -43,13 +44,6 @@ The `read()` function reads until the buffer is full or an error occurs: include::example$snippets/4g_composed_operations.cpp[tag=read_full,indent=0] ---- -=== Signature - -[source,cpp] ----- -include::example$snippets/4g_composed_operations.cpp[tag=read_signature] ----- - === Behavior 1. Calls `read_some()` repeatedly until all buffers are filled @@ -83,13 +77,6 @@ The `write()` function writes all data or fails: include::example$snippets/4g_composed_operations.cpp[tag=write_full,indent=0] ---- -=== Signature - -[source,cpp] ----- -include::example$snippets/4g_composed_operations.cpp[tag=write_signature] ----- - === Behavior 1. Calls `write_some()` repeatedly until all buffers are written @@ -109,17 +96,10 @@ range, so it can be passed directly to any I/O operation: include::example$snippets/4g_composed_operations.cpp[tag=slice_helper,indent=0] ---- -=== Interface - -[source,cpp] ----- -include::example$snippets/4g_composed_operations.cpp[tag=slice_interface] ----- - The returned `slice_type` models the same buffer-sequence concept as -`seq` (mutable when `seq` is a mutable buffer sequence): slicing a -single buffer yields an adjusted buffer of the same kind, while any -other sequence yields a borrowed view. `offset` is clamped to the total +`seq`, and is mutable when `seq` is a mutable buffer sequence. Slicing a +single buffer yields an adjusted buffer of the same kind. Any other +sequence yields a borrowed view. `offset` is clamped to the total size, and `length` defaults to the end of the sequence. NOTE: Except for the single-buffer case, the result borrows `seq`: it diff --git a/doc/modules/ROOT/pages/4.guide/4h.timers.adoc b/doc/modules/ROOT/pages/4.guide/4h.timers.adoc index 904aadb87..42a52055e 100644 --- a/doc/modules/ROOT/pages/4.guide/4h.timers.adoc +++ b/doc/modules/ROOT/pages/4.guide/4h.timers.adoc @@ -9,15 +9,16 @@ // = Delays and Timeouts +:page-mode: how-to Corosio schedules delays and deadlines through two free functions, `delay()` and `timeout()`. Neither one names an I/O object: there is no timer to construct, store as a member, move around, or close. Each call returns an awaitable that arms whatever clock resource it -needs for the duration of a single `co_await`, then releases it. A -loop that waits repeatedly, like a heartbeat or a retry backoff, just -calls `delay()` again on the next iteration instead of resetting a -long-lived object's expiry. +needs for the duration of a single `co_await`, then releases it. A loop +that waits repeatedly, like a heartbeat or a retry backoff, just calls +`delay()` again on the next iteration. There is no long-lived object +whose expiry must be reset. [NOTE] ==== @@ -58,9 +59,9 @@ A time point already in the past also completes synchronously. == Delaying on a Different Clock `delay(time_point)` also accepts a time point on clocks other than -`steady_clock` -- for example `std::chrono::system_clock`, when a -deadline is naturally expressed as wall-clock time rather than a -monotonic duration: +`steady_clock`. One example is `std::chrono::system_clock`, for a +deadline naturally expressed as wall-clock time rather than a monotonic +duration: [source,cpp] ---- @@ -68,8 +69,8 @@ include::example$snippets/4h_timers.cpp[tag=delay_wallclock,indent=0] ---- Internally this performs one or more bounded `steady_clock` waits, -re-reading the clock between them, so an adjustment to the clock is -observed at the next re-check rather than only at the original +re-reading the clock between them. An adjustment to the clock is +therefore observed at the next re-check rather than only at the original deadline. Pass a custom traits type as `delay(time_point)` to bound how quickly such an adjustment is observed: @@ -108,9 +109,9 @@ include::example$snippets/4h_timers.cpp[tag=timeout_read,indent=0] If the inner operation wins, its `io_result` is returned unchanged, error or success, payload and all. If the deadline wins, the inner -operation is cancelled and `timeout()` produces its own result: `ec` -is `capy::cond::timeout` and any payload (such as a byte count) is -default-initialized, not whatever the cancelled operation happened +operation is cancelled and `timeout()` produces its own result. `ec` is +`capy::cond::timeout`, and any payload such as a byte count is +default-initialized. It is not whatever the cancelled operation happened to leave behind. Because the deadline-win path default-initializes that payload, the inner operation's `io_result` payload type must be default constructible. @@ -126,8 +127,8 @@ include::example$snippets/4h_timers.cpp[tag=timeout_deadline,indent=0] A `timeout()` call sits inside whatever stop token its own coroutine is awaited under. If that parent token is stopped, the race ends the -same way an unguarded operation would: the inner awaitable is -cancelled and `timeout()` reports `capy::cond::canceled`, not +same way an unguarded operation would. The inner awaitable is cancelled, +and `timeout()` reports `capy::cond::canceled`, not `capy::cond::timeout`. The two conditions are never ambiguous: [cols="1,2"] @@ -149,10 +150,10 @@ include::example$snippets/4h_timers.cpp[tag=timeout_vs_cancel,indent=0] == Requires an io_context Both `delay()` and `timeout()` need a clock service to arm, which -they obtain from the awaiting coroutine's executor. That executor -must belong to an `io_context`; awaiting either one from any other -kind of execution context terminates the program, since silently -skipping the requested delay would be worse than a hard failure. +they obtain from the awaiting coroutine's executor. That executor must +belong to an cpp:io_context[]. Awaiting either one from any other kind +of execution context terminates the program: silently skipping the +requested delay would be worse than a hard failure. == Composing with Other Operations diff --git a/doc/modules/ROOT/pages/4.guide/4i.signals.adoc b/doc/modules/ROOT/pages/4.guide/4i.signals.adoc index 160bd37a6..245322cff 100644 --- a/doc/modules/ROOT/pages/4.guide/4i.signals.adoc +++ b/doc/modules/ROOT/pages/4.guide/4i.signals.adoc @@ -8,8 +8,9 @@ // = Signal Handling +:page-mode: how-to -The `signal_set` class provides asynchronous signal handling. It allows +The cpp:signal_set[] class provides asynchronous signal handling. It allows coroutines to wait for operating system signals like SIGINT (Ctrl+C) or SIGTERM. @@ -89,7 +90,7 @@ Add a signal to the set: include::example$snippets/4i_signals.cpp[tag=add_signal,indent=0] ---- -Adding a signal that's already in the set has no effect. +Adding a signal that's already in the set with the same flags has no effect. ==== Signal Flags (POSIX) @@ -128,13 +129,13 @@ Available flags: | Accept existing flags if signal is already registered |=== -NOTE: On Windows, only `none` and `dont_care` flags are supported. On some POSIX -systems, `no_child_wait` may not be available. Using unsupported flags returns -`operation_not_supported`. +NOTE: On Windows, only `none` and `dont_care` flags are supported. On POSIX +systems without `SA_NOCLDWAIT`, `no_child_wait` is unavailable. Using +unsupported flags returns `operation_not_supported`. ==== Flag Compatibility -When multiple `signal_set` objects register for the same signal, they must use +When multiple cpp:signal_set[] objects register for the same signal, they must use compatible flags: [source,cpp] @@ -203,7 +204,7 @@ include::example$snippets/4i_signals.cpp[tag=stop_token,indent=0] A stop request cancels only the wait that is pending when it arrives. Unlike `cancel()`, whose cancellation is latched and consumed by the next `wait()`, -a stop request that arrives between waits is discarded: the following +a stop request that arrives between waits is discarded. The following `wait()` starts uncancelled. NOTE: On Windows the cancelled wait resumes inline, on the thread that @@ -262,15 +263,15 @@ NOTE: After a move, the destination uses the source's execution context. |=== | Operation | Thread Safety -| Distinct signal_sets +| Distinct cpp:signal_set[] objects | Safe from different threads -| Same signal_set +| The same cpp:signal_set[] | NOT safe for concurrent operations |=== Don't call `wait()`, `add()`, `remove()`, `clear()`, or `cancel()` -concurrently on the same signal_set. +concurrently on the same cpp:signal_set[]. == Example: Server with Graceful Shutdown @@ -303,7 +304,7 @@ to an internal self-pipe (a `write()`, saving and restoring `errno`). It never locks a mutex, allocates memory, or dispatches handlers. The event loop drains the pipe and completes waiting `wait()` operations in normal context. As a result it is safe for a signal to arrive at any time, including while another -thread is modifying a `signal_set`. +thread is modifying a cpp:signal_set[]. == Next Steps diff --git a/doc/modules/ROOT/pages/4.guide/4j.resolver.adoc b/doc/modules/ROOT/pages/4.guide/4j.resolver.adoc index deb6e76df..4d6182f24 100644 --- a/doc/modules/ROOT/pages/4.guide/4j.resolver.adoc +++ b/doc/modules/ROOT/pages/4.guide/4j.resolver.adoc @@ -8,8 +8,9 @@ // = Name Resolution +:page-mode: how-to -The `resolver` class performs asynchronous DNS lookups, converting hostnames +The cpp:resolver[] class performs asynchronous DNS lookups, converting hostnames to IP addresses. It wraps the system's `getaddrinfo()` function with an asynchronous interface. @@ -59,7 +60,7 @@ The service can be: === Host Only When the port travels separately — as it does in most configuration — -resolve just the host and get `ip_address` values back, with no +resolve just the host. You get `ip_address` values back, with no fabricated service and no port-0 endpoints: [source,cpp] @@ -96,7 +97,7 @@ include::example$snippets/4j_resolver.cpp[tag=with_flags,indent=0] | Service is a port number string, don't look up service name | `address_configured` -| Only return IPv4 if the system has IPv4 configured, same for IPv6 +| Only return IPv4 if the system has a non-loopback IPv4 address configured, same for IPv6 | `v4_mapped` | Intended to return IPv4-mapped IPv6 addresses when no IPv6 addresses are @@ -239,9 +240,10 @@ include::example$snippets/4j_resolver.cpp[tag=http_get] == Platform Notes -The resolver uses the system's `getaddrinfo()` function. On most platforms, -this is a blocking call executed on a thread pool to avoid blocking the -I/O context. +The resolver uses the system's `getaddrinfo()` function. On POSIX, this is a +blocking call executed on a thread pool, so it does not block the I/O context. +On Windows, resolution uses the native asynchronous `GetAddrInfoExW` API +instead. [IMPORTANT] ==== diff --git a/doc/modules/ROOT/pages/4.guide/4k.tcp-server.adoc b/doc/modules/ROOT/pages/4.guide/4k.tcp-server.adoc index a5145dea1..a3871025b 100644 --- a/doc/modules/ROOT/pages/4.guide/4k.tcp-server.adoc +++ b/doc/modules/ROOT/pages/4.guide/4k.tcp-server.adoc @@ -8,8 +8,9 @@ // = TCP Server +:page-mode: how-to -The `tcp_server` class provides a framework for building TCP servers with +The cpp:tcp_server[] class provides a framework for building TCP servers with connection pooling. It manages acceptors, worker pools, and connection lifecycle automatically. @@ -24,7 +25,7 @@ include::example$snippets/4k_tcp_server.cpp[tag=assume] == Overview -`tcp_server` is a base class designed for inheritance. You derive from it, +cpp:tcp_server[] is a base class designed for inheritance. You derive from it, define your worker type, and implement the connection handling logic. The framework handles: @@ -45,17 +46,16 @@ a socket and any state needed for a session. === worker_base -The `worker_base` class is the foundation. It declares a constructor and a -virtual destructor, holds private intrusive-list bookkeeping used by the -server's idle and active pools, and exposes two pure virtuals you must -override: +The cpp:tcp_server::worker_base[worker_base] class is the foundation. It declares a constructor and a +virtual destructor, and holds private intrusive-list bookkeeping used by the server's idle and active +pools. It exposes two pure virtuals you must override: [source,cpp] ---- include::example$snippets/4k_tcp_server.cpp[tag=worker_base] ---- -Your worker inherits from `worker_base`, owns its socket, and implements the +Your worker inherits from cpp:tcp_server::worker_base[worker_base], owns its socket, and implements the required methods: [source,cpp] @@ -66,7 +66,7 @@ include::example$snippets/4k_tcp_server.cpp[tag=my_worker] === Providing Workers The worker pool is installed with `set_workers()`. It accepts any forward -range of pointer-like objects convertible to `worker_base*`, such as a +range of pointer-like objects convertible to a pointer to cpp:tcp_server::worker_base[], such as a `std::vector>`. The server takes ownership of the range and reuses each worker across connections: @@ -82,13 +82,13 @@ A small helper that builds the range keeps construction tidy: include::example$snippets/4k_tcp_server.cpp[tag=make_workers] ---- -Because the range holds `unique_ptr`, workers are stored +Because the range holds a `unique_ptr` to cpp:tcp_server::worker_base[], workers are stored polymorphically, allowing different worker types in the same pool if needed. == The Launcher -When a connection is accepted, `tcp_server` calls your worker's `run()` -method with a `launcher` object. The launcher manages the coroutine lifecycle: +When a connection is accepted, cpp:tcp_server[] calls your worker's `run()` +method with a cpp:tcp_server::launcher[launcher] object. The launcher manages the coroutine lifecycle: [source,cpp] ---- @@ -146,7 +146,7 @@ Begin accepting connections: include::example$snippets/4k_tcp_server.cpp[tag=start,indent=0] ---- -Workers must have been provided via `set_workers()` before calling `start()`, +Workers must be provided via `set_workers()` before calling `start()`, and at least one endpoint must be bound. After `start()`, the server: @@ -190,7 +190,7 @@ include::example$snippets/4k_tcp_server.cpp[tag=worker_reuse,indent=0] === Multiple Ports -`tcp_server` can listen on multiple ports simultaneously. All ports share +cpp:tcp_server[] can listen on multiple ports simultaneously. All ports share the same worker pool: [source,cpp] @@ -209,8 +209,8 @@ connection limits at a higher layer. == Thread Safety -The `tcp_server` class is not thread-safe. All operations on the server -must occur from coroutines running on its `io_context`. Workers may not be +The cpp:tcp_server[] class is not thread-safe. All operations on the server +must occur from coroutines running on its cpp:io_context[]. Workers may not be accessed concurrently. For multi-threaded operation, create one server per thread, or use external diff --git a/doc/modules/ROOT/pages/4.guide/4l.tls.adoc b/doc/modules/ROOT/pages/4.guide/4l.tls.adoc index 23eee14eb..7a86cd6ec 100644 --- a/doc/modules/ROOT/pages/4.guide/4l.tls.adoc +++ b/doc/modules/ROOT/pages/4.guide/4l.tls.adoc @@ -10,8 +10,9 @@ // = TLS Encryption +:page-mode: how-to -Corosio provides TLS encryption through the `tls_context` configuration class +Corosio provides TLS encryption through the cpp:tls_context[] configuration class and stream wrappers that add encryption to existing connections. This chapter covers context configuration, stream usage, and common TLS patterns. @@ -29,10 +30,10 @@ include::example$snippets/4l_tls.cpp[tag=assume] TLS (Transport Layer Security) encrypts data on TCP connections, providing confidentiality, integrity, and authentication. Corosio supports TLS through: -* **tls_context** — Portable configuration for certificates, keys, and options -* **tls_stream** — Abstract base class adding handshake and shutdown -* **wolfssl_stream** — TLS implementation using WolfSSL -* **openssl_stream** — TLS implementation using OpenSSL +* cpp:tls_context[] — Portable configuration for certificates, keys, and options +* cpp:tls_stream[] — Abstract base class adding handshake and shutdown +* cpp:wolfssl_stream[] — TLS implementation using WolfSSL +* cpp:openssl_stream[] — TLS implementation using OpenSSL A verified-safe client trusts the system CAs, requires a peer certificate, and checks the hostname: @@ -46,12 +47,13 @@ Trust anchors can also be supplied explicitly with `add_certificate_authority()`, `load_verify_file()`, or `add_verify_path()`, and `set_verify_callback()` installs a custom verification hook. -Protocol version bounds (`set_min_protocol_version()` / -`set_max_protocol_version()`), cipher suites (`set_ciphersuites()` and -`set_ciphersuites_tls13()`), ALPN (`set_alpn()`, read back with -`alpn_protocol()`), PKCS#12 credentials (`use_pkcs12()` / -`use_pkcs12_file()`), and CRL-based revocation (`add_crl()` / -`add_crl_file()` with `set_revocation_policy()`) are all supported. +The following are all supported. Protocol version bounds +(`set_min_protocol_version()` / `set_max_protocol_version()`), cipher +suites (`set_ciphersuites()` and `set_ciphersuites_tls13()`), and ALPN +(`set_alpn()`, read back with `alpn_protocol()`). Also PKCS#12 +credentials (`use_pkcs12()` / `use_pkcs12_file()`) and CRL-based +revocation (`add_crl()` / `add_crl_file()` with +`set_revocation_policy()`). [NOTE] ==== @@ -73,7 +75,7 @@ include::example$snippets/4l_tls.cpp[tag=typical_flow,indent=0] == tls_context -The `tls_context` class stores TLS configuration: certificates, keys, trust +The cpp:tls_context[] class stores TLS configuration: certificates, keys, trust anchors, protocol settings, and verification options. Contexts are shared handles—copies share the same underlying state. @@ -144,8 +146,8 @@ include::example$snippets/4l_tls.cpp[tag=system_trust,indent=0] ---- On OpenSSL the `SSL_CERT_FILE` and `SSL_CERT_DIR` environment variables are -honored; the WolfSSL backend requires a build with `WOLFSSL_SYS_CA_CERTS`, -without which this call has no effect and a CA bundle must be supplied +honored. The WolfSSL backend requires a build with `WOLFSSL_SYS_CA_CERTS`. +Without it this call has no effect, and a CA bundle must be supplied explicitly via `load_verify_file()` or `add_certificate_authority()`. ==== Custom CA Certificates @@ -171,8 +173,8 @@ include::example$snippets/4l_tls.cpp[tag=protocol_versions,indent=0] ---- NOTE: On WolfSSL the ceiling is applied by selecting a version-specific -method (there is no native set-max call); a window whose minimum exceeds -its maximum yields a context that fails the handshake. +method, because there is no native set-max call. A window whose minimum +exceeds its maximum yields a context that fails the handshake. Available versions: @@ -244,8 +246,8 @@ This does two things: 2. Verifies the server certificate matches this hostname An IP literal (IPv4 or IPv6) is verified against the certificate's -iPAddress entries instead of its DNS names, and no SNI is sent -(RFC 6066 excludes literals). +iPAddress entries instead of its DNS names. No SNI is sent, because RFC +6066 excludes literals. ==== Verification Depth @@ -274,8 +276,9 @@ include::example$snippets/4l_tls.cpp[tag=verify_callback,indent=0] Which certificates the callback sees depends on the backend: * OpenSSL — invoked for every certificate in the chain, including ones - that passed, so it can both *relax* (accept a rejected cert) and - *tighten* (reject an otherwise-valid cert, e.g. pinning) verification. + that passed. It can therefore both *relax* verification (accept a + rejected cert) and *tighten* it (reject an otherwise-valid cert, as + pinning does). * WolfSSL built with `WOLFSSL_ALWAYS_VERIFY_CB` (via `--enable-opensslextra`) — same as OpenSSL. * WolfSSL without that option — the library invokes the callback only on @@ -301,35 +304,34 @@ Under `soft_fail`, a certificate whose status cannot be determined revoked is rejected. Under `hard_fail`, an undeterminable status is also rejected. `disabled` (the default) skips revocation entirely. -NOTE: On WolfSSL, CRL checking requires a `HAVE_CRL` build; without it, +NOTE: On WolfSSL, CRL checking requires a `HAVE_CRL` build. Without it, supplying a CRL or a non-disabled policy fails the handshake with `std::errc::function_not_supported` rather than skip the check silently. == TLS Streams -TLS streams wrap an underlying stream (like a connected `tcp_socket`) to +TLS streams wrap an underlying stream (like a connected cpp:tcp_socket[]) to provide encrypted I/O. === tls_stream Base Class -`tls_stream` is a standalone, coroutine-based abstract base class. It does -*not* derive from `io_stream`: unlike OS-level I/O completed by the kernel, -its operations are coroutines that orchestrate reads and writes on the -underlying stream. Its `read_some`/`write_some` template wrappers satisfy +cpp:tls_stream[] is a standalone, coroutine-based abstract base class. It does +*not* derive from cpp:io_stream[]. An cpp:io_stream[] is OS-level I/O completed +by the kernel; these operations are coroutines that orchestrate reads and +writes on the underlying stream. Its `read_some`/`write_some` template wrappers +satisfy the `capy::Stream` concept, so composed operations like `capy::read` and -`capy::write` work with it. - -[source,cpp] ----- -include::example$snippets/4l_tls.cpp[tag=tls_stream_interface,indent=0] ----- +`capy::write` work with it. It also provides `handshake()` to negotiate +the session, `shutdown()` to close it gracefully, and `next_layer()` to +reach the underlying stream. See cpp:tls_stream[] for the complete +interface. === wolfssl_stream -The WolfSSL-based implementation. Two construction modes are available: -the *reference* form takes a pointer and does not own the stream (the caller -keeps it alive), while the *owning* form takes the stream by value and moves -it. To wrap an already-connected socket, use the pointer form: +The WolfSSL-based implementation. Two construction modes are available. The +*reference* form takes a pointer and does not own the stream, so the caller +keeps it alive. The *owning* form takes the stream by value and moves it. To +wrap an already-connected socket, use the pointer form: [source,cpp] ---- @@ -349,7 +351,7 @@ The OpenSSL-based implementation, with the same construction modes: corosio::openssl_stream secure(&sock, ctx); ---- -Both implementations provide the same interface through `tls_stream`. +Both implementations provide the same interface through cpp:tls_stream[]. == Handshake @@ -426,8 +428,8 @@ before the next `handshake()`; `handshake()` also performs this implicitly. == Plain and Encrypted Connections -A `tls_stream` is *not* an `io_stream`, so a function taking `io_stream&` -will not accept a TLS stream. Provide a separate overload taking +A cpp:tls_stream[] is *not* an cpp:io_stream[], so a function taking `io_stream&` +does not accept a TLS stream. Provide a separate overload taking `tls_stream&` for the encrypted case. The bodies are identical because `capy::read` and `capy::write` accept either stream: @@ -512,17 +514,17 @@ A TLS stream allows one read operation and one write operation to be in flight at the same time. `shutdown()` may overlap a pending read; that read completes with an end-of-file error once the peer answers the close_notify. Two operations in the same direction, or a handshake -running alongside any other operation, must not overlap. When the -`io_context` runs on multiple threads, perform every operation on a -given stream from within the same `capy::strand` (or otherwise ensure -they never run concurrently); a single-threaded `io_context` needs no +running alongside any other operation, must not overlap. On a +multi-threaded cpp:io_context[], perform every operation on a given +stream from within the same `capy::strand`. Otherwise ensure they never +run concurrently. A single-threaded cpp:io_context[] needs no strand. == Building with TLS Libraries === WolfSSL -[source,cmake] +[source,cmake,role=external] ---- find_package(WolfSSL REQUIRED) target_link_libraries(my_target PRIVATE WolfSSL::WolfSSL) @@ -530,7 +532,7 @@ target_link_libraries(my_target PRIVATE WolfSSL::WolfSSL) === OpenSSL -[source,cmake] +[source,cmake,role=external] ---- find_package(OpenSSL REQUIRED) target_link_libraries(my_target PRIVATE OpenSSL::SSL OpenSSL::Crypto) diff --git a/doc/modules/ROOT/pages/4.guide/4m.error-handling.adoc b/doc/modules/ROOT/pages/4.guide/4m.error-handling.adoc index fa5e2af86..f13b0fcb7 100644 --- a/doc/modules/ROOT/pages/4.guide/4m.error-handling.adoc +++ b/doc/modules/ROOT/pages/4.guide/4m.error-handling.adoc @@ -8,6 +8,7 @@ // = Error Handling +:page-mode: how-to Corosio reports I/O errors through the `io_result` type, which carries an error code alongside any values produced by the operation. @@ -20,7 +21,7 @@ Every fallible operation reports through exactly one channel: operations return `std::error_code` (or `io_result` when a payload rides along — `seek()`, the `make_*` factories), and asynchronous operations complete with `io_result<...>`. Every - error-returning function is `[[nodiscard]]`. + error-returning function is `+[[nodiscard]]+`. * *Misuse of a documented precondition throws* `std::system_error` — for example `release()`, `set_option()`, or `get_option()` on a closed object. @@ -35,21 +36,20 @@ channel: the operation returns, throws, or completes with == Avoiding Exceptions -Every throwing convenience is sugar over an exception-free spelling, -or is guarded by a public pre-check — code that performs the check or -uses the piecewise path never sees the throw: +Every throwing convenience is sugar over an exception-free spelling, or +is guarded by a public pre-check. Code that performs the check, or uses +the piecewise path, never sees the throw: [cols="1,1"] |=== | Throwing convenience | Exception-free spelling -| `tcp_acceptor(ctx, ep, backlog)` + +| cpp:tcp_acceptor[] constructed with a context, endpoint, and backlog + `local_stream_acceptor(ctx, ep, backlog)` -| default-construct, then `open()` + `bind()` + `listen()` - ( `tcp_acceptor` also configures address reuse via `set_option()` - between open and bind: `SO_REUSEADDR` on POSIX, - `SO_EXCLUSIVEADDRUSE` on Windows ) — the constructor throws exactly - the codes this path reports +| default-construct, then `open()` + `bind()` + `listen()`. +cpp:tcp_acceptor[] also configures address reuse via `set_option()` +between open and bind: `SO_REUSEADDR` on POSIX, `SO_EXCLUSIVEADDRUSE` on +Windows. The constructor throws exactly the codes this path reports | `endpoint("host:port")` | `make_endpoint(s)` @@ -57,14 +57,15 @@ uses the piecewise path never sees the throw: | `ipv4_address(s)` / `ipv6_address(s)` | `make_ipv4_address(s)` / `make_ipv6_address(s)` -| `signal_set(ctx, sig, sigs...)` +| cpp:signal_set[] constructed with a context and one or more signals | `signal_set(ctx)`, then `add()` per signal | `local_endpoint(path)` -| check `path.size() <= local_endpoint::max_path_length` first — the - public constant is the entire precondition +| check `path.size() <=` cpp:local_endpoint[]`::max_path_length` first — + the public constant is the entire precondition -| `io_context(opts, ...)` throwing `std::invalid_argument` +| cpp:io_context[]'s options constructor throwing `std::invalid_argument` + (POSIX only) | ensure `opts.thread_pool_size >= 1` | `release()`, `size()`, `available()`, `set_option()`, @@ -73,16 +74,16 @@ uses the piecewise path never sees the throw: |=== What cannot be spelled exception-free: root construction and the run -loop. `io_context` itself throws if backend setup fails (there is no -code-returning way to construct it), any constructor can throw -`std::bad_alloc`, and `run()`/`stop()` throw `std::system_error` if +loop. cpp:io_context[] itself throws if backend setup fails, because +there is no code-returning way to construct it. Any constructor can +throw `std::bad_alloc`. `run()`/`stop()` throw `std::system_error` if the OS demultiplexer itself fails — a process-fatal condition with no -per-operation channel to carry it. One environmental caveat on the -last table row: `set_option()`/`get_option()` also throw when the -platform rejects the option itself (an unsupported option on that -protocol or OS), so an open check removes the closed-object throw but -not that environmental arm — probe an option once at startup if it -must not throw later. +per-operation channel to carry it. One environmental caveat applies to +the last table row. `set_option()`/`get_option()` also throw when the +platform rejects the option itself, such as an unsupported option on +that protocol or OS. An open check therefore removes the closed-object +throw but not that environmental arm. Probe an option once at startup if +it must not throw later. Startup construction failing by exception is the intended shape. [NOTE] @@ -228,7 +229,7 @@ platform and backend: truncated hostname from `host_name()` | `filename_too_long` -| A `local_endpoint` path over `max_path_length` +| A cpp:local_endpoint[] path over cpp:local_endpoint::max_path_length[max_path_length] |=== === Cancellation @@ -238,9 +239,10 @@ value per trigger. Depending on the path, a cancelled operation may surface as `capy::error::canceled` (capy's category) or as `std::errc::operation_canceled` (the generic category). For example, a stop token that is already requested when the operation is awaited tends -to produce `std::errc::operation_canceled`, while `cancel()`, an -in-flight stop-token cancel, and a syscall reporting `ECANCELED` tend to -produce `capy::error::canceled`. Do not rely on the specific category or +to produce `std::errc::operation_canceled`. Three other sources tend to +produce `capy::error::canceled`: `cancel()`, an in-flight stop-token +cancel, and a syscall reporting `ECANCELED`. Do not rely on the specific +category or value. Always test cancellation portably with the `capy::cond::canceled` @@ -255,8 +257,8 @@ include::example$snippets/4m_error_handling.cpp[tag=canceled_condition,indent=0] `corosio::timeout()` races an operation against a deadline and keeps these two outcomes distinct. A deadline that elapses first produces -`capy::cond::timeout`; a stop token that fires first (the coroutine's -own cancellation, independent of the deadline) produces +`capy::cond::timeout`. A stop token that fires first — the coroutine's +own cancellation, independent of the deadline — produces `capy::cond::canceled`, exactly as an unguarded operation would: [source,cpp] @@ -265,7 +267,7 @@ include::example$snippets/4m_error_handling.cpp[tag=timeout_vs_cancel,indent=0] ---- Never infer a timeout from `capy::cond::canceled`, and never infer a -cancellation from `capy::cond::timeout`; the two conditions never +cancellation from `capy::cond::timeout`. The two conditions never overlap for a single result. == EOF Handling diff --git a/doc/modules/ROOT/pages/4.guide/4n.buffers.adoc b/doc/modules/ROOT/pages/4.guide/4n.buffers.adoc index 486af5c2d..e4ebbb62a 100644 --- a/doc/modules/ROOT/pages/4.guide/4n.buffers.adoc +++ b/doc/modules/ROOT/pages/4.guide/4n.buffers.adoc @@ -8,6 +8,7 @@ // = Buffer Sequences +:page-mode: how-to Corosio I/O operations work with buffer sequences from Boost.Capy. This page explains how to use buffers effectively. @@ -140,7 +141,7 @@ its `data()`. == buffer_param -The `buffer_param` class type-erases buffer sequences: +The cpp:buffer_param[] class type-erases buffer sequences: [source,cpp] ---- diff --git a/doc/modules/ROOT/pages/4.guide/4o.file-io.adoc b/doc/modules/ROOT/pages/4.guide/4o.file-io.adoc index 42677a3bf..dbbd52107 100644 --- a/doc/modules/ROOT/pages/4.guide/4o.file-io.adoc +++ b/doc/modules/ROOT/pages/4.guide/4o.file-io.adoc @@ -8,9 +8,10 @@ // = File I/O +:page-mode: how-to Corosio provides two classes for asynchronous file operations: -`stream_file` for sequential access and `random_access_file` for +cpp:stream_file[] for sequential access and cpp:random_access_file[] for offset-based access. Both dispatch I/O to a worker thread on POSIX platforms and use native overlapped I/O on Windows. @@ -25,9 +26,9 @@ include::example$snippets/4o_file_io.cpp[tag=assume] == Stream File -`stream_file` reads and writes sequentially, maintaining an internal -position that advances after each operation. It inherits from `io_stream`, -so it works with any algorithm that accepts an `io_stream&`. +cpp:stream_file[] reads and writes sequentially, maintaining an internal +position that advances after each operation. It inherits from cpp:io_stream[], +so it works with any cpp:io_stream[] algorithm. === Reading a File @@ -54,7 +55,7 @@ include::example$snippets/4o_file_io.cpp[tag=seek,indent=0] == Random Access File -`random_access_file` reads and writes at explicit byte offsets +cpp:random_access_file[] reads and writes at explicit byte offsets without maintaining an internal position. This is useful for databases, indices, or any workload that accesses non-sequential regions of a file. @@ -107,12 +108,12 @@ Both file types provide synchronous metadata operations: include::example$snippets/4o_file_io.cpp[tag=metadata,indent=0] ---- -`stream_file` additionally provides `seek()` for repositioning. +cpp:stream_file[] additionally provides `seek()` for repositioning. == Native Handle Access Both file types support releasing and adopting native handles. -`release()` transfers ownership out of the file object; the caller +`release()` transfers ownership out of the file object. The caller becomes responsible for closing the handle: [source,cpp] @@ -132,7 +133,7 @@ include::example$snippets/4o_file_io.cpp[tag=native_adopt,indent=0] ==== On Windows, `assign` requires a handle that has never been associated with an I/O completion port. A handle released from another Corosio -file object is already associated and cannot be re-adopted there; on +file object is already associated and cannot be re-adopted there. On POSIX platforms no such restriction exists. ==== @@ -146,21 +147,21 @@ end-of-file return `capy::cond::eof`: include::example$snippets/4o_file_io.cpp[tag=error_handling,indent=0] ---- -Synchronous operations that can fail in normal use — `open`, -`resize`, `sync_data`, `sync_all`, and `assign` — return a -`std::error_code`; `seek` returns the code together with the new -position. Opening a nonexistent file with `read_only` reports -`no_such_file_or_directory`; use `create` to create files that may +Synchronous operations that can fail in normal use return a +`std::error_code`. Those are `open`, `resize`, `sync_data`, `sync_all`, +and `assign`. `seek` returns the code together with the new position. +Opening a nonexistent file with `read_only` reports +`no_such_file_or_directory`. Use `create` to create files that may not exist. Only misuse, such as calling `size()` or `release()` on a closed file, throws `std::system_error`. == Thread Safety * Distinct objects are safe to use concurrently. -* `random_access_file` supports multiple concurrent reads and writes +* cpp:random_access_file[] supports multiple concurrent reads and writes from coroutines sharing the same file object. Each operation is independently heap-allocated. -* `stream_file` allows at most one asynchronous operation in flight at +* cpp:stream_file[] allows at most one asynchronous operation in flight at a time (same as Asio's stream_file). Sequential access with an implicit position makes concurrent ops semantically undefined. * Non-async operations (open, close, size, resize, etc.) require @@ -168,9 +169,10 @@ a closed file, throws `std::system_error`. == Platform Notes -On Linux, macOS, and BSD, file I/O is dispatched to a shared worker -thread pool using `preadv`/`pwritev`. This is the same pool used by -the resolver. +On Linux, macOS, and BSD, the default epoll/kqueue backend dispatches +file I/O to a shared worker thread pool using `preadv`/`pwritev`. This +is the same pool used by the resolver. The io_uring backend submits +reads and writes directly instead. On Windows, file I/O uses native IOCP overlapped I/O via `ReadFile`/`WriteFile` with `FILE_FLAG_OVERLAPPED`. diff --git a/doc/modules/ROOT/pages/4.guide/4p.unix-sockets.adoc b/doc/modules/ROOT/pages/4.guide/4p.unix-sockets.adoc index 20b6e487a..e5732e43b 100644 --- a/doc/modules/ROOT/pages/4.guide/4p.unix-sockets.adoc +++ b/doc/modules/ROOT/pages/4.guide/4p.unix-sockets.adoc @@ -8,6 +8,7 @@ // = Unix Domain Sockets +:page-mode: how-to Unix domain sockets provide inter-process communication (IPC) on the same machine without going through the TCP/IP network stack. They use filesystem @@ -43,11 +44,11 @@ Corosio provides two Unix socket types, mirroring the TCP/UDP split: |=== | Class | Protocol | Description -| `local_stream_socket` +| cpp:local_stream_socket[] | `SOCK_STREAM` | Reliable, ordered byte stream (like TCP). Supports connect/accept. -| `local_datagram_socket` +| cpp:local_datagram_socket[] | `SOCK_DGRAM` | Message-oriented datagrams (like UDP). Preserves message boundaries. |=== @@ -94,12 +95,12 @@ include::example$snippets/4p_unix_sockets.cpp[tag=stream_pair,indent=0] == Datagram Sockets -Datagram sockets preserve message boundaries. Each `send` delivers exactly -one message that the receiver gets as a complete unit from `recv`. +Datagram sockets preserve message boundaries. Each cpp:local_datagram_socket::send[send] delivers exactly +one message that the receiver gets as a complete unit from cpp:local_datagram_socket::recv[recv]. === Connectionless Mode -Both sides bind to paths, then use `send_to`/`recv_from`: +Both sides bind to paths, then use cpp:local_datagram_socket::send_to[send_to]/cpp:local_datagram_socket::recv_from[recv_from]: [source,cpp] ---- @@ -108,7 +109,7 @@ include::example$snippets/4p_unix_sockets.cpp[tag=datagram_connectionless,indent === Connected Mode -After calling `connect()`, use `send`/`recv` without specifying the peer: +After calling cpp:local_datagram_socket::connect[connect], use cpp:local_datagram_socket::send[send]/cpp:local_datagram_socket::recv[recv] without specifying the peer: [source,cpp] ---- @@ -127,7 +128,7 @@ include::example$snippets/4p_unix_sockets.cpp[tag=endpoints,indent=0] The maximum path length is 103 bytes. This is the portable minimum across platforms: the `sun_path` field in `sockaddr_un` is 108 bytes on Linux but only 104 on macOS and FreeBSD. Corosio uses a 104-byte buffer with a 103-char -limit (one byte reserved for the null terminator) so a `local_endpoint` is +limit (one byte reserved for the null terminator) so a cpp:local_endpoint[] is portable across all three. Paths longer than this throw `std::errc::filename_too_long`. @@ -146,7 +147,7 @@ include::example$snippets/4p_unix_sockets.cpp[tag=abstract,indent=0] [cols="1,1,1"] |=== -| Feature | TCP (`tcp_socket`) | Unix (`local_stream_socket`) +| Feature | TCP (cpp:tcp_socket[]) | Unix (cpp:local_stream_socket[]) | Addressing | IP address + port @@ -169,7 +170,7 @@ include::example$snippets/4p_unix_sockets.cpp[tag=abstract,indent=0] | File permissions | DNS resolution -| Yes (via `resolver`) +| Yes (via cpp:resolver[]) | No (direct paths) | Platform diff --git a/doc/modules/ROOT/pages/4.guide/4q.udp.adoc b/doc/modules/ROOT/pages/4.guide/4q.udp.adoc index 57efd37bc..2897f96b0 100644 --- a/doc/modules/ROOT/pages/4.guide/4q.udp.adoc +++ b/doc/modules/ROOT/pages/4.guide/4q.udp.adoc @@ -8,14 +8,15 @@ // = UDP Sockets +:page-mode: how-to -UDP is a connectionless, message-oriented transport. Each `send` produces -exactly one datagram, and each `recv` consumes exactly one datagram. There +UDP is a connectionless, message-oriented transport. Each cpp:udp_socket::send[send] produces +exactly one datagram, and each cpp:udp_socket::recv[recv] consumes exactly one datagram. There is no handshake, no acknowledgment, and no ordering guarantee. For the protocol-level fundamentals (header layout, fragmentation, when to choose UDP over TCP), see xref:2.networking-tutorial/2g.udp.adoc[UDP: Fast, -Simple, Unreliable]. This page covers the Corosio API: `udp_socket` and the +Simple, Unreliable]. This page covers the Corosio API: cpp:udp_socket[] and the operations it supports. [NOTE] @@ -37,15 +38,14 @@ with — `family::v4` or `family::v6`: include::example$snippets/4q_udp.cpp[tag=protocol,indent=0] ---- -`open()` defaults to `family::v4` when no family is supplied, and -`connect()` picks the family automatically from the endpoint when the +`open()` defaults to `family::v4` when no family is supplied. `connect()` picks the family automatically from the endpoint when the socket is not yet open — see xref:#connected-mode[Connected Mode] below. == Opening and Binding -Receiving datagrams requires an open socket bound to a local endpoint -(`connect()` opens the socket automatically; the other initiators -complete with `errc::bad_file_descriptor` on a closed socket): +Receiving datagrams requires an open socket bound to a local endpoint. +`connect()` opens the socket automatically; the other initiators +complete with `errc::bad_file_descriptor` on a closed socket: [source,cpp] ---- @@ -60,7 +60,7 @@ use `ipv4_address::any()` or `ipv6_address::any()`. To restrict to loopback, use `::loopback()`. Senders that never need to receive replies on a known port may skip `bind()`; -the kernel assigns an ephemeral port on the first `send_to`. +the kernel assigns an ephemeral port on the first cpp:udp_socket::send_to[send_to]. == Connectionless Mode @@ -74,13 +74,13 @@ receive captures the sender's address. include::example$snippets/4q_udp.cpp[tag=send_to,indent=0] ---- -`send_to` either delivers the entire datagram to the network or fails — there +cpp:udp_socket::send_to[send_to] either delivers the entire datagram to the network or fails — there is no partial send for UDP. On success `n` equals the buffer size; on failure `ec` carries the reason. === Receiving -`recv_from` writes the datagram into the buffer and stores the sender's +cpp:udp_socket::recv_from[recv_from] writes the datagram into the buffer and stores the sender's address in the endpoint reference you pass in: [source,cpp] @@ -106,7 +106,7 @@ so there is no acceptor and no peer socket to manage. == Connected Mode `connect()` does not perform a handshake — it sets a default peer in the -kernel. Datagrams from any other source are filtered out, and `send`/`recv` +kernel. Datagrams from any other source are filtered out, and cpp:udp_socket::send[send]/cpp:udp_socket::recv[recv] become available without endpoint arguments: [source,cpp] @@ -124,13 +124,13 @@ Connected mode is useful for two reasons: * **Filtering** — the kernel drops stray datagrams from unrelated senders before your code sees them, which simplifies request/response clients. -* **ICMP error reporting** — when a peer is unreachable, the kernel surfaces - the resulting ICMP error on a subsequent `send` or `recv` instead of - silently discarding it. +* **ICMP error reporting** — when a peer is unreachable, the kernel surfaces the resulting ICMP error rather + than silently discarding it. It appears on a subsequent cpp:udp_socket::send[send] or + cpp:udp_socket::recv[recv]. == Message Flags -`send_to`, `recv_from`, `send`, and `recv` accept an optional +cpp:udp_socket::send_to[send_to], cpp:udp_socket::recv_from[recv_from], cpp:udp_socket::send[send], and cpp:udp_socket::recv[recv] accept an optional `corosio::message_flags`: [cols="1,3"] @@ -138,7 +138,7 @@ Connected mode is useful for two reasons: | Flag | Effect | `peek` -| Return data without removing it from the receive queue. The next `recv` +| Return data without removing it from the receive queue. The next cpp:udp_socket::recv[recv] returns the same datagram. | `out_of_band` @@ -168,20 +168,20 @@ Options commonly relevant to UDP: |=== | Option | When to set -| `reuse_address` +| cpp:socket_option::reuse_address[reuse_address] | Multiple sockets on the same address (e.g., a reload swap, or several receivers in the same multicast group). -| `broadcast` +| cpp:socket_option::broadcast[broadcast] | Required before sending to a broadcast address such as `255.255.255.255`. Off by default. -| `receive_buffer_size` / `send_buffer_size` +| cpp:socket_option::receive_buffer_size[receive_buffer_size] / cpp:socket_option::send_buffer_size[send_buffer_size] | Tune the kernel's per-socket queues. UDP datagrams are dropped when the receive queue is full — bursts of inbound traffic argue for a larger receive buffer. -| `v6_only` +| cpp:socket_option::v6_only[v6_only] | On an IPv6 socket, refuse IPv4-mapped addresses. Off by default on most platforms; enable for IPv6-only services. |=== @@ -205,20 +205,20 @@ Related options: |=== | Option | Purpose -| `join_group` / `leave_group` +| cpp:socket_option::join_group[join_group] / cpp:socket_option::leave_group[leave_group] | Subscribe to or unsubscribe from a multicast group. The group's family selects the wire form, so one pair serves both families. -| `multicast_loop` +| cpp:socket_option::multicast_loop[multicast_loop] | Enable or disable receiving your own outgoing multicast. One option for both families; it renders for the family the socket was opened with. -| `multicast_hops` +| cpp:socket_option::multicast_hops[multicast_hops] | Set the multicast TTL (IPv4) / hop limit (IPv6). Default is `1` — datagrams stay on the local subnet unless you raise it. -| `multicast_interface` +| cpp:socket_option::multicast_interface[multicast_interface] | Choose the outgoing interface — by address for IPv4, by index for IPv6; construct with whichever names your interface. |=== @@ -248,11 +248,11 @@ xref:4.guide/4m.error-handling.adoc[Error Handling]. == Concurrent Operations -A `udp_socket` permits one outstanding send and one outstanding receive at -a time. Two simultaneous `recv_from` calls on the same socket are not +A cpp:udp_socket[] permits one outstanding send and one outstanding receive at +a time. Two simultaneous cpp:udp_socket::recv_from[recv_from] calls on the same socket are not supported. This is enough for the common pattern of a single coroutine that -alternates send and receive, or a pair of coroutines where one drives -outbound traffic and the other drains the receive queue. +alternates send and receive. It also suits a pair of coroutines where one +drives outbound traffic and the other drains the receive queue. For higher concurrency, use multiple sockets — UDP has no per-connection cost in the kernel, so a server can run several receivers in parallel. @@ -261,7 +261,7 @@ cost in the kernel, so a server can run several receivers in parallel. [cols="1,1,1"] |=== -| Aspect | `tcp_socket` | `udp_socket` +| Aspect | cpp:tcp_socket[] | cpp:udp_socket[] | Model | Reliable byte stream @@ -269,7 +269,7 @@ cost in the kernel, so a server can run several receivers in parallel. | Message boundaries | Not preserved -| Preserved (one `recv` = one datagram) +| Preserved (one cpp:udp_socket::recv[recv] = one datagram) | Setup | Three-way handshake before I/O diff --git a/doc/modules/ROOT/pages/4.guide/4r.wait.adoc b/doc/modules/ROOT/pages/4.guide/4r.wait.adoc index 4f21c316f..f1bb0c514 100644 --- a/doc/modules/ROOT/pages/4.guide/4r.wait.adoc +++ b/doc/modules/ROOT/pages/4.guide/4r.wait.adoc @@ -8,12 +8,13 @@ // = Readiness Wait +:page-mode: how-to The `wait()` method on every socket and acceptor suspends until the underlying file descriptor becomes ready in a chosen direction, without transferring any bytes. Use it to integrate with C libraries that own -the I/O on a nonblocking file descriptor and only need notification -that data is available or that the descriptor is writable. +the I/O on a nonblocking file descriptor. Such libraries need only +notification that data is available, or that the descriptor is writable. [NOTE] ==== @@ -26,16 +27,10 @@ include::example$snippets/4r_wait.cpp[tag=assume] == Overview -Three directions are exposed via the `wait_type` enum: - -[source,cpp] ----- -include::example$snippets/4r_wait.cpp[tag=wait_type_enum,indent=0] ----- +Three directions are exposed via the cpp:wait_type[] enum. The awaitable yields an `error_code` with no `bytes_transferred`. On -success the socket is observed to be ready; no data has been consumed -from it. +success the socket is ready; the wait consumes no data from it. [source,cpp] ---- @@ -46,19 +41,19 @@ include::example$snippets/4r_wait.cpp[tag=wait_read,indent=0] The original motivation is libraries such as libssh and libpq that manage their own buffers and do their own I/O on an `O_NONBLOCK` -socket. They need two things from the surrounding event loop: "tell -me when the fd is ready" without stealing bytes from the stream, and -"never touch my descriptor". +socket. They need two things from the surrounding event loop. First, +"tell me when the fd is ready" without stealing bytes from the stream. +Second, "never touch my descriptor". -`wait()` provides the first: it never reads, writes, or consumes the +`wait()` provides the first. It never reads, writes, or consumes the socket's pending error, so the library's next `PQconsumeInput` (or -equivalent) sees everything the kernel has delivered. Adoption -provides the second, with one rule to follow: `assign()` takes -ownership and will close the descriptor, so adopt a `dup()` of the -library's fd rather than the fd itself. Readiness lives on the open -file description, which both descriptors share — the duplicate -reports exactly the library's readiness, and closing it can never -close the library's connection. Neither `assign()` nor `wait()` +equivalent) sees everything the kernel has delivered. Adoption provides +the second, with one rule to follow. `assign()` takes ownership and +closes the descriptor, so adopt a `dup()` of the library's fd rather +than the fd itself. Readiness lives on the open file description, which +both descriptors share. The duplicate therefore reports exactly the +library's readiness, and closing it can never close the library's +connection. Neither `assign()` nor `wait()` alters the descriptor's flags, so the library's non-blocking configuration is untouched. @@ -72,14 +67,14 @@ the library owns the byte stream; corosio supplies readiness only. On Windows, `dup()` does not duplicate a `SOCKET`. Either adopt the library's socket directly and `release()` it before the library needs -exclusive ownership again, or create a true duplicate with +exclusive ownership again. Or create a true duplicate with `WSADuplicateSocketW` and adopt that. == Acceptors -`tcp_acceptor` and `local_stream_acceptor` expose the same `wait()`. +cpp:tcp_acceptor[] and cpp:local_stream_acceptor[] expose the same `wait()`. For `wait_type::read`, completion signals that a connection is pending -on the listen socket. A subsequent `accept()` will succeed without +on the listen socket. A subsequent `accept()` succeeds without blocking: [source,cpp] @@ -93,7 +88,7 @@ signaling) without holding an `accept()` call open. A connection already queued when the wait begins completes it immediately — including on an adopted listener whose backlog predates -the adoption, the socket-activation handoff shape. +the adoption. == Cancellation @@ -119,13 +114,14 @@ include::example$snippets/4r_wait.cpp[tag=wait_timeout,indent=0] `wait(wait_type::write)` completes when the socket can accept a non-blocking write. On a socket that is not backpressured this is -immediate; once the send buffer is full the wait parks until the peer +immediate. Once the send buffer is full, the wait parks until the peer drains enough of it for a write to make progress again. -That is the signal an external flush loop needs: code that owns its -own buffers and retries "when the socket is writable" would busy-spin -if the wait completed unconditionally, precisely when the socket is -congested. Code that hands its buffers to `write_some()` does not need +That is the signal an external flush loop needs. Code that owns its own +buffers and retries "when the socket is writable" would busy-spin if the +wait completed unconditionally. That would happen precisely when the +socket is congested. +Code that hands its buffers to `write_some()` does not need `wait(wait_type::write)` at all — `write_some()` already parks on the same condition. @@ -143,10 +139,10 @@ parked write wait. On Windows (IOCP), stream-socket `wait_read` uses a zero-byte `WSARecv`: the kernel signals completion when data is available -without consuming bytes. All other waits (datagram-read, -acceptor-read, write-wait, error-wait) route through an auxiliary -`WSAPoll`-based reactor that runs on a dedicated thread and bridges -into the IOCP via `PostQueuedCompletionStatus`. The public API is +without consuming bytes. All other waits — datagram-read, acceptor-read, +write-wait, error-wait — route through an auxiliary `WSAPoll`-based +reactor. That reactor runs on a dedicated thread and bridges into the +IOCP via `PostQueuedCompletionStatus`. The public API is uniform across platforms. == See Also diff --git a/doc/modules/ROOT/pages/5.testing/5.intro.adoc b/doc/modules/ROOT/pages/5.testing/5.intro.adoc index cb9b7c32c..64b03567c 100644 --- a/doc/modules/ROOT/pages/5.testing/5.intro.adoc +++ b/doc/modules/ROOT/pages/5.testing/5.intro.adoc @@ -9,6 +9,7 @@ // = Testing +:page-mode: explanation Asynchronous I/O code is notoriously difficult to test. Real network operations introduce latency, non-determinism, and dependencies on diff --git a/doc/modules/ROOT/pages/5.testing/5a.mocket.adoc b/doc/modules/ROOT/pages/5.testing/5a.mocket.adoc index 3c40d601e..bac1c2177 100644 --- a/doc/modules/ROOT/pages/5.testing/5a.mocket.adoc +++ b/doc/modules/ROOT/pages/5.testing/5a.mocket.adoc @@ -9,10 +9,11 @@ // = Mock Sockets +:page-mode: how-to -The `mocket` class is a testable wrapper around a `tcp_socket` that lets -you stage bytes for reads and assert on bytes written, without giving up -real socket semantics when you don't need them. Mockets are the main tool +The cpp:mocket[] class wraps a cpp:tcp_socket[] for testing. It lets you stage +bytes for reads and assert on bytes written, without giving up real socket +semantics where you still want them. Mockets are the main tool for byte-level deterministic tests of corosio I/O code. [NOTE] @@ -34,8 +35,7 @@ include::example$snippets/5a_mocket.cpp[tag=assume] before passing through. Once both buffers are empty, I/O passes through to the underlying socket -unchanged. The default alias is `using mocket = basic_mocket<>;`, which -uses `tcp_socket`. +unchanged. cpp:mocket[] is the default alias, using cpp:tcp_socket[]. == Creating Mockets @@ -46,13 +46,13 @@ Mockets are created paired with a peer socket, connected via loopback: include::example$snippets/5a_mocket.cpp[tag=creating,indent=0] ---- -`make_mocket_pair` returns `std::pair, Socket>`. The -first element is the mocket (test-instrumented). The second is the peer -(a plain `Socket`). Both are open and immediately usable. +cpp:make_mocket_pair[] returns a pair. The first element is the mocket +(test-instrumented). The second is the peer (a plain `Socket`). Both are +open and immediately usable. == Staging Data for Reads -Use `provide()` to stage bytes that the mocket itself will hand back from +Use `provide()` to stage bytes that the mocket itself hands back from `read_some`: [source,cpp] @@ -84,7 +84,7 @@ at `max_write_size`), not the length of the matched prefix. == Chunked I/O `make_mocket_pair` accepts `max_read_size` and `max_write_size` to cap -the bytes a single `read_some` / `write_some` will deliver. This is the +the bytes a single `read_some` / `write_some` delivers. This is the right tool for forcing your code's read/write loops to handle short transfers: @@ -98,10 +98,11 @@ the constructor. The default is `std::size_t(-1)` (unlimited). == Closing and Verification -`verify()` checks that both staging buffers were fully consumed and -returns `error::test_failure` if either holds leftover data; `close()` +`verify()` checks that both staging buffers were fully consumed, and +returns `error::test_failure` if either holds leftover data. `close()` shuts the underlying socket, running the same check through the fuse -on the way out: +(the `capy::test::fuse` fault-injection object passed to +`make_mocket_pair`) on the way out: [source,cpp] ---- @@ -116,8 +117,8 @@ fails the test. == Templated over Socket -`basic_mocket` is a template; the default alias only specializes it for -`tcp_socket`. For backend-specific tests (`native_tcp_socket`, +cpp:basic_mocket[] is a template; the default alias only specializes it for +cpp:tcp_socket[]. For backend-specific tests (`native_tcp_socket`, etc.), name the specialization explicitly: [source,cpp] @@ -138,10 +139,9 @@ include::example$snippets/5a_mocket.cpp[tag=socket_access,indent=0] [IMPORTANT] ==== I/O performed through the returned raw socket *bypasses* the mocket's -`provide()` / `expect()` scripting. Those stages run only inside the -mocket's own `read_some` / `write_some`; a stream built on `socket()` -talks to the underlying socket directly. To script bytes for a -higher-level stream, drive the mocket directly instead. +`provide()` / `expect()` scripting. See +xref:5.testing/5c.patterns.adoc[Testing Patterns] ("Layering Streams on +a Mocket") for the full explanation. ==== See xref:5.testing/5c.patterns.adoc[Testing Patterns] for a TLS-over-mocket diff --git a/doc/modules/ROOT/pages/5.testing/5b.socket-pair.adoc b/doc/modules/ROOT/pages/5.testing/5b.socket-pair.adoc index 6c176a6b2..a3e8400cf 100644 --- a/doc/modules/ROOT/pages/5.testing/5b.socket-pair.adoc +++ b/doc/modules/ROOT/pages/5.testing/5b.socket-pair.adoc @@ -8,8 +8,9 @@ // = Socket Pairs +:page-mode: how-to -`make_socket_pair` creates two `tcp_socket` objects connected via +`make_socket_pair` creates two cpp:tcp_socket[] objects connected via loopback TCP. Use it when a test needs real socket semantics — TLS handshakes, `set_option`, `shutdown` ordering, true EOF — that the byte-level staging in xref:5.testing/5a.mocket.adoc[`mocket`] cannot reproduce. @@ -25,10 +26,10 @@ include::example$snippets/5b_socket_pair.cpp[tag=assume] == Overview -[source,cpp] ----- -include::example$snippets/5b_socket_pair.cpp[tag=signature] ----- +cpp:make_socket_pair[] takes an cpp:io_context[] and returns a +`std::pair` of two connected sockets. Three template parameters +customize it: `Socket` (default cpp:tcp_socket[]), `Acceptor` (default +cpp:tcp_acceptor[]), and `Linger` (default `true`, see below). The function: @@ -40,9 +41,10 @@ The function: Both sockets come back `is_open()` and ready to use. If open, bind, listen, accept, or connect fails, `make_socket_pair` -throws `std::runtime_error` naming the failing step; the underlying +throws `std::runtime_error` naming the failing step. The underlying `error_code::message()` appears in the exception text for open, bind, -and listen failures, and on `stderr` for accept and connect failures. +and listen failures. It appears on `stderr` for accept and connect +failures. == Round Trip @@ -56,8 +58,8 @@ include::example$snippets/5b_socket_pair.cpp[tag=round_trip,indent=0] When `Linger` is `true` (the default), `make_socket_pair` sets `SO_LINGER(true, 0)` on both ends after the connection is established. This makes `close()` send `RST` instead of going through the normal -`FIN`/`ACK` shutdown, so test sockets release immediately and stress -runs don't accumulate `TIME_WAIT` entries. +`FIN`/`ACK` shutdown. Test sockets therefore release immediately, and +stress runs do not accumulate `TIME_WAIT` entries. Set `Linger` to `false` only when a test specifically exercises graceful shutdown: diff --git a/doc/modules/ROOT/pages/5.testing/5c.patterns.adoc b/doc/modules/ROOT/pages/5.testing/5c.patterns.adoc index 2da7d6941..bd88071b9 100644 --- a/doc/modules/ROOT/pages/5.testing/5c.patterns.adoc +++ b/doc/modules/ROOT/pages/5.testing/5c.patterns.adoc @@ -8,10 +8,11 @@ // = Testing Patterns +:page-mode: how-to This page collects recipes that combine xref:5.testing/5a.mocket.adoc[`mocket`] and xref:5.testing/5b.socket-pair.adoc[`make_socket_pair`] in realistic test -scenarios. Each recipe is a small standalone block; copy and adapt. +scenarios. Each recipe is a small standalone block. Copy and adapt it. [NOTE] ==== @@ -56,10 +57,10 @@ The same applies to `max_write_size` for write-loop testing. == Layering Streams on a Mocket -`m.socket()` returns the underlying `tcp_socket`. You can stack any stream -that wraps a TCP socket on top of it, but be aware that doing so *bypasses* -the mocket's `provide()` / `expect()` scripting: those stages run only -inside the mocket's own `read_some` / `write_some`. A stream built on +`m.socket()` returns the underlying cpp:tcp_socket[]. You can stack any stream +that wraps a TCP socket on top of it. Doing so *bypasses* the mocket's +`provide()` / `expect()` scripting, because those stages run only inside the +mocket's own `read_some` / `write_some`. A stream built on `m.socket()` calls the underlying socket's `read_some` / `write_some` directly, so staged bytes do not apply. If you need to script bytes for a higher-level stream, drive the mocket directly instead of layering on @@ -96,9 +97,9 @@ include::example$snippets/5c_patterns.cpp[tag=close_verification,indent=0] This is the line that catches "the test passed because the code under test silently did nothing." Treat it as a test-suite convention. An -unmet expectation also trips the fuse, so a plain `close()` without -the explicit `verify()` still fails the test — the assertion just -makes the failure local and readable. +unmet expectation also trips the fuse, so a plain `close()` without the +explicit `verify()` still fails the test. The assertion just makes the +failure local and readable. == See Also diff --git a/doc/modules/ROOT/pages/benchmark-report.adoc b/doc/modules/ROOT/pages/benchmark-report.adoc index ec0311397..745c1fe60 100644 --- a/doc/modules/ROOT/pages/benchmark-report.adoc +++ b/doc/modules/ROOT/pages/benchmark-report.adoc @@ -1,11 +1,13 @@ = Boost.Corosio Performance Benchmarks +:page-mode: explanation :toc: left :toclevels: 3 :source-highlighter: highlightjs == Executive Summary -This report presents comprehensive performance benchmarks comparing *Boost.Corosio*, *Boost.Asio with coroutines* (`co_spawn`/`use_awaitable`), and *Boost.Asio with callbacks* on Windows using the IOCP (I/O Completion Ports) backend. The benchmarks cover handler dispatch, socket throughput, socket latency, HTTP server workloads, timers, and connection churn. +This report presents comprehensive performance benchmarks on Windows, using the IOCP (I/O Completion Ports) backend. It compares *Boost.Corosio*, *Boost.Asio with coroutines* (`co_spawn`/`use_awaitable`), and *Boost.Asio with callbacks*. The benchmarks cover handler dispatch, socket throughput, socket latency, HTTP server workloads, timers, and connection +churn. === Bottom Line @@ -16,7 +18,7 @@ Corosio *outperforms Asio coroutines* in handler dispatch (9-50% faster) and *sc * *Multi-threaded handler scaling:* Best scaling of all three — maintains 89% throughput at 8 threads vs 58% (Asio coroutines) and 53% (Asio callbacks) * *Concurrent post and run:* 46% faster than Asio coroutines (2.35 Mops/s vs 1.61 Mops/s) * *Interleaved post/run:* 34% faster than Asio coroutines (2.14 Mops/s vs 1.60 Mops/s) -* *HTTP concurrent connections:* 5-7% higher throughput than Asio coroutines +* *HTTP concurrent connections:* 2-7% higher throughput than Asio coroutines === Where Asio Callbacks Leads @@ -278,11 +280,11 @@ Multiple threads running handlers concurrently. ==== Scaling Analysis -[source] +[role=output] ---- Throughput vs Thread Count: -Threads Corosio Asio Coro Asio CB Best Scaling +Threads Corosio Asio Coroutine Asio CB Best Scaling 1 1.72 M 1.78 M 2.82 M — 2 2.10 M 1.40 M 2.33 M Corosio (1.23×) 4 2.02 M 1.25 M 2.10 M Corosio (1.18×) @@ -389,7 +391,7 @@ Single direction transfer with varying buffer sizes. | *2.31 GB/s* |=== -*Observation:* Unidirectional throughput is within 10% across all three implementations. Corosio has a slight edge at the smallest buffer size. All three are bounded by the same kernel socket path. +*Observation:* Unidirectional throughput is within 13% across all three implementations. Corosio has a slight edge at the smallest buffer size. All three are bounded by the same kernel socket path. === Bidirectional Throughput @@ -485,7 +487,7 @@ A single socket pair exchanges messages for 3 seconds. | *927.80 μs* |=== -*Observation:* All three implementations deliver latency within 5% of each other. Asio callbacks has marginally better tail latency. The differences are small enough to be within measurement noise. +*Observation:* All three implementations deliver mean, p50, p90, and p99 latency within 5% of each other. Those differences are small enough to be within measurement noise. The far tail spreads wider: p99.9 varies by 16% and max by more than 2×. Asio callbacks has the lowest p99.9 and max. === Concurrent Socket Pairs @@ -493,7 +495,7 @@ Multiple socket pairs operating concurrently (64-byte messages). [cols="1,1,1,1,1,1,1", options="header"] |=== -| Pairs | Corosio Mean | Asio Coro Mean | Asio CB Mean | Corosio p99 | Asio Coro p99 | Asio CB p99 +| Pairs | Corosio Mean | Asio Coroutine Mean | Asio CB Mean | Corosio p99 | Asio Coroutine p99 | Asio CB p99 | 1 | 10.78 μs @@ -557,7 +559,7 @@ Multiple socket pairs operating concurrently (64-byte messages). [cols="1,1,1,1,1,1,1", options="header"] |=== -| Connections | Corosio Throughput | Asio Coro Throughput | Asio CB Throughput | Corosio Mean | Asio Coro Mean | Asio CB Mean +| Connections | Corosio Throughput | Asio Coroutine Throughput | Asio CB Throughput | Corosio Mean | Asio Coroutine Mean | Asio CB Mean | 1 | *86.79 Kops/s* @@ -592,7 +594,7 @@ Multiple socket pairs operating concurrently (64-byte messages). | *391.54 μs* |=== -*Observation:* Corosio consistently outperforms Asio coroutines by 5-7% in concurrent connection throughput. Corosio and Asio callbacks trade the lead depending on connection count. +*Observation:* Corosio consistently outperforms Asio coroutines by 2-7% in concurrent connection throughput. The gap narrows at higher connection counts. Corosio and Asio callbacks trade the lead depending on connection count. === Multi-Threaded HTTP (32 Connections) @@ -630,7 +632,7 @@ Multiple socket pairs operating concurrently (64-byte messages). [cols="1,1,1,1,1,1,1", options="header"] |=== -| Threads | Corosio Mean | Asio Coro Mean | Asio CB Mean | Corosio p99 | Asio Coro p99 | Asio CB p99 +| Threads | Corosio Mean | Asio Coroutine Mean | Asio CB Mean | Corosio p99 | Asio Coroutine p99 | Asio CB p99 | 1 | 393.50 μs @@ -678,15 +680,15 @@ Multiple socket pairs operating concurrently (64-byte messages). == Timer Benchmarks NOTE: The timer category was removed from the benchmark suite. Corosio's -stateless delay() and timeout() acquire their timer per wait while Asio -reuses a caller-owned timer object, so the two sides no longer measure -comparable work. The figures below are a historical snapshot taken while -both libraries exposed an equivalent timer object API. +stateless delay() and timeout() acquire their timer per wait, while Asio +reuses a caller-owned timer object. The two sides therefore no longer +measure comparable work. The figures below are a historical snapshot +taken while both libraries exposed an equivalent timer object API. -NOTE: The fan-out join primitives are likewise no longer like-for-like -(corosio's `async_waker` latch versus Asio's parked-timer cancel), so -those figures compare each library's idiomatic completion signal rather -than identical mechanisms. +NOTE: The fan-out join primitives are likewise no longer like-for-like: +corosio's `async_waker` latch versus Asio's parked-timer cancel. Those +figures compare each library's idiomatic completion signal rather than +identical mechanisms. === Timer Schedule/Cancel @@ -746,7 +748,7 @@ Multiple timers firing at 15 ms intervals concurrently. [cols="1,1,1,1,1,1,1", options="header"] |=== -| Timers | Corosio Mean | Asio Coro Mean | Asio CB Mean | Corosio p99 | Asio Coro p99 | Asio CB p99 +| Timers | Corosio Mean | Asio Coroutine Mean | Asio CB Mean | Corosio p99 | Asio Coroutine p99 | Asio CB p99 | 10 | 15.39 ms @@ -773,7 +775,7 @@ Multiple timers firing at 15 ms intervals concurrently. | 18.17 ms |=== -*Observation:* Concurrent timer latency is identical across all three implementations. Mean latency stays within 0.06 ms of the 15 ms target regardless of concurrency level. Corosio has the best p99 at 1000 concurrent timers. +*Observation:* Concurrent timer latency is identical across all three implementations. Mean latency stays within 0.06 ms across implementations and concurrency levels. Every mean sits about 0.4 ms above the nominal 15 ms interval. Corosio has the best p99 at 1000 concurrent timers. == Connection Churn Benchmark @@ -815,7 +817,7 @@ The handler dispatch results tell a nuanced story across the three implementatio [cols="1,1,1,1", options="header"] |=== -| Pattern | Corosio vs Asio Coro | Corosio vs Asio CB | Notes +| Pattern | Corosio vs Asio Coroutine | Corosio vs Asio CB | Notes | Single-threaded | +9% @@ -840,7 +842,7 @@ The handler dispatch results tell a nuanced story across the three implementatio The most telling result is multi-threaded scaling. Every implementation loses throughput as threads increase due to coordination overhead, but Corosio degrades the least: -[source] +[role=output] ---- Throughput retained at 8 threads (vs 1 thread): @@ -867,7 +869,7 @@ HTTP server performance is comparable across all three implementations at all co === Timers -Timer schedule/cancel throughput is a notable gap — Asio's timer operations are approximately 10× faster. However, the gap narrows substantially for timer fire rate (8%) and disappears entirely for concurrent timer latency accuracy. Applications that create and cancel timers at very high rates may notice this difference; applications that primarily use timers for timeouts and delays will not. +Timer schedule/cancel throughput is a notable gap — Asio's timer operations are approximately 10× faster. However, the gap narrows substantially for timer fire rate (8%). It disappears entirely for concurrent timer latency accuracy. Applications that create and cancel timers at very high rates may notice this difference; applications that primarily use timers for timeouts and delays do not. === Summary @@ -875,7 +877,7 @@ Timer schedule/cancel throughput is a notable gap — Asio's timer operations ar |=== | Component | Assessment -| *Handler Dispatch (vs Asio Coro)* +| *Handler Dispatch (vs Asio Coroutine)* | Corosio 9-50% faster | *Handler Dispatch (vs Asio CB)* @@ -945,13 +947,14 @@ Asio callbacks achieve the highest raw single-threaded dispatch rate, but this a === Key Takeaway -For coroutine-based async programming on Windows (IOCP), *Corosio provides equivalent or better performance* compared to Asio coroutines in every category except bidirectional socket throughput and timer schedule/cancel (a measurement that predates removal of the standalone timer API). Corosio's superior multi-threaded scaling makes it particularly well-suited for applications that distribute work across threads. Bidirectional throughput and timer operations are identified areas for future optimization. +For coroutine-based async programming on Windows (IOCP), *Corosio provides equivalent or better performance* than Asio coroutines in every category but two. The exceptions are bidirectional socket throughput and timer schedule/cancel, a measurement that predates removal of the standalone timer API. Corosio's superior multi-threaded scaling makes it particularly well-suited for applications that distribute work across threads. Bidirectional throughput and timer operations are identified areas for future +optimization. == Appendix: Raw Data === Corosio Results -[source] +[role=output] ---- Backend: iocp Duration: 3 s per benchmark @@ -1036,7 +1039,7 @@ Duration: 3 s per benchmark === Asio Coroutines Results -[source] +[role=output] ---- === Single-threaded Handler Post (Asio Coroutines) === Handlers: 4712000 @@ -1114,7 +1117,7 @@ Duration: 3 s per benchmark === Asio Callbacks Results -[source] +[role=output] ---- === Single-threaded Handler Post (Asio Callbacks) === Handlers: 7764000 diff --git a/doc/modules/ROOT/pages/glossary.adoc b/doc/modules/ROOT/pages/glossary.adoc index 9fbdcb7ba..1e7035330 100644 --- a/doc/modules/ROOT/pages/glossary.adoc +++ b/doc/modules/ROOT/pages/glossary.adoc @@ -8,23 +8,23 @@ // = Glossary +:page-mode: reference This glossary defines terms used throughout the Corosio documentation. == A Acceptor:: -An I/O object that listens for and accepts incoming TCP connections. See -`corosio::tcp_acceptor` and xref:4.guide/4e.tcp-acceptor.adoc[Acceptors Guide]. +An I/O object that listens for and accepts incoming connections. See +cpp:tcp_acceptor[] and xref:4.guide/4e.tcp-acceptor.adoc[Acceptors Guide]. -Affine Awaitable:: -An awaitable type that implements the affine protocol, receiving an -`io_env const*` in `await_suspend` (carrying the executor, stop token, and -frame allocator) to ensure correct executor affinity. +Affine Awaitable:: An awaitable type that implements the affine protocol. +It receives an `io_env const*` in `await_suspend`, carrying the executor, +stop token, and frame allocator, to ensure correct executor affinity. Affinity:: The binding of a coroutine to a specific executor. A coroutine with affinity -to executor `ex` will have all its resumptions dispatched through `ex`. +to executor `ex` has all its resumptions dispatched through `ex`. buffer_param:: A type-erased buffer sequence parameter, allowing non-template code to @@ -58,12 +58,15 @@ Completion:: The event when an asynchronous operation finishes, either successfully or with an error. +Completion Handler:: +A function or coroutine handle that runs when an operation completes. + Composed Operation:: An operation built from multiple primitive operations. For example, `capy::read()` repeatedly calls `read_some()` until the buffer is full. Concurrency Hint:: -A value passed to `io_context` indicating how many threads may call `run()`. +A value passed to cpp:io_context[] indicating how many threads may call `run()`. Affects internal synchronization strategy. Continuation:: @@ -118,14 +121,11 @@ Handle:: A reference to a system resource (socket, file, etc.) managed by the operating system. -Handler:: -A callback function or coroutine handle that runs when an operation completes. - == I I/O Context:: The main event loop in Corosio. Processes asynchronous operations and -dispatches completions. See `corosio::io_context`. +dispatches completions. See cpp:io_context[]. I/O Object:: A class representing an I/O resource (socket, resolver, etc.). Base class is @@ -156,7 +156,7 @@ by `corosio::local_endpoint`. See xref:4.guide/4p.unix-sockets.adoc[Unix Domain == M Mocket:: -A mock socket for testing. See `corosio::test::mocket`. +The in-process test double for a socket. See `corosio::test::mocket`. Move-Only:: A type that can be moved but not copied. Sockets and other I/O objects are @@ -193,7 +193,7 @@ any amount of data. == R Resolver:: -An I/O object that performs DNS lookups. See `corosio::resolver`. +An I/O object that resolves host names to addresses. See cpp:resolver[]. Resume:: Continuing execution of a suspended coroutine. @@ -216,7 +216,7 @@ An I/O object that waits for operating system signals. See `corosio::signal_set`. Socket:: -An I/O object for TCP network communication. See `corosio::tcp_socket`. +An I/O object for network communication. See cpp:tcp_socket[]. Stop Token:: A mechanism for requesting cancellation. See `std::stop_token`. @@ -225,7 +225,7 @@ Strand:: A serialization mechanism that ensures handlers don't run concurrently, so operations execute one at a time, eliminating data races without mutexes. Corosio does not provide a strand type; the equivalent is executor affinity -or a single-threaded `io_context`. +or a single-threaded cpp:io_context[]. See xref:4.guide/4b.concurrent-programming.adoc[Concurrent Programming]. Stream:: @@ -252,7 +252,7 @@ A framework class for building TCP servers with worker pools. See Thread Safety:: The ability to use an object safely from multiple threads. Individual I/O -objects are generally not thread-safe. +objects are not thread-safe. Timeout:: Racing an awaitable against a deadline. See `corosio::timeout()` and @@ -270,7 +270,7 @@ polymorphism without templates. Unix Domain Socket:: A socket that communicates between processes on the same machine using filesystem paths instead of IP addresses and ports. Available as stream -(`local_stream_socket`) and datagram (`local_datagram_socket`) variants. +(cpp:local_stream_socket[]) and datagram (cpp:local_datagram_socket[]) variants. See xref:4.guide/4p.unix-sockets.adoc[Unix Domain Sockets]. == W diff --git a/doc/modules/ROOT/pages/index.adoc b/doc/modules/ROOT/pages/index.adoc index 3d038c726..1ebca8ec8 100644 --- a/doc/modules/ROOT/pages/index.adoc +++ b/doc/modules/ROOT/pages/index.adoc @@ -8,6 +8,7 @@ // = Boost.Corosio +:page-mode: explanation Boost.Corosio is a coroutine-first I/O library for C++20 that provides asynchronous networking primitives with automatic executor affinity propagation. @@ -19,19 +20,19 @@ C++20 coroutines. Every operation returns an awaitable that integrates with the _IoAwaitable_ protocol, ensuring your coroutines resume on the correct executor without manual dispatch. -* **io_context** — Event loop for processing asynchronous operations -* **tcp_socket** — Asynchronous TCP socket with connect, read, and write -* **tcp_acceptor** — TCP listener for accepting incoming connections -* **tcp_server** — Server framework with worker pools -* **udp_socket** — Asynchronous UDP socket for datagrams (including multicast) -* **local_stream_socket** — Unix domain stream socket for local IPC -* **local_stream_acceptor** — Unix domain stream listener -* **local_datagram_socket** — Unix domain datagram socket for local IPC -* **resolver** — Asynchronous DNS resolution -* **delay()** / **timeout()**: stateless delays and deadline racing for coroutines -* **signal_set** — Asynchronous signal handling -* **stream_file** / **random_access_file** — Asynchronous file I/O -* **openssl_stream** / **wolfssl_stream** — TLS encryption (OpenSSL or WolfSSL) +* cpp:io_context[] — Event loop for processing asynchronous operations +* cpp:tcp_socket[] — Asynchronous TCP socket with connect, read, and write +* cpp:tcp_acceptor[] — TCP listener for accepting incoming connections +* cpp:tcp_server[] — Server framework with worker pools +* cpp:udp_socket[] — Asynchronous UDP socket for datagrams (including multicast) +* cpp:local_stream_socket[] — Unix domain stream socket for local IPC +* cpp:local_stream_acceptor[] — Unix domain stream listener +* cpp:local_datagram_socket[] — Unix domain datagram socket for local IPC +* cpp:resolver[] — Asynchronous DNS resolution +* cpp:delay[] / cpp:timeout[]: stateless delays and deadline racing for coroutines +* cpp:signal_set[] — Asynchronous signal handling +* cpp:stream_file[] / cpp:random_access_file[] — Asynchronous file I/O +* cpp:openssl_stream[] / cpp:wolfssl_stream[] — TLS encryption (OpenSSL or WolfSSL) == What This Library Does Not Do @@ -49,16 +50,17 @@ Corosio works with Boost.Capy for task management and execution contexts. **Coroutines only.** Every I/O operation returns an awaitable. There are no callback-based interfaces. -**Affinity through protocol.** The dispatcher propagates through `await_suspend` +**Affinity through protocol.** The executor propagates through `await_suspend` parameters, not through thread-local storage. When an I/O operation completes, -it resumes your coroutine through the dispatcher you provided. +it resumes your coroutine through the executor you provided. **Structured bindings.** Results use `io_result` which supports structured -bindings: `auto [ec, n] = co_await s.read_some(buf)`. `io_result` has no -`value()`; to throw on error, check `ec` and `throw std::system_error(ec)`. +bindings: `auto [ec, n] = co_await s.read_some(buf)`. See +xref:4.guide/4m.error-handling.adoc[Error Handling] for how to handle the +error code. **Type erasure at I/O boundaries.** Socket implementations use type-erased -dispatchers internally. The indirection cost is negligible compared to I/O +I/O backends internally. The indirection cost is negligible compared to I/O latency. == Target Audience @@ -89,7 +91,7 @@ and xref:4.guide/4b.concurrent-programming.adoc[Concurrent Programming] for back === Platform Support -* Linux — epoll backend (x86_64, ARM64); io_uring planned +* Linux — epoll backend (x86_64, ARM64); io_uring backend available, not the default * Windows — IOCP backend * macOS — kqueue backend (ARM64) * FreeBSD — kqueue backend @@ -113,6 +115,7 @@ include::example$programs/index_page_connect.cpp[tag=full] == Next Steps * xref:quick-start.adoc[Quick Start] — Build a working echo server +* xref:4.guide/4m.error-handling.adoc[Error Handling] — How errors are reported and handled * xref:4.guide/4a.tcp-networking.adoc[TCP/IP Networking] — Networking fundamentals * xref:4.guide/4b.concurrent-programming.adoc[Concurrent Programming] — Coroutines and strands * xref:4.guide/4c.io-context.adoc[I/O Context] — Understand the event loop diff --git a/doc/modules/ROOT/pages/quick-start.adoc b/doc/modules/ROOT/pages/quick-start.adoc index 691f6025c..eca1d4c9e 100644 --- a/doc/modules/ROOT/pages/quick-start.adoc +++ b/doc/modules/ROOT/pages/quick-start.adoc @@ -8,6 +8,7 @@ // = Quick Start +:page-mode: tutorial This guide walks you through building your first network application with Corosio: a simple echo server that accepts connections and echoes back @@ -24,7 +25,7 @@ include::example$programs/quick_start_echo.cpp[tag=assume] == Step 1: Create the I/O Context -Every Corosio program starts with an `io_context`. This is the event loop that +Every Corosio program starts with an cpp:io_context[]. This is the event loop that processes all asynchronous operations: [source,cpp] @@ -36,7 +37,7 @@ The `run()` method blocks and processes events until there's no more work. == Step 2: Define the Server Class -The `tcp_server` base class provides connection pooling and lifecycle management. +The cpp:tcp_server[] base class provides connection pooling and lifecycle management. Derive from it and define a worker class: [source,cpp] @@ -46,9 +47,9 @@ include::example$programs/quick_start_echo.cpp[tag=server_class] Key points: -* Workers derive from `worker_base` and implement `socket()` and `run()` +* Workers derive from cpp:tcp_server::worker_base[worker_base] and implement `socket()` and `run()` * Each worker owns its socket and any per-connection state -* The `launcher` starts the session coroutine and returns the worker to the pool when done +* The cpp:tcp_server::launcher[launcher] starts the session coroutine and returns the worker to the pool when done == Step 3: Write the Echo Session @@ -77,7 +78,7 @@ include::example$programs/quick_start_echo.cpp[tag=main] Start the server, then use netcat or telnet to test: -[source,bash] +[role=output] ---- $ telnet localhost 8080 Trying 127.0.0.1... @@ -111,6 +112,7 @@ style for handling a failed operation. Now that you have a working echo server: +* xref:3.tutorials/3a.echo-server.adoc[Echo Server Tutorial] — Build the same server with worker pools and design rationale * xref:4.guide/4k.tcp-server.adoc[TCP Server Guide] — Deep dive into tcp_server * xref:4.guide/4a.tcp-networking.adoc[TCP/IP Networking] — Networking fundamentals * xref:4.guide/4b.concurrent-programming.adoc[Concurrent Programming] — Coroutines and strands diff --git a/doc/mrdocs.yml b/doc/mrdocs.yml index cd392e57e..10f1b6b6f 100644 --- a/doc/mrdocs.yml +++ b/doc/mrdocs.yml @@ -10,6 +10,26 @@ file-patterns: # Filters include-symbols: - 'boost::corosio::**' +# `io_context::sched_` and the copy its executor holds are `protected:`, which +# `extract-private` does not filter -- protected members are part of the +# interface a derived class sees. This one is not: it is the scheduler pointer +# the backend installs during construction. +exclude-symbols: + - 'boost::corosio::io_context::sched_' + - 'boost::corosio::io_context::executor_type::sched_' +# The five socket-option accessors are the contract `set_option`/`get_option` +# run on the caller's behalf, not calls a user makes: across the whole guide +# they appear zero times, and `boolean_option` documents them at implementers +# ("Derived types provide `level()` and `name()`"). They are public only +# because `set_option` is an unconstrained template, so the contract is +# duck-typed, and because the concrete options inherit them. Documenting all +# sixteen copies presents plumbing as API, and on the thirteen options that +# ignore the family argument the parameter text would say nothing true. + - 'boost::corosio::socket_option::*::level' + - 'boost::corosio::socket_option::*::name' + - 'boost::corosio::socket_option::*::data' + - 'boost::corosio::socket_option::*::size' + - 'boost::corosio::socket_option::*::resize' implementation-defined: - 'boost::corosio::detail' - 'boost::corosio::*::detail' @@ -33,6 +53,21 @@ multipage: true # Sorting sort-members-relational-last: false +# Warnings. These feed doc/lint/mrdocs-warnings.mjs, which parses the build log. +# Adopted for parity with Capy, whose config enables the same set. MrDocs turns +# warn-if-undocumented and warn-no-paramdoc on by default, so those were already +# in force here; warn-unnamed-param, warn-broken-ref and warn-if-doc-error were +# not, and each surfaces a class of finding Corosio was previously blind to. +# Deliberately not warn-as-error: the mrdocs_warnings gate reports rather than +# blocks (see .github/workflows/docs.yml), because MrDocs is a rolling +# develop-release build whose output moves on its own. +warnings: true +warn-if-undocumented: true +warn-no-paramdoc: true +warn-unnamed-param: true +warn-broken-ref: true +warn-if-doc-error: true + # Reference examples are injected by addons/extensions/reference-snippets.lua. # MrDocs loads extensions from /share/mrdocs/addons/extensions, so the # doc build installs it there (see doc/build_antora.sh). Setting diff --git a/doc/package-lock.json b/doc/package-lock.json index 981979490..79bf0851b 100644 --- a/doc/package-lock.json +++ b/doc/package-lock.json @@ -17,7 +17,8 @@ "devDependencies": { "@antora/cli": "3.1.14", "@antora/site-generator": "3.1.14", - "antora": "3.1.14" + "antora": "3.1.14", + "asciidoctor": "^4.0.5" } }, "node_modules/@antora/asciidoc-loader": { @@ -597,6 +598,23 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "node_modules/asciidoctor": { + "version": "4.0.11", + "resolved": "https://registry.npmjs.org/asciidoctor/-/asciidoctor-4.0.11.tgz", + "integrity": "sha512-8KiLQVN1RmDfN2n4qWlysWorelAWZFLgtQcbTd6NJVeKRXCgtrxDEICGJqVSy9zOLVDjcnpdhM7Swjf/i4LHCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asciidoctor/core": "4.0.11" + }, + "bin": { + "asciidoctor": "bin/asciidoctor", + "asciidoctorjs": "bin/asciidoctor" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/asciidoctor-opal-runtime": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/asciidoctor-opal-runtime/-/asciidoctor-opal-runtime-0.3.3.tgz", @@ -611,6 +629,16 @@ "node": ">=8.11" } }, + "node_modules/asciidoctor/node_modules/@asciidoctor/core": { + "version": "4.0.11", + "resolved": "https://registry.npmjs.org/@asciidoctor/core/-/core-4.0.11.tgz", + "integrity": "sha512-5uWPTLTiPJVQp9q4MllSROM2avdECzoT7oeVw/onT9H2PIdCCtQwKrSTvztwxMbSu/8cqHqZox/2qTBrQN454g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/async-lock": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.1.tgz", @@ -2029,6 +2057,7 @@ "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.4.0" } diff --git a/doc/package.json b/doc/package.json index add6848fd..bc341ae06 100644 --- a/doc/package.json +++ b/doc/package.json @@ -2,7 +2,8 @@ "devDependencies": { "@antora/cli": "3.1.14", "@antora/site-generator": "3.1.14", - "antora": "3.1.14" + "antora": "3.1.14", + "asciidoctor": "^4.0.5" }, "dependencies": { "@antora/collector-extension": "^1.0.3", diff --git a/doc/prompts/README.md b/doc/prompts/README.md new file mode 100644 index 000000000..ddfd2bbca --- /dev/null +++ b/doc/prompts/README.md @@ -0,0 +1,127 @@ + +# Documentation Prompt Collection + +A collection of structured prompts (in the `tools-public` house style) that generate, +repair, and audit Corosio documentation. They were ported from Capy's `doc/prompts`, which +remains the origin for the shared design rationale; what is Corosio-specific is recorded +here. The prompts do the work a linter cannot: +they own the **judgment rules** — Diátaxis mode purity, duplication, objectiveness, +pedagogy, and code↔doc drift — while deterministic tooling owns the mechanical rules. + +**Two surfaces.** Documentation lives as exposition `.adoc` pages *and* as reference +docstrings in the headers (MrDocs generates the reference from them). Every tool applies the +five axes to both: `doc-audit` gains a reference mode (fixed Diátaxis mode = `reference`, +axes remapped to the docstring contract); `doc-write`/`doc-fix` accept a header `target_file` +and write/repair the docstring in the `.hpp`; `doc-sync` is the natural home for reference +drift — a changed symbol and its docstring share one diff, so the co-located docstring is +always its first drift hit. **A reference edit lands in the `.hpp`, never a generated page.** +Docstring conventions follow the `boost-docs` skill. + +## The two ends of the pipeline + +``` + GENERATE ─────────────────────────────► DETECT + doc-write doc-sync doc-audit + (new page to spec) (repair code-change (score existing + drift) pages on 5 axes) + │ │ + └──────► doc-fix ◄──────┘ + (apply grounded edits + from findings) +``` + +| Tool | End | Trigger | Output | +|---|---|---|---| +| **doc-write** | generate | a symbol/feature/topic to document | a new `.adoc` page + compiled snippet(s) + nav entry | +| **doc-sync** | detect + fix | a code change (diff / commit range) | edits that repair docs the change made stale | +| **doc-audit** | detect | existing or new pages | ranked findings on the five axes | +| **doc-fix** | fix | findings (from `doc-audit` or `doc-sync`) | a minimal, grounded patch set | + +`doc-sync` and `doc-audit` both hand their findings to `doc-fix` for repair, so the edit +contract lives in one place. + +## Shared rubric + +Every tool references one source of truth: the project **documentation style guide** +(`doc/STYLE_GUIDE.md`). The style guide defines: + +- the **five axes** — **St**ructure, **Ac**curacy, **Wo**rding, **Co**mpleteness & Pedagogy, + **Pr**esentation & Tooling; +- the **Diátaxis** mode taxonomy (tutorial / how-to / reference / explanation); +- the **single-source-of-truth** rules (link the reference via `cpp:`; include compiled + snippets, never paste code); +- the **terminology table** (one term per concept). + +The tools cite the guide by section (e.g. "style guide C.1" for terminology) rather than +restating it, so the rubric never drifts from the guide. + +## Division of labor — what these tools do NOT do + +Deterministic tooling owns the mechanical rules and is a separate CI gate: + +- **Vale** — banned words and terminology substitutions (`doc/.vale/styles/Corosio/*`). It does + **not** enforce C2. +- **snippet-compile job** — every documentation code block compiles against the real API. + `doc/lint/check-include-tags.mjs` additionally proves every `[tag=…]` resolves to a live + tag in a compiled source. +- **`doc/lint/sentence-length.mjs`** — the authority for C2, no sentence over 25 words, + over both corpora. +- **`doc/lint/mrdocs-warnings.mjs`** — the reference surface: undocumented symbols, + missing `@param`/`@return`, broken `@ref`. +- **structural lint script** (`doc/lint/doc-lint.mjs`) — mode-attribute presence, nav + position, "no raw `[source]` + blocks", "every concept page has an `include::example$`". + +`doc/lint/README.md` describes how those gates are wired; +`doc/lint/check-no-new-violations.mjs` is the gate that compares a fresh run against +`doc/lint/baseline.json`. These prompts assume those gates exist and target only what +they cannot check. + +## Shared invariants (every tool obeys) + +1. **Raw code and raw page prose never enter the main context.** Sub-agents read from disk; + the main context orchestrates over structured JSON records only. +2. **Every claim is grounded.** A statement about the API is backed by a verbatim quote of + the real declaration or reference; no claim is invented. +3. **Single source.** Code blocks are `include::example$…[tag=…]` of compiled sources, never + hand-typed. Signatures are `cpp:` links, never restated in prose. +4. **Noise floor.** A finding or edit without a verbatim span is discarded. Subjective + preference is not a finding. Doing nothing is a valid outcome. + +## Sub-agent dispatch contract + +Invariant 1 above is a promise; this section is the mechanism that keeps it, stated fully +inside this collection (no external tool or file is required to understand or run it). + +1. **Step 0 is deterministic and runs in the orchestrator** (what each tool calls "the main + context") — no LLM call. It only computes paths, an inventory, a change set, or a brief + from arguments and file **listings** (names, diff stats) — never file **contents**. +2. **One sub-agent per unit** — one page, one symbol, one doc hit, one finding, one edit. The + orchestrator's dispatch to that sub-agent carries only identifiers already produced by a + prior step: a `path`/`symbol`, and the prior step's typed JSON record. It never carries + file contents, because the orchestrator never held any to begin with. +3. **The sub-agent is the only actor that reads raw content.** It opens the file(s) itself, + from disk, using its own tools. Whatever it reads exists only inside that sub-agent's own + context — the orchestrator has no channel into it. +4. **The sub-agent's only return value is its step's typed JSON record**, validated against + that step's schema before the orchestrator accepts it. A record's only raw-text fields are + the short, capped verbatim spans the schema itself demands (`span`, `source_span`, + `evidence` — ≤ 200–300 chars, with the single stated C1/C2 exception) — never the + surrounding page, docstring, or diff hunk. +5. **The adversarial challenge/verify stage is a separate sub-agent dispatch**, not a + continuation of the authoring sub-agent's context. It receives the same kind of + identifier-plus-record input, re-reads the file from disk **independently**, and returns + its own typed verdict record. It never receives the first sub-agent's raw reading — only + its claim. +6. **Consequence:** the orchestrator's own context, across an entire run, contains nothing + but paths, counts, and validated JSON records carrying capped verbatim spans. There is no + step at which an orchestrator instruction says "read this file and show me its contents" — + every read happens inside a sub-agent whose one output channel is the schema. Raw code and + raw prose structurally cannot reach the orchestrator under this contract. diff --git a/doc/prompts/doc-audit.md b/doc/prompts/doc-audit.md new file mode 100644 index 000000000..778d111fc --- /dev/null +++ b/doc/prompts/doc-audit.md @@ -0,0 +1,253 @@ +--- +description: Documentation audit against the five documentation axes +--- + + +# Documentation Audit + +Audits Antora documentation pages **and** public-header docstrings against the five axes +defined in the documentation style guide — **Structure, Accuracy, Wording, Completeness & +Pedagogy, Presentation & Tooling**. Takes one or more `.adoc` pages (**exposition mode**, +Diátaxis-classified) or `include/boost/corosio/**` headers (**reference mode**, Diátaxis mode +fixed to `reference`), and runs a structured analysis pipeline. Sub-agents read all prose or +docstrings and perform all scoring. The main context orchestrates, filters, and renders the +report. Raw page prose and raw docstring text never enter the main context. + +**Noise philosophy:** The tool goes out of its way not to find anything. Every finding must +justify its existence against a style-guide rule with a verbatim quoted span. The default +posture is: this page is fine until proven otherwise. A page that scores clean on all axes +is a valid and desirable outcome, not a failure of the tool. Subjective preference is not a +finding. + +\newpage + +```mermaid +flowchart TD + Paths --> Inventory + Inventory --> Classify + Classify --> Score + Score --> Challenge + Challenge --> Synthesize + Synthesize --> Report +``` + +\newpage + +--- + +## Core Rule + +Raw page prose or docstring text NEVER enters the main context. All reading and scoring +happen inside sub-agents; the main context receives only structured JSON records. A finding +without a verbatim quoted span is discarded. Non-negotiable. + +The five axes are the ONLY axes. The authoritative definitions live in the style guide; the +summaries below are for the sub-agent's convenience. Cite the specific rule (e.g. "C5", +"B1", "D2") in each finding. + +- **St — Structure.** Diátaxis mode purity (one mode per page); dependency-correct ordering; + no duplication of another page; no reproduction of reference signatures (style guide A, B). +- **Ac — Accuracy.** Every claim correct and verifiable against code/reference; no drift. + (Example *compilation* is a CI gate, not this tool.) +- **Wo — Wording.** Judgment-level prose rules a linter cannot check: undefined terms, + decorative metaphor/cliché, tone, unnecessary negatives (style guide C5–C7). +- **Co — Completeness & Pedagogy.** Goal-oriented not syntax-first; a concept page shows the + library's own type running; non-obvious choices state rationale; thread-safety/affinity + documented (style guide D). +- **Pr — Presentation & Tooling.** Prose links the reference via `cpp:` rather than restating + it (style guide E1). Nav/ToC/theme/reference-grouping are owned by the build, not here. + +**Reference mode remaps every axis to the docstring contract** (fixed Diátaxis mode = +`reference`); see "Reference Mode" below for the concrete per-axis rules. + +--- + +## Step 0 - Inventory + +Runs in main context. No LLM. Deterministic. + +**Input:** paths — files/directories under `doc/modules/ROOT/pages` (**exposition mode**), +or files/directories under `include/boost/corosio/**` (**reference mode**). + +**Actions (exposition mode):** expand directories to `.adoc`; exclude partials (`_*.adoc`), +nav, generated reference. Attach `nav_position` and `declared_mode` (the `:page-mode:` +attribute, or null). + +**Actions (reference mode):** expand directories to headers; exclude `detail/`, `impl/`, +`experimental/detail/`, and any public declaration with **no** docstring at all (an +undocumented declaration is the MrDocs no-warnings gate's job, not this tool's — see "Not +this tool's job"). One entry per documented public declaration, keyed by its qualified +`symbol` and the verbatim Doxygen block immediately preceding it. + +**Output:** `DiscoveryResult` — `page_entries[]` of `{ path, unit_kind, nav_position, +declared_mode }` for `unit_kind=page`, or `{ path, unit_kind, symbol }` for +`unit_kind=docstring`. Inform the user: "[N] pages / [M] docstrings under audit." + +--- + +## Step 1 - Classify + +One sub-agent per page. Determine the page's true Diátaxis mode from content. + +**Reference mode (`unit_kind=docstring`) skips this step.** A docstring's Diátaxis mode is +fixed to `reference` by definition (style guide Part A) — set `inferred_mode=reference`, +`declared_mode=reference`, `mode_mismatch=false`, and go straight to Step 2. + +**Return:** `ClassifyRecord` + +- `path`: string +- `inferred_mode`: one of `tutorial`, `how-to`, `reference`, `explanation`, `mixed` +- `declared_mode`: string or null +- `mode_mismatch`: boolean — `true` only when `declared_mode` is **non-null** and disagrees + with `inferred_mode`, or `inferred_mode` is `mixed`. **A `null` `declared_mode` is never a + mismatch** — an undeclared `:page-mode:` is the deterministic lint script's gate (A1; see + README "Division of labor"), not this tool's judgment call, regardless of how many pages in + the target corpus currently declare one. (In Capy, where this rule was written, the corpus + went from 1 of 65 pages declaring `:page-mode:` to 65 of 65 over the course of one plan; the + rule above did not change and must not be re-tuned to a snapshot count. Corosio's corpus is + at 48 of 48. A corpus in any of those states defers presence-checking to A1, never to this + tool's judgment.) +- `topic`: string, **one sentence** — the concept the page teaches +- `approx_word_count`: integer + +**Validation:** reject invalid JSON, unknown enum, `topic` > 200 chars. If two pages share a +`topic`, flag a duplication candidate for Step 2. + +--- + +## Step 2 - Score + +One sub-agent per page. Score the five axes in a single read. + +**Input:** `ClassifyRecord` + any duplication-candidate paths. Sub-agent reads from disk. + +**Return:** `PageScore` + +- `path`: string +- `axes[]`: exactly five, one per `St`,`Ac`,`Wo`,`Co`,`Pr`, each: + - `axis`: one of `St`,`Ac`,`Wo`,`Co`,`Pr` + - `grade`: one of `clean`, `minor`, `major` + - `findings[]`: **at most 4 per axis**, each: + - `span`: **verbatim quote** (≤ 200 chars, **except** a `C1`/`C2` sentence-length finding, + whose `span` is the full offending sentence even past 200 chars — truncating a run-on + sentence deletes the clause that proves the violation). Required. + - `rule`: string — the style-guide rule id (e.g. `C5`, `D2`). + - `problem`: string, **one sentence**. + - `fix`: string, **one sentence** — the concrete edit. + - `confidence`: one of `high`, `medium`, `low`. +- `runnable_example_present`: boolean — feeds the `Co` grade for concept pages. **D2 scope:** + applies hard to tutorial/how-to concept pages (a type must be shown *actually running*, and + a claimed program output must trace to a compiled `main`/test harness — see the dry-run + finding below). For `explanation`/design-essay pages, compiled snippets that illustrate + mechanism (no claimed program output) satisfy the single-source rule without D2's stronger + "shown running" bar — do not force-fail an essay for lacking one. + +**Wording exemption:** text inside an attributed `[quote]` block or a `role=external`/ +`role=pseudocode` code block is not the page author's prose — exclude it from `Wo`-axis and +terminology checks (rewriting a citation to match house terminology misquotes the source). + +**Presentation scope (`Pr`/E1/B1):** flag a hand-typed **signature** (a restated parameter +list or return type) or a restated concept definition, not every backtick-quoted type name in +casual prose. (Confirmed against the corpus: `cpp:` is established house convention — 51 of +65 pages use it, 555 uses across 93 distinct targets. This is no longer an adoption backlog, +so the noise floor tightens: a bare backtick-quoted reference to a type or member that has a +resolvable `cpp:` target is in scope for a finding, same as a hand-typed signature. Casual +prose that names a concept with no corresponding reference target remains out of scope — +still don't flag every backtick-quoted word.) + +**Validation:** reject if any finding lacks a `span`; if a `span` is not verbatim in the +file; if > 4 findings per axis; if the axes are not exactly `St,Ac,Wo,Co,Pr`. + +### Reference mode — axis remap (docstrings) + +Fixed Diátaxis mode = `reference`. The five axes remap to the docstring contract (align with +the `boost-docs` skill's Doxygen conventions): + +- **St** — structural completeness: brief (implicit first sentence) present; no stray + `\`-commands mixed with `@`-commands; no paragraph stranded inside or after a `@par` block. + **No section-ordering rule** — retired: the corpus splits 65/26 in favor of `@par` *before* + the first `@param`/`@tparam`/`@return` (house convention is the "violating" form, 71/29), + and MrDocs 0.8.0 normalizes section order in the rendered output regardless of source order + (e.g. `task.hpp`'s `await_resume` writes `@return` before `@par Exception Safety`, but the + rendered page shows Exception Safety before Return Value) — so there is no stable target to + score source order against. +- **Ac** — docstring↔code accuracy: every actual parameter has a matching `@param`, same + name, same order; `@return` present iff the function returns non-`void`; `@throws` matches + what the code can actually throw (a `noexcept` function carries no `@throws`); a + `requires`/concept constraint on a template parameter is reflected in prose or `@par + Requires`. +- **Wo** — same STE-derived prose rules (C1–C10), scoped to the docstring's own sentences + (excluding `@code`/`@endcode`). +- **Co** — completeness & pedagogy remapped to docstring-contract completeness: `@param`/ + `@return`/`@throws` coverage; thread-safety documented (`@par Thread Safety`) where not + obviously single-threaded; the template constraint's *purpose* is explained, not only + stated; `@par Example` present where feasible. +- **Pr** — **MrDocs render check**: valid Doxygen command syntax (no unclosed `@code`, no + `@param` naming a parameter that does not exist); the brief describes behavior, not + identity, and does not restate the declaration (style guide B4). + +**Known gap — `Ac` has no lettered rule.** Unlike `St`, `Co`, and `Pr` above, reference-mode +`Ac` doesn't map onto any lettered clause in the style guide: Part A/B are written for +exposition-mode structure and briefs, not a per-axis accuracy contract for docstrings. Past +audits have cited `B4` (written for class-brief identity-vs-behavior, not factual accuracy) +to justify a plain factual-error finding under `Ac` — that's a stretch, not a real citation. +Until the style guide settles this (a possible Part B accuracy rule, or an explicit blessing +of the axis id as its own citation — out of scope here, see Task 1b), citing the bare axis id +(`Ac`) as a reference-mode finding's `rule` is acceptable. Do not discard an otherwise-valid +`Ac` finding for lacking a lettered citation the style guide does not currently provide. + +A docstring finding's `span` is a verbatim quote from the header (same 200-char rule and the +same C1/C2 exception as exposition mode). Reference-mode findings feed `doc-fix`/`doc-sync` +exactly like exposition findings — the repair still lands in the `.hpp`, never a generated +page (see `doc-fix`/`doc-write`). + +--- + +## Step 3 - Challenge + +One adversarial sub-agent per page with any `major` grade. Its job is to REFUTE findings. + +**Return:** `ChallengeRecord` — `verdicts[]` of `{ span, survives, reason }`. +**Rule:** default `survives=false` when uncertain. Main drops every non-surviving finding. + +--- + +## Step 4 - Synthesize + +Main context, surviving records only. Roll up per-page grades to an axis line +(`St:major Ac:clean Wo:minor Co:major Pr:minor`); rank pages by `3×major + minor`; merge +duplication candidates into one cross-page Structure finding. + +**Output — Report** (feeds `doc-fix` directly): + +``` +## Documentation Audit +| Page / Symbol | St | Ac | Wo | Co | Pr | Mode ok? | +|------|----|----|----|----|----|----------| + +### — St:major, Co:major +- **[St · A2]** "" — -> (high) +``` + +Reference-mode rows use `::` (e.g. `include/boost/corosio/tcp_socket.hpp:: +connect(endpoint)`) in the Page/Symbol column; `Mode ok?` is always `yes` (mode is fixed). + +End with: "[P] pages audited, [D] docstrings audited, [C] clean, [F] findings across the five +axes." + +--- + +## Not this tool's job + +Compilation (CI), mechanical prose lint and structural/nav checks (Vale + lint script), and +**rewriting** (`doc-fix` consumes this report). This tool judges only what a linter cannot. +For the reference surface: a public declaration with **no** docstring at all is the MrDocs +no-warnings gate's job (Task 2), not a finding here — this tool judges whether an *existing* +docstring is complete, accurate, and well-worded, not whether one exists. diff --git a/doc/prompts/doc-fix.md b/doc/prompts/doc-fix.md new file mode 100644 index 000000000..c0bb0cf49 --- /dev/null +++ b/doc/prompts/doc-fix.md @@ -0,0 +1,144 @@ +--- +description: Apply minimal, grounded repairs to documentation from a findings list +--- + + +# Documentation Fix + +Consumes a findings list (from `doc-audit` or `doc-sync`, or a single page path) and produces +a minimal, grounded patch set. Each edit is tied to one finding and grounded in the real +code/reference. Sub-agents read and edit; the main context orchestrates over records. Raw +code and raw prose never enter the main context. + +**Noise philosophy:** The smallest edit that resolves the finding. Preserve the author's +voice and intent. Do not rewrite beyond the finding's span. If resolving a finding requires a +judgment the finding does not authorize (restructuring, changing an argument), escalate +instead of guessing. + +\newpage + +```mermaid +flowchart TD + Findings --> Intake + Intake --> Ground + Ground --> Edit + Edit --> Verify + Verify --> Patch +``` + +\newpage + +--- + +## Core Rule + +An edit is emitted only when (a) it is tied to a specific finding, (b) it changes only the +finding's span or its minimal enclosing block, and (c) any factual content is grounded in a +verbatim quote of the real code/reference. Code fixes edit the **compiled snippet source**, +not the page. Signature restatements are replaced with `cpp:` links (style guide B). Raw code +and prose never enter the main context. + +--- + +## Step 0 - Intake + +Runs in main context. Deterministic. + +**Input:** a findings array (the `doc-audit`/`doc-sync` report), or `{ page_path }` (in which +case run `doc-audit` on that page first). `page_path` may be an `.adoc` page **or** a header +(`include/boost/corosio/**`) — a header target runs `doc-audit` reference mode. + +**Actions:** group findings by `path` (a header path groups its docstring findings same as a +page groups its prose findings); within a page, order by axis severity (`major` before +`minor`) and by document position. Drop findings without a `span`. + +**Output:** `FixQueue` — `pages[]` of `{ path, findings[] }`. + +--- + +## Step 1 - Ground + +One sub-agent per page. Reads the page and the real code/reference for each finding's span. + +**Return:** `GroundRecord` + +- `path`: string +- `items[]`: each `{ finding_ref, current_span, corrected_fact, source_span }` where + `source_span` is a **verbatim quote** of the code/reference that justifies the correction, + or `null` if the fix is purely stylistic (wording). + +**Validation:** reject any item whose `corrected_fact` is factual but has no `source_span`. + +--- + +## Step 2 - Edit + +One sub-agent per finding. Produces the concrete edit. + +**Return:** `Edit` + +- `finding_ref`: string +- `edit_kind`: one of `prose`, `link` (restated signature → `cpp:`), `snippet` (fix the + compiled source), `admonition` (move/insert rationale), `docstring` (repair a Doxygen block + in a header), `escalate`. +- `target_file`: string — the `.adoc` page; the snippet source for `edit_kind=snippet`; or the + `.hpp` for `edit_kind=docstring`. A reference-mode edit always lands in the header, never a + generated reference page. +- `before`: **verbatim** current text (≤ 300 chars). +- `after`: replacement text. Conforms to the style guide (C1–C10 for prose; B1/B2 for + links/snippets) for `target_file=.adoc`, or to the `boost-docs` skill's Doxygen conventions + (brief/`@param`/`@return`/`@throws`/thread-safety order) for `edit_kind=docstring`. +- `escalation_reason`: string or null — set when the fix needs unauthorized judgment. + +**Validation:** reject if `before` is not verbatim in `target_file`; reject if `after` +introduces a factual claim absent from Step 1's `source_span`. + +--- + +## Step 3 - Verify + +One adversarial sub-agent per edit. Confirms the edit resolves the finding without collateral. + +**Return:** `Verdict` — `{ finding_ref, resolves, introduces_new_claim, style_conformant, +accept }`. `accept=true` requires `resolves && !introduces_new_claim && style_conformant`. +Default to `accept=false` when uncertain. + +For `edit_kind=snippet`, `accept` additionally requires the edited source to **compile**. For +`edit_kind=docstring`, `accept` additionally requires the header to still compile and every +`@param` name to match the declaration, in order. + +--- + +## Step 4 - Patch + +Main context, accepted edits only. Emit an ordered patch set per file (unified-diff style), +then the escalations as a separate human-review list. + +**Output:** + +``` +## Documentation Fix — patch set +### (N edits) +- [St · A2] link: "the tcp_socket class" -> cpp:tcp_socket[] +- [Wo · C5] prose: "" -> "" +### (N edits) +- [Ac · B4] docstring: "" -> "" +### Escalations (human judgment required) +- : — +``` + +End with: "[E] edits across [P] pages, [S] escalations." + +--- + +## Not this tool's job + +Finding the problems (`doc-audit`/`doc-sync` do that); deciding structure or argument +(escalated); the mechanical prose lint pass (Vale) runs afterward and should come back clean. diff --git a/doc/prompts/doc-sync.md b/doc/prompts/doc-sync.md new file mode 100644 index 000000000..d26a6b028 --- /dev/null +++ b/doc/prompts/doc-sync.md @@ -0,0 +1,172 @@ +--- +description: Repair documentation that a code change made stale, including silent drift +--- + + +# Documentation Sync + +Given a code change (a diff or commit range), finds and repairs the documentation the change +made stale — **including drift that breaks no deterministic rule**. Vale still passes, the +structure is still valid, and any snippet that does not touch the changed API still compiles; +yet a prose description, a rationale, or a reference brief can now be silently wrong. This +tool keys off the *diff*, not off doc-internal rules, so it catches exactly that drift. +Sub-agents read code and docs; the main context orchestrates over records. Raw code and prose +never enter the main context. + +**Noise philosophy:** Only the changed surface can cause drift. Start from the diff and reach +outward. A change that touches nothing documented produces zero edits — a valid outcome. No +edit without a changed symbol and a stale doc span. + +\newpage + +```mermaid +flowchart TD + Diff --> ChangeSurface + ChangeSurface --> Locate + Locate --> AssessDrift + AssessDrift --> Repair + Repair --> Verify + Verify --> Synthesize +``` + +\newpage + +--- + +## Core Rule + +An edit is proposed only when tied to a specific changed symbol AND a specific documentation +span, and the correction is verifiable against the **new** code (quote the new declaration or +behavior). No speculative rewrites. Repairs follow the `doc-fix` edit contract (compiled +snippet sources for code; `cpp:` links for signatures; style guide for prose). Raw code and +prose never enter the main context. + +--- + +## Step 0 - Change surface + +Runs in main context. No LLM. Deterministic. + +**Input:** a diff or commit range (e.g. `git diff A..B` over the public headers). + +**Actions:** compute the changed **public** API surface — restrict to declarations under the +public include path; ignore `detail/` and tests. + +**Output:** `ChangeSet` — `changes[]` of: +- `symbol`: string (qualified) +- `change_kind`: one of `added`, `removed`, `renamed`, `signature_changed`, + `semantics_changed` (docstring/behavior changed but signature stable) +- `old_decl`, `new_decl`: strings (verbatim, or null for added/removed) + +Inform the user: "[K] public symbols changed." + +--- + +## Step 1 - Locate + +One sub-agent per changed symbol. Finds every documentation location that mentions or depends +on it — prose references, reference briefs, example sources, compiled snippets, and prose that +*assumes old behavior* without naming the symbol. + +**Drift-hit #1 is mandatory and unconditional.** Before searching anywhere else, the +sub-agent emits one hit for the changed symbol's **co-located docstring** — the Doxygen block +immediately preceding its declaration in the header — as `hit_kind=own_docstring`. This hit +is always produced, whether or not the diff touched the docstring's own text: the docstring +sits beside the symbol the diff just changed, so it is inspected on every run, and Step 2 +assesses it against the *new* declaration like any other hit. (This is the reference-mode +entry point for `doc-sync`: reference drift almost always surfaces here first, because a +changed signature or a newly added constraint is exactly the kind of thing an existing brief +or `@param` block silently stops matching.) + +**Return:** `DocHits` — `hits[]` of `{ symbol, path, span, hit_kind }` where `hit_kind` is one +of `own_docstring` (the co-located Doxygen block; always hit #1), `signature_mention`, +`prose_reference`, `example_use`, `rationale_dependency`, `xref`; `span` is a **verbatim +quote** from the doc. + +--- + +## Step 2 - Assess drift + +One sub-agent per hit. Compares the hit against the `new_decl` / new semantics. + +**Return:** `DriftRecord` + +- `path`, `symbol`, `span` +- `status`: one of `stale_signature`, `stale_behavior`, `stale_example`, `stale_rationale`, + `stale_xref`, `still_correct` +- `evidence`: **verbatim quote** of the new declaration/behavior that proves staleness +- `axis`: the style-guide axis the drift violates (usually `Ac`; `stale_rationale` may be + `Co`) + +**Validation:** reject `status != still_correct` without `evidence`. Drop `still_correct`. + +--- + +## Step 3 - Repair + +For every stale `DriftRecord`, produce an edit under the **`doc-fix` Step 2 contract** (same +`Edit` record: `edit_kind`, `target_file`, `before`, `after`, grounded in `evidence`). Code +drift fixes the compiled snippet source; signature drift *in exposition prose* becomes a +`cpp:` link; `stale_rationale` is flagged `escalate` (a changed *why* usually needs human +judgment). + +A stale `own_docstring` hit is repaired **in the header itself** — `edit_kind=docstring`, +`target_file` is the `.hpp`, `after` conforms to the `boost-docs` skill's Doxygen conventions +(brief/`@param`/`@return`/`@throws`/thread-safety, in that order). The reference edit lands +in the `.hpp`, never a generated page — the generated reference is a build artifact of the +docstring, not a document to edit directly. + +**Return:** `Edit[]` (as defined by `doc-fix`). + +--- + +## Step 4 - Verify + +One adversarial sub-agent per edit. Confirms the edit matches the **new** code and introduces +no claim the new code does not support. For snippet edits, the source must compile against the +new API. For `edit_kind=docstring`, `accept` additionally requires the header still compiles +and every `@param` name still matches the declaration. Default `accept=false` when uncertain. +(Same `Verdict` record as `doc-fix`.) + +--- + +## Step 5 - Synthesize + +Main context, accepted edits only. + +**Output:** + +``` +## Documentation Sync — +[K] symbols changed, [H] doc hits, [D] stale, [E] edits, [X] escalations. + +### (signature_changed) +- [Ac] own_docstring stale_signature: "" -> (accepted) +- [Ac] stale_signature: "" -> cpp:... (accepted) +- [Co] stale_rationale: "" — ESCALATE: behavior changed, rewrite the "why" +``` + +The `own_docstring` row is the co-located docstring hit (Step 1) — it always appears first +when the changed symbol's own brief/`@param` block no longer matches the new declaration. + +Escalations (renames touching many pages, changed rationale, removed symbols still taught) +are listed for human review, never auto-applied. + +--- + +## Not this tool's job + +Judging pages the change did not touch (`doc-audit` does that); applying edits beyond the +changed surface; the mechanical lint/compile gates, which run afterward as the backstop. + +## Intended use + +Run in the PR that changes a public header, or in a scheduled job over the merge range, so +docs cannot silently drift from code between releases. diff --git a/doc/prompts/doc-write.md b/doc/prompts/doc-write.md new file mode 100644 index 000000000..481dbef26 --- /dev/null +++ b/doc/prompts/doc-write.md @@ -0,0 +1,171 @@ +--- +description: Generate a new documentation page to spec, grounded in the real API +--- + + +# Documentation Write + +Generates a new Antora documentation page (or a section) for a symbol, feature, or topic. +The page is written in the correct Diátaxis mode, conforms to the documentation style guide, +grounds every claim in the real code and reference, and sources every code block from a +compiled snippet. Sub-agents read the code and author the prose; the main context +orchestrates over structured records. Raw code never enters the main context. + +**Noise philosophy:** Write the minimum that teaches the reader the concept and lets them +run something. Do not pad. Do not restate the reference. If a fact is not in the code or the +reference, it is not written — it is flagged as a gap for a human. + +\newpage + +```mermaid +flowchart TD + Target --> Brief + Brief --> Ground + Ground --> Outline + Outline --> Snippet + Snippet --> Draft + Draft --> SelfCheck + SelfCheck --> Page +``` + +\newpage + +--- + +## Core Rule + +Every claim about the API is grounded in a verbatim quote of the real declaration, docstring, +or reference — no invented behavior. Every code block is an `include::example$…[tag=…]` of a +compiled source authored in Step 4, never hand-typed. Every signature mentioned in prose is a +`cpp:` link, never restated (style guide B). Raw code never enters the main context. + +--- + +## Step 0 - Brief + +Runs in main context. Deterministic. + +**Input:** `{ target, mode, audience, target_file }` — the symbol/feature/topic, the intended +Diátaxis mode (`tutorial`|`how-to`|`reference`|`explanation`), the reader assumed, and an +optional `target_file`. + +**Actions:** resolve `target` to its set of public symbols; determine the nav location and +the pages it must cross-link. **If `target_file` names a header** (`include/boost/corosio/**`), +this is a **docstring write**, not a page write: `mode` is forced to `reference`, +`symbols[]` is the single declaration in `target_file`, and `nav_parent`/`related_pages[]` are +`null` (a docstring has no nav entry — Steps 2/4 skip nav-shaped output accordingly). + +**Output:** `Brief` — `{ target, mode, audience, symbols[], nav_parent, related_pages[], +target_file }` (`target_file` is `null` for a page write). + +--- + +## Step 1 - Ground + +One sub-agent. Reads the real declarations, docstrings, and existing reference for +`symbols[]`. Produces the fact sheet the page may draw on — nothing outside it may be +claimed. + +**Return:** `FactSheet` + +- `facts[]`: each `{ symbol, kind, signature_ref, behavior, preconditions[], errors[], + thread_safety, affinity, source_span }` where `source_span` is a **verbatim quote** of the + declaration/docstring that grounds `behavior`. +- `gaps[]`: strings — facts the reader needs that the code/reference does not state + (escalated to a human; never invented). + +**Validation:** reject any `fact` whose `behavior` is not supported by its `source_span`. + +--- + +## Step 2 - Outline + +One sub-agent. Produces a mode-appropriate outline; each section maps to `facts[]`. + +**Return:** `Outline` + +- `sections[]`: each `{ heading, purpose_one_sentence, fact_ids[], needs_runnable_example }` +- Mode contract: `tutorial`/`how-to` are goal-oriented (open with the use case, not the + syntax; style guide D1) and every concept section sets `needs_runnable_example=true`; + `reference` is complete and dry; `explanation` is argued and may carry rationale. **A + docstring write (`target_file` set) has exactly one section — the declaration itself — and + always sets `needs_runnable_example` from whether the symbol is example-worthy, not from + the mode contract above.** + +**Validation:** reject if any section maps to no fact; reject a `mixed` outline (one mode +per page, style guide A1). + +--- + +## Step 3 - Snippet + +One sub-agent + deterministic build. For each `needs_runnable_example`, first check for an +**existing** compiled snippet already covering the same fact (grep `test/doc/snippets/` and +`example/` for the symbol); reuse its `source_path`/`tag` rather than authoring a duplicate. +Otherwise author a new compiled source file with a tagged region under `example/` or +`test/doc/`, then compile it. + +**Return:** `Snippets` — `snippets[]` of `{ section_heading, source_path, tag, compiles }`. + +**Validation:** reject any snippet where `compiles=false`. A section that needs an example +but has no compiling snippet blocks the draft. + +--- + +## Step 4 - Draft + +One sub-agent. Writes the `.adoc` from the outline, fact sheet, and snippets — **or**, when +`target_file` is set, writes the Doxygen docstring block for the header instead. + +**Rules applied while drafting a page (style guide):** one idea per sentence, active voice, +simple tense (C1–C4); terminology table (C.1); `cpp:` links for every symbol (B1, E1); code +via `include::example$…[tag=…]` only (B2); rationale in interleaved admonitions (A3, D3); +`:page-mode:` attribute set (A1). + +**Rules applied while drafting a docstring** (`target_file` set; follow the `boost-docs` +skill): implicit one-sentence brief describing behavior, not identity (B4); section order +brief → description → `@param` (one per parameter, in declaration order) → `@return` (omit +for `void`) → `@par` blocks (Thread Safety, Complexity, Example, as applicable) → `@throws` → +`@see`; a `requires`/concept constraint on a template parameter is explained in prose, not +only named; match the local file's comment style (`/** */` vs Asio's `///` + `/** */`) — +never introduce a second convention into a file. **The edit lands in the `.hpp` — a docstring +write never produces or touches a generated reference page.** + +**Return:** `Draft` — `{ page_path, adoc_text, nav_entry }` for a page write, or +`{ target_file, symbol, docstring_text }` for a docstring write. + +--- + +## Step 5 - Self-check + +One adversarial sub-agent. Verifies the draft before it is emitted. + +**Return:** `CheckRecord` + +- `claim_checks[]`: each `{ span, grounded_by_fact_id, supported }` — every API claim in the + prose maps to a fact; `supported=false` marks a hallucination. +- `mode_pure`: boolean; `all_code_is_include`: boolean; `all_signatures_linked`: boolean. +- For a docstring write: `param_names_match`: boolean — every `@param` name matches the + actual parameter, in order; `brief_describes_behavior`: boolean — the brief is not a + restated declaration (B4). + +**Rule:** if any `supported=false`, or any boolean is false, the draft is returned to Step 4 +with the specific failures. Nothing is emitted until all pass. + +**Output:** the `.adoc` page, the compiled snippet source(s), the nav entry, and the `gaps[]` +list for human follow-up — or, for a docstring write, the edited `.hpp` and the `gaps[]` list. +Never a generated reference page. + +--- + +## Not this tool's job + +Deciding the *information architecture* across many pages (a human, or a structure pass, owns +mode assignment and nav shape); mechanical prose lint (Vale) runs afterward as the backstop. diff --git a/include/boost/corosio/backend.hpp b/include/boost/corosio/backend.hpp index 42d9a177c..1bfdbac39 100644 --- a/include/boost/corosio/backend.hpp +++ b/include/boost/corosio/backend.hpp @@ -52,39 +52,72 @@ class posix_random_access_file_service; } // namespace detail -/// Backend tag for the Linux epoll I/O multiplexer. +/// Selects the Linux epoll I/O multiplexer as the backend. struct epoll_t { - using scheduler_type = detail::epoll_scheduler; - using tcp_socket_type = detail::epoll_tcp_socket; - using tcp_service_type = detail::epoll_tcp_service; - using udp_socket_type = detail::epoll_udp_socket; - using udp_service_type = detail::epoll_udp_service; - using tcp_acceptor_type = detail::epoll_tcp_acceptor; + /// The scheduler that drives the event loop. + using scheduler_type = detail::epoll_scheduler; + /// The concrete TCP socket type. + using tcp_socket_type = detail::epoll_tcp_socket; + /// The service that owns the TCP socket implementations. + using tcp_service_type = detail::epoll_tcp_service; + /// The concrete UDP socket type. + using udp_socket_type = detail::epoll_udp_socket; + /// The service that owns the UDP socket implementations. + using udp_service_type = detail::epoll_udp_service; + /// The concrete TCP acceptor type. + using tcp_acceptor_type = detail::epoll_tcp_acceptor; + /// The service that owns the TCP acceptor implementations. using tcp_acceptor_service_type = detail::epoll_tcp_acceptor_service; - using local_stream_socket_type = detail::epoll_local_stream_socket; - using local_stream_service_type = detail::epoll_local_stream_service; + /// The concrete Unix domain stream socket type. + using local_stream_socket_type = detail::epoll_local_stream_socket; + /// The service that owns the Unix domain stream implementations. + using local_stream_service_type = detail::epoll_local_stream_service; + /// The concrete Unix domain stream acceptor type. using local_stream_acceptor_type = detail::epoll_local_stream_acceptor; + /// The service that owns the Unix domain acceptor implementations. using local_stream_acceptor_service_type = detail::epoll_local_stream_acceptor_service; - using local_datagram_socket_type = detail::epoll_local_datagram_socket; + /// The concrete Unix domain datagram socket type. + using local_datagram_socket_type = detail::epoll_local_datagram_socket; + /// The service that owns the Unix domain datagram implementations. using local_datagram_service_type = detail::epoll_local_datagram_service; - using signal_type = detail::posix_signal; - using signal_service_type = detail::posix_signal_service; - using resolver_type = detail::posix_resolver; + /// The concrete signal set type. + using signal_type = detail::posix_signal; + /// The service that owns the signal set implementations. + using signal_service_type = detail::posix_signal_service; + /// The concrete name resolver type. + using resolver_type = detail::posix_resolver; + /// The service that owns the resolver implementations. using resolver_service_type = detail::posix_resolver_service; - using stream_file_type = detail::posix_stream_file; + /// The concrete sequential file type. + using stream_file_type = detail::posix_stream_file; + /// The service that owns the sequential file implementations. using stream_file_service_type = detail::posix_stream_file_service; - using random_access_file_type = detail::posix_random_access_file; + /// The concrete random-access file type. + using random_access_file_type = detail::posix_random_access_file; + /// The service that owns the random-access file implementations. using random_access_file_service_type = detail::posix_random_access_file_service; - /// Create the scheduler and services for this backend. + /** Create the scheduler and services for this backend. + + @param ctx The execution context that owns the scheduler. + @param concurrency_hint Hint for the number of threads that + call `run()`. A performance tuning knob; the + thread-safety contract is set separately by + @ref io_context_options::locking. + + @return Reference to the newly created scheduler. + + @throws std::system_error If the backend's infrastructure + could not be created. + */ BOOST_COROSIO_DECL static detail::scheduler& - construct(capy::execution_context&, unsigned concurrency_hint); + construct(capy::execution_context& ctx, unsigned concurrency_hint); }; /// Tag value for selecting the epoll backend. @@ -121,39 +154,72 @@ class posix_random_access_file_service; } // namespace detail -/// Backend tag for the portable select() I/O multiplexer. +/// Selects the portable select() I/O multiplexer as the backend. struct select_t { - using scheduler_type = detail::select_scheduler; - using tcp_socket_type = detail::select_tcp_socket; - using tcp_service_type = detail::select_tcp_service; - using udp_socket_type = detail::select_udp_socket; - using udp_service_type = detail::select_udp_service; - using tcp_acceptor_type = detail::select_tcp_acceptor; + /// The scheduler that drives the event loop. + using scheduler_type = detail::select_scheduler; + /// The concrete TCP socket type. + using tcp_socket_type = detail::select_tcp_socket; + /// The service that owns the TCP socket implementations. + using tcp_service_type = detail::select_tcp_service; + /// The concrete UDP socket type. + using udp_socket_type = detail::select_udp_socket; + /// The service that owns the UDP socket implementations. + using udp_service_type = detail::select_udp_service; + /// The concrete TCP acceptor type. + using tcp_acceptor_type = detail::select_tcp_acceptor; + /// The service that owns the TCP acceptor implementations. using tcp_acceptor_service_type = detail::select_tcp_acceptor_service; - using local_stream_socket_type = detail::select_local_stream_socket; - using local_stream_service_type = detail::select_local_stream_service; + /// The concrete Unix domain stream socket type. + using local_stream_socket_type = detail::select_local_stream_socket; + /// The service that owns the Unix domain stream implementations. + using local_stream_service_type = detail::select_local_stream_service; + /// The concrete Unix domain stream acceptor type. using local_stream_acceptor_type = detail::select_local_stream_acceptor; + /// The service that owns the Unix domain acceptor implementations. using local_stream_acceptor_service_type = detail::select_local_stream_acceptor_service; - using local_datagram_socket_type = detail::select_local_datagram_socket; + /// The concrete Unix domain datagram socket type. + using local_datagram_socket_type = detail::select_local_datagram_socket; + /// The service that owns the Unix domain datagram implementations. using local_datagram_service_type = detail::select_local_datagram_service; - using signal_type = detail::posix_signal; - using signal_service_type = detail::posix_signal_service; - using resolver_type = detail::posix_resolver; + /// The concrete signal set type. + using signal_type = detail::posix_signal; + /// The service that owns the signal set implementations. + using signal_service_type = detail::posix_signal_service; + /// The concrete name resolver type. + using resolver_type = detail::posix_resolver; + /// The service that owns the resolver implementations. using resolver_service_type = detail::posix_resolver_service; - using stream_file_type = detail::posix_stream_file; + /// The concrete sequential file type. + using stream_file_type = detail::posix_stream_file; + /// The service that owns the sequential file implementations. using stream_file_service_type = detail::posix_stream_file_service; - using random_access_file_type = detail::posix_random_access_file; + /// The concrete random-access file type. + using random_access_file_type = detail::posix_random_access_file; + /// The service that owns the random-access file implementations. using random_access_file_service_type = detail::posix_random_access_file_service; - /// Create the scheduler and services for this backend. + /** Create the scheduler and services for this backend. + + @param ctx The execution context that owns the scheduler. + @param concurrency_hint Hint for the number of threads that + call `run()`. A performance tuning knob; the + thread-safety contract is set separately by + @ref io_context_options::locking. + + @return Reference to the newly created scheduler. + + @throws std::system_error If the backend's infrastructure + could not be created. + */ BOOST_COROSIO_DECL static detail::scheduler& - construct(capy::execution_context&, unsigned concurrency_hint); + construct(capy::execution_context& ctx, unsigned concurrency_hint); }; /// Tag value for selecting the select backend. @@ -190,39 +256,72 @@ class posix_random_access_file_service; } // namespace detail -/// Backend tag for the BSD kqueue I/O multiplexer. +/// Selects the BSD kqueue I/O multiplexer as the backend. struct kqueue_t { - using scheduler_type = detail::kqueue_scheduler; - using tcp_socket_type = detail::kqueue_tcp_socket; - using tcp_service_type = detail::kqueue_tcp_service; - using udp_socket_type = detail::kqueue_udp_socket; - using udp_service_type = detail::kqueue_udp_service; - using tcp_acceptor_type = detail::kqueue_tcp_acceptor; + /// The scheduler that drives the event loop. + using scheduler_type = detail::kqueue_scheduler; + /// The concrete TCP socket type. + using tcp_socket_type = detail::kqueue_tcp_socket; + /// The service that owns the TCP socket implementations. + using tcp_service_type = detail::kqueue_tcp_service; + /// The concrete UDP socket type. + using udp_socket_type = detail::kqueue_udp_socket; + /// The service that owns the UDP socket implementations. + using udp_service_type = detail::kqueue_udp_service; + /// The concrete TCP acceptor type. + using tcp_acceptor_type = detail::kqueue_tcp_acceptor; + /// The service that owns the TCP acceptor implementations. using tcp_acceptor_service_type = detail::kqueue_tcp_acceptor_service; - using local_stream_socket_type = detail::kqueue_local_stream_socket; - using local_stream_service_type = detail::kqueue_local_stream_service; + /// The concrete Unix domain stream socket type. + using local_stream_socket_type = detail::kqueue_local_stream_socket; + /// The service that owns the Unix domain stream implementations. + using local_stream_service_type = detail::kqueue_local_stream_service; + /// The concrete Unix domain stream acceptor type. using local_stream_acceptor_type = detail::kqueue_local_stream_acceptor; + /// The service that owns the Unix domain acceptor implementations. using local_stream_acceptor_service_type = detail::kqueue_local_stream_acceptor_service; - using local_datagram_socket_type = detail::kqueue_local_datagram_socket; + /// The concrete Unix domain datagram socket type. + using local_datagram_socket_type = detail::kqueue_local_datagram_socket; + /// The service that owns the Unix domain datagram implementations. using local_datagram_service_type = detail::kqueue_local_datagram_service; - using signal_type = detail::posix_signal; - using signal_service_type = detail::posix_signal_service; - using resolver_type = detail::posix_resolver; + /// The concrete signal set type. + using signal_type = detail::posix_signal; + /// The service that owns the signal set implementations. + using signal_service_type = detail::posix_signal_service; + /// The concrete name resolver type. + using resolver_type = detail::posix_resolver; + /// The service that owns the resolver implementations. using resolver_service_type = detail::posix_resolver_service; - using stream_file_type = detail::posix_stream_file; + /// The concrete sequential file type. + using stream_file_type = detail::posix_stream_file; + /// The service that owns the sequential file implementations. using stream_file_service_type = detail::posix_stream_file_service; - using random_access_file_type = detail::posix_random_access_file; + /// The concrete random-access file type. + using random_access_file_type = detail::posix_random_access_file; + /// The service that owns the random-access file implementations. using random_access_file_service_type = detail::posix_random_access_file_service; - /// Create the scheduler and services for this backend. + /** Create the scheduler and services for this backend. + + @param ctx The execution context that owns the scheduler. + @param concurrency_hint Hint for the number of threads that + call `run()`. A performance tuning knob; the + thread-safety contract is set separately by + @ref io_context_options::locking. + + @return Reference to the newly created scheduler. + + @throws std::system_error If the backend's infrastructure + could not be created. + */ BOOST_COROSIO_DECL static detail::scheduler& - construct(capy::execution_context&, unsigned concurrency_hint); + construct(capy::execution_context& ctx, unsigned concurrency_hint); }; /// Tag value for selecting the kqueue backend. @@ -259,39 +358,72 @@ class posix_resolver_service; } // namespace detail -/// Backend tag for the Linux io_uring proactor. +/// Selects the Linux io_uring proactor as the backend. struct uring_t { - using scheduler_type = detail::uring_scheduler; - using tcp_socket_type = detail::uring_tcp_socket; - using tcp_service_type = detail::uring_tcp_service; - using udp_socket_type = detail::uring_udp_socket; - using udp_service_type = detail::uring_udp_service; - using tcp_acceptor_type = detail::uring_tcp_acceptor; + /// The scheduler that drives the event loop. + using scheduler_type = detail::uring_scheduler; + /// The concrete TCP socket type. + using tcp_socket_type = detail::uring_tcp_socket; + /// The service that owns the TCP socket implementations. + using tcp_service_type = detail::uring_tcp_service; + /// The concrete UDP socket type. + using udp_socket_type = detail::uring_udp_socket; + /// The service that owns the UDP socket implementations. + using udp_service_type = detail::uring_udp_service; + /// The concrete TCP acceptor type. + using tcp_acceptor_type = detail::uring_tcp_acceptor; + /// The service that owns the TCP acceptor implementations. using tcp_acceptor_service_type = detail::uring_tcp_acceptor_service; - using local_stream_socket_type = detail::uring_local_stream_socket; - using local_stream_service_type = detail::uring_local_stream_service; + /// The concrete Unix domain stream socket type. + using local_stream_socket_type = detail::uring_local_stream_socket; + /// The service that owns the Unix domain stream implementations. + using local_stream_service_type = detail::uring_local_stream_service; + /// The concrete Unix domain stream acceptor type. using local_stream_acceptor_type = detail::uring_local_stream_acceptor; + /// The service that owns the Unix domain acceptor implementations. using local_stream_acceptor_service_type = detail::uring_local_stream_acceptor_service; - using local_datagram_socket_type = detail::uring_local_datagram_socket; + /// The concrete Unix domain datagram socket type. + using local_datagram_socket_type = detail::uring_local_datagram_socket; + /// The service that owns the Unix domain datagram implementations. using local_datagram_service_type = detail::uring_local_datagram_service; - using signal_type = detail::posix_signal; - using signal_service_type = detail::posix_signal_service; - using resolver_type = detail::posix_resolver; + /// The concrete signal set type. + using signal_type = detail::posix_signal; + /// The service that owns the signal set implementations. + using signal_service_type = detail::posix_signal_service; + /// The concrete name resolver type. + using resolver_type = detail::posix_resolver; + /// The service that owns the resolver implementations. using resolver_service_type = detail::posix_resolver_service; - using stream_file_type = detail::uring_stream_file; + /// The concrete sequential file type. + using stream_file_type = detail::uring_stream_file; + /// The service that owns the sequential file implementations. using stream_file_service_type = detail::uring_stream_file_service; - using random_access_file_type = detail::uring_random_access_file; + /// The concrete random-access file type. + using random_access_file_type = detail::uring_random_access_file; + /// The service that owns the random-access file implementations. using random_access_file_service_type = detail::uring_random_access_file_service; - /// Create the scheduler and services for this backend. + /** Create the scheduler and services for this backend. + + @param ctx The execution context that owns the scheduler. + @param concurrency_hint Hint for the number of threads that + call `run()`. A performance tuning knob; the + thread-safety contract is set separately by + @ref io_context_options::locking. + + @return Reference to the newly created scheduler. + + @throws std::system_error If the backend's infrastructure + could not be created. + */ BOOST_COROSIO_DECL static detail::scheduler& - construct(capy::execution_context&, unsigned concurrency_hint); + construct(capy::execution_context& ctx, unsigned concurrency_hint); }; /// Tag value for selecting the io_uring backend. @@ -329,39 +461,56 @@ class win_random_access_file_service; } // namespace detail -/** Backend tag for the Windows I/O Completion Ports multiplexer. +/** Selects the Windows I/O Completion Ports multiplexer as the backend. - Selects the IOCP-based reactor for all I/O services, including - TCP, UDP, Unix domain sockets (AF_UNIX), signals, and name - resolution. + Used for all I/O services, including TCP, UDP, Unix domain + sockets (AF_UNIX), signals, name resolution, and file I/O. */ struct iocp_t { - using scheduler_type = detail::win_scheduler; - using tcp_socket_type = detail::win_tcp_socket; - using tcp_service_type = detail::win_tcp_service; - using tcp_acceptor_type = detail::win_tcp_acceptor; + /// The scheduler that drives the event loop. + using scheduler_type = detail::win_scheduler; + /// The concrete TCP socket type. + using tcp_socket_type = detail::win_tcp_socket; + /// The service that owns the TCP socket implementations. + using tcp_service_type = detail::win_tcp_service; + /// The concrete TCP acceptor type. + using tcp_acceptor_type = detail::win_tcp_acceptor; + /// The service that owns the TCP acceptor implementations. using tcp_acceptor_service_type = detail::win_tcp_acceptor_service; - using udp_socket_type = detail::win_udp_socket; - using udp_service_type = detail::win_udp_service; + /// The concrete UDP socket type. + using udp_socket_type = detail::win_udp_socket; + /// The service that owns the UDP socket implementations. + using udp_service_type = detail::win_udp_service; /// @name Unix domain socket types /// @{ - using local_stream_socket_type = detail::win_local_stream_socket; - using local_stream_service_type = detail::win_local_stream_service; + using local_stream_socket_type = detail::win_local_stream_socket; + /// The service that owns the Unix domain stream implementations. + using local_stream_service_type = detail::win_local_stream_service; + /// The concrete Unix domain stream acceptor type. using local_stream_acceptor_type = detail::win_local_stream_acceptor; + /// The service that owns the Unix domain acceptor implementations. using local_stream_acceptor_service_type = detail::win_local_stream_acceptor_service; /// @} - using signal_type = detail::win_signal; - using signal_service_type = detail::win_signals; - using resolver_type = detail::win_resolver; + /// The concrete signal set type. + using signal_type = detail::win_signal; + /// The service that owns the signal set implementations. + using signal_service_type = detail::win_signals; + /// The concrete name resolver type. + using resolver_type = detail::win_resolver; + /// The service that owns the resolver implementations. using resolver_service_type = detail::win_resolver_service; - using stream_file_type = detail::win_stream_file; + /// The concrete sequential file type. + using stream_file_type = detail::win_stream_file; + /// The service that owns the sequential file implementations. using stream_file_service_type = detail::win_file_service; - using random_access_file_type = detail::win_random_access_file; + /// The concrete random-access file type. + using random_access_file_type = detail::win_random_access_file; + /// The service that owns the random-access file implementations. using random_access_file_service_type = detail::win_random_access_file_service; @@ -369,14 +518,17 @@ struct iocp_t @param ctx The execution context that owns the scheduler. @param concurrency_hint Hint for the number of threads that - will call `run()`; a performance tuning knob. The + call `run()`. A performance tuning knob; the thread-safety contract is set separately by @ref io_context_options::locking. @return Reference to the newly created scheduler. + + @throws std::system_error If the backend's infrastructure + could not be created. */ BOOST_COROSIO_DECL static detail::scheduler& - construct(capy::execution_context&, unsigned concurrency_hint); + construct(capy::execution_context& ctx, unsigned concurrency_hint); }; /// Tag value for selecting the IOCP backend. diff --git a/include/boost/corosio/connect.hpp b/include/boost/corosio/connect.hpp index ac7dce795..d20e62487 100644 --- a/include/boost/corosio/connect.hpp +++ b/include/boost/corosio/connect.hpp @@ -100,20 +100,26 @@ connect(Socket& s, Iter begin, Iter end, ConnectCondition cond); @param s The socket to connect. Must have a `connect(endpoint)` member returning an awaitable, plus `close()` and `is_open()`. - If the socket is already open, it will be closed before the + If the socket is already open, it is closed before the first attempt. @param endpoints A range of candidate endpoints. Taken by value so temporaries (e.g. the `std::vector` returned from `resolver::resolve`) remain alive for the coroutine's lifetime. + Passing an lvalue copies the range; pass an rvalue + (`std::move(endpoints)`) or use the iterator overload + (`connect(s, begin, end)`) to avoid the copy. @return An awaitable completing with `capy::io_result`: - - on success: default error_code and the connected endpoint; + - on success: default `error_code` and the connected endpoint; - on failure of all attempts: the error from the last attempt and a default-constructed endpoint; - on empty range: `std::errc::no_such_device_or_address` and a default-constructed endpoint. + @throws std::bad_alloc if copying an lvalue `endpoints` fails to + allocate. + @note The socket is closed and re-opened before each attempt, so any socket options set by the caller (e.g. `no_delay`, `reuse_address`) are lost. Apply options after this operation @@ -143,8 +149,8 @@ connect(Socket& s, Range endpoints) For each candidate the condition is invoked as `cond(last_ec, ep)` where `last_ec` is the error from the most recent attempt (default-constructed before the first attempt). If - the condition returns `false` the candidate is skipped; otherwise a - connect is attempted. + the condition returns `false`, the candidate is skipped. Otherwise, + a connect is attempted. @param s The socket to connect. See the non-condition overload for requirements. @@ -216,7 +222,7 @@ connect(Socket& s, Range endpoints, ConnectCondition cond) @param end One past the last candidate. @return An awaitable completing with `capy::io_result`: - - on success: default error_code and the iterator of the + - on success: default `error_code` and the iterator of the successful endpoint; - on failure of all attempts: the error from the last attempt and `end`; @@ -241,6 +247,12 @@ connect(Socket& s, Iter begin, Iter end) /** Asynchronously connect a socket by trying each endpoint in an iterator range, filtered by a user-supplied condition. + @par Cancellation + Supports cancellation via the affine awaitable protocol. If a + per-endpoint connect completes with `capy::cond::canceled` the + operation completes immediately with that error and `end`, without + trying further endpoints. + @param s The socket to connect. @param begin The first candidate. @param end One past the last candidate. diff --git a/include/boost/corosio/delay.hpp b/include/boost/corosio/delay.hpp index 5d945c561..52189e6a0 100644 --- a/include/boost/corosio/delay.hpp +++ b/include/boost/corosio/delay.hpp @@ -75,9 +75,7 @@ emplace_delay_timer(std::optional& t, capy::execution_context& ctx) } // namespace detail -/** IoAwaitable returned by @ref delay. - - Suspends the calling coroutine until the deadline elapses or +/** Suspends the calling coroutine until the deadline elapses or the environment's stop token is activated, whichever comes first. A deadline already elapsed at suspension, or a stop token already active, resumes the coroutine inline, without @@ -85,25 +83,23 @@ emplace_delay_timer(std::optional& t, capy::execution_context& ctx) coroutine resumes through the executor once the timer fires or a mid-wait cancellation arrives. - Not intended to be named directly; use the @ref delay factory + Not intended to be named directly. Use the @ref delay factory overloads instead. - @par Preconditions - The awaiting coroutine's executor must belong to an - `io_context`. Any other execution context terminates with a - diagnostic, because silently running without a timer would - drop the requested delay. + @pre The awaiting coroutine's executor must belong to an + `io_context`. Any other execution context terminates with a + diagnostic, because silently running without a timer would + drop the requested delay. @par Cancellation If stop is already requested before suspension, the coroutine resumes immediately with `error::canceled`. If stop is requested while suspended, the pending wait is cancelled and - the coroutine resumes with `error::canceled`. Requesting stop - from another thread while the io_context runs in - single_threaded mode (auto-enabled at concurrency_hint == 1) - is not permitted by io_context's threading rules; - cross-thread cancellation requires a multi-threaded-capable - context. + the coroutine resumes with `error::canceled`. Requesting stop from + another thread while the `io_context` runs in `single_threaded` mode is + not permitted by `io_context`'s threading rules. That mode is + auto-enabled at `concurrency_hint` == 1. Cross-thread cancellation + requires a multi-threaded-capable context. @see delay */ @@ -133,13 +129,16 @@ class delay_awaitable { } - /// Construct by transferring state from `other`. // Only moved before await_suspend; wait_ is engaged after. + /// Construct by transferring state from `other`. delay_awaitable(delay_awaitable&&) = default; - delay_awaitable(delay_awaitable const&) = delete; + /// Copy construction is disabled; an awaitable owns its timer. + delay_awaitable(delay_awaitable const&) = delete; + /// Copy assignment is disabled; an awaitable owns its timer. delay_awaitable& operator=(delay_awaitable const&) = delete; - delay_awaitable& operator=(delay_awaitable&&) = delete; + /// Move assignment is disabled; an awaitable is moved only before it is awaited. + delay_awaitable& operator=(delay_awaitable&&) = delete; /// Return false unconditionally; see await_suspend. // The elapsed-deadline fast path must run after the stop-token @@ -149,7 +148,15 @@ class delay_awaitable return false; } - /// Resume inline if stopped or elapsed; else wait on a timer. + /** Resume inline if stopped or elapsed; else wait on a timer. + + @param h Coroutine handle to resume on completion. + @param env The I/O environment, carrying the executor, stop token + and frame allocator. + + @return The handle to resume immediately, or `noop_coroutine()` when + the wait was published to the timer service. + */ std::coroutine_handle<> await_suspend(std::coroutine_handle<> h, capy::io_env const* env) { @@ -187,24 +194,23 @@ class delay_awaitable } }; -/** IoAwaitable returned by the clock overloads of @ref delay. - - Suspends the calling coroutine until `Clock::now()` reaches the - deadline or the environment's stop token is activated. The wait - is a sequence of steady-clock timer waits: after each expiry the - clock is re-read and, if the deadline is unreached, the same - frame-embedded waiter is re-published for the next - `Traits::to_wait_duration` cap — without resuming the coroutine - and without allocating. +/** Suspends the calling coroutine until `Clock::now()` reaches the + deadline or the environment's stop token is activated. The wait is a + sequence of steady-clock timer waits. After each expiry the clock is + re-read. If the deadline is unreached, the same frame-embedded + waiter is re-published for the next `Traits::to_wait_duration` cap. + That re-publish neither resumes the coroutine nor allocates. - Not intended to be named directly; use the @ref delay factory + Not intended to be named directly. Use the @ref delay factory overloads instead. - @par Preconditions - The awaiting coroutine's executor must belong to an - `io_context`. Any other execution context terminates with a - diagnostic, because silently running without a timer would - drop the requested delay. + @tparam Clock The clock the deadline is expressed in. + @tparam Traits The wait-traits policy bounding each steady-clock wait. + + @pre The awaiting coroutine's executor must belong to an + `io_context`. Any other execution context terminates with a + diagnostic, because silently running without a timer would + drop the requested delay. @par Cancellation Identical to @ref delay_awaitable: stop already requested @@ -256,16 +262,19 @@ class clock_delay_awaitable { } - /// Construct by transferring the deadline from `other`. // Only moved before await_suspend; w_ is quiescent until then. + /// Construct by transferring the deadline from `other`. clock_delay_awaitable(clock_delay_awaitable&& other) noexcept : deadline_(other.deadline_) { } - clock_delay_awaitable(clock_delay_awaitable const&) = delete; + /// Copy construction is disabled; an awaitable owns its timer. + clock_delay_awaitable(clock_delay_awaitable const&) = delete; + /// Copy assignment is disabled; an awaitable owns its timer. clock_delay_awaitable& operator=(clock_delay_awaitable const&) = delete; - clock_delay_awaitable& operator=(clock_delay_awaitable&&) = delete; + /// Move assignment is disabled; an awaitable is moved only before it is awaited. + clock_delay_awaitable& operator=(clock_delay_awaitable&&) = delete; /// Return false unconditionally; see await_suspend. // The elapsed-deadline fast path must run after the stop-token @@ -275,7 +284,15 @@ class clock_delay_awaitable return false; } - /// Resume inline if stopped or reached; else wait on a timer. + /** Resume inline if stopped or reached; else wait on a timer. + + @param h Coroutine handle to resume on completion. + @param env The I/O environment, carrying the executor, stop token + and frame allocator. + + @return The handle to resume immediately, or `noop_coroutine()` when + the wait was published to the timer service. + */ std::coroutine_handle<> await_suspend(std::coroutine_handle<> h, capy::io_env const* env) { @@ -356,14 +373,14 @@ delay(std::chrono::steady_clock::time_point tp) noexcept observation of `Clock::now() >= tp`, or earlier if the environment's stop token is activated. The wait is one or more bounded steady-clock waits, re-reading `Clock::now()` after - each; `Traits::to_wait_duration` bounds each one. With the - default @ref wait_traits a single full-length wait is used, so - an adjustment of `Clock` mid-wait is observed only at natural - wakeup; supply capping traits to bound that latency. Time + each. `Traits::to_wait_duration` bounds each one. With the default + @ref wait_traits a single full-length wait is used. An adjustment of + `Clock` mid-wait is therefore observed only at natural wakeup. + Supply capping traits to bound that latency. Time points already reached complete synchronously. @note `Clock::now()` and `Traits::to_wait_duration` are invoked - on the io_context's run thread and must not throw or block. + on the `io_context`'s run thread and must not throw or block. @par Example @par !example system_clock_deadline @@ -371,6 +388,11 @@ delay(std::chrono::steady_clock::time_point tp) noexcept @tparam Traits The wait-traits policy; `void` selects @ref wait_traits. + @tparam Clock The clock type. This overload does not participate + when `Clock` is `std::chrono::steady_clock`. The dedicated + @ref delay overload taking a `steady_clock::time_point` handles + that case. + @param tp The time point to wait until. @return A @ref clock_delay_awaitable yielding `io_result<>`. diff --git a/include/boost/corosio/detail/buffer_param.hpp b/include/boost/corosio/detail/buffer_param.hpp index 4ba5e5faf..397b60cfb 100644 --- a/include/boost/corosio/detail/buffer_param.hpp +++ b/include/boost/corosio/detail/buffer_param.hpp @@ -18,7 +18,7 @@ namespace boost::corosio { -/** A type-erased buffer sequence for I/O system call boundaries. +/** Erases a buffer sequence to the pointer and length a system call takes. This class enables I/O objects to accept any buffer sequence type across a virtual function boundary, while preserving the caller's diff --git a/include/boost/corosio/detail/native_handle.hpp b/include/boost/corosio/detail/native_handle.hpp index 2e048ee6f..0d4305cf8 100644 --- a/include/boost/corosio/detail/native_handle.hpp +++ b/include/boost/corosio/detail/native_handle.hpp @@ -17,10 +17,11 @@ namespace boost::corosio { -/// Represent a platform-specific socket descriptor (`int` on POSIX, `SOCKET` on Windows). #if BOOST_COROSIO_HAS_IOCP && !defined(BOOST_COROSIO_MRDOCS) +/// Represent a platform-specific socket descriptor (`int` on POSIX, `SOCKET` on Windows). using native_handle_type = std::uintptr_t; #else +/// Represent a platform-specific socket descriptor (`int` on POSIX, `SOCKET` on Windows). using native_handle_type = int; #endif diff --git a/include/boost/corosio/detail/thread_pool.hpp b/include/boost/corosio/detail/thread_pool.hpp index 2eb6bbc35..4d11635ca 100644 --- a/include/boost/corosio/detail/thread_pool.hpp +++ b/include/boost/corosio/detail/thread_pool.hpp @@ -343,12 +343,11 @@ class thread_pool_ref /** Return the pool, creating it if this is the first use. - @par Preconditions - For the throwing clauses below to be unreachable, the owning - context must already hold the pool service. Every `io_context` - constructor installs it — what waits for a first post is the - service's workers, not the service — so the creating branch is - reached only by a scheduler driven without one. + @pre For the throwing clauses below to be unreachable, the owning + context must already hold the pool service. Every `io_context` + constructor installs it — what waits for a first post is the + service's workers, not the service — so the creating branch is + reached only by a scheduler driven without one. @par Exception Safety Strong guarantee. diff --git a/include/boost/corosio/detail/timer.hpp b/include/boost/corosio/detail/timer.hpp index 33b0eed2d..332943fac 100644 --- a/include/boost/corosio/detail/timer.hpp +++ b/include/boost/corosio/detail/timer.hpp @@ -147,9 +147,8 @@ class BOOST_COROSIO_DECL timer : public io_object heap, completes by posting the continuation without publishing. - @par Preconditions - @p w is fully initialized, and its storage (the awaitable - on the suspended coroutine's frame) outlives the wait. + @pre @p w is fully initialized, and its storage (the awaitable + on the suspended coroutine's frame) outlives the wait. @param w The waiter to publish. */ @@ -167,8 +166,7 @@ class BOOST_COROSIO_DECL timer : public io_object embedded op; hook-driven waits must observe every completion through the op, where the re-arm hook runs. - @par Preconditions - Same as `wait`. + @pre Same as `wait`. @param w The waiter to publish. */ @@ -234,8 +232,7 @@ class BOOST_COROSIO_DECL timer : public io_object /** Set the timer's expiry time as an absolute time. - @par Preconditions - No wait is published on this timer. + @pre No wait is published on this timer. @param t The expiry time to be used for the timer. */ @@ -250,8 +247,7 @@ class BOOST_COROSIO_DECL timer : public io_object /** Set the timer's expiry time relative to now. - @par Preconditions - No wait is published on this timer. + @pre No wait is published on this timer. @param d The expiry time relative to now. */ @@ -311,9 +307,8 @@ class BOOST_COROSIO_DECL timer : public io_object re-publish the waiter to continue a logical wait across several timer expirations. - @par Preconditions - @p w is fully initialized ( handle, executor, stop token, - hook fields ) and its storage outlives the wait. + @pre @p w is fully initialized ( handle, executor, stop token, + hook fields ) and its storage outlives the wait. @param w The waiter to publish. @@ -329,9 +324,8 @@ class BOOST_COROSIO_DECL timer : public io_object where the waiter has been popped from the service but not yet resumed. - @par Preconditions - The timer has no other waiters — this is what makes the - unlocked expiry write race-free. + @pre The timer has no other waiters — this is what makes the + unlocked expiry write race-free. Re-publication needs heap capacity and can fail under allocation pressure. On failure the waiter is left exactly as @@ -480,8 +474,7 @@ struct BOOST_COROSIO_SYMBOL_VISIBLE waiter_node /** Arm the stop callback. - @par Preconditions - `token_` is set. + @pre `token_` is set. */ void arm_stop_cb() { diff --git a/include/boost/corosio/endpoint.hpp b/include/boost/corosio/endpoint.hpp index c722d6808..243440a2a 100644 --- a/include/boost/corosio/endpoint.hpp +++ b/include/boost/corosio/endpoint.hpp @@ -24,11 +24,11 @@ namespace boost::corosio { -/** An IP endpoint (address + port) supporting both IPv4 and IPv6. +/** Pairs an IP address with a port for either IPv4 or IPv6. This class represents an endpoint for IP communication, consisting of an IP address of either family and a port number. - Endpoints are used to specify connection targets and bind addresses. + Use an endpoint to specify a connection target or bind address. @par Thread Safety Distinct objects: Safe.@n @@ -43,9 +43,7 @@ class endpoint std::uint16_t port_ = 0; public: - /** Default constructor. - - Creates an endpoint with the IPv4 any address (0.0.0.0) and port 0. + /** Creates an endpoint with the IPv4 any address (0.0.0.0) and port 0. */ endpoint() noexcept = default; @@ -153,8 +151,8 @@ class endpoint Establishes a strict total ordering consistent with @ref operator==: equal endpoints compare equivalent. - Endpoints are ordered first by address family (IPv4 - before IPv6), then by address value, then by port. This + `operator<=>` orders endpoints first by address family + (IPv4 before IPv6), then by address value, then by port. This makes `endpoint` usable as a key in ordered containers such as `std::map` and `std::set`. @@ -169,9 +167,9 @@ class endpoint } }; -/** Endpoint format detection result. +/** Identifies which of the four supported endpoint string formats a string is in. - Used internally by make_endpoint to determine + Used internally by `make_endpoint` to determine the format of an endpoint string. */ enum class endpoint_format diff --git a/include/boost/corosio/family.hpp b/include/boost/corosio/family.hpp index e36d386db..ef812f01f 100644 --- a/include/boost/corosio/family.hpp +++ b/include/boost/corosio/family.hpp @@ -15,14 +15,14 @@ namespace boost::corosio { /** The address family of an IP socket or address. This is the portable spelling of the address family throughout - the public API: `open()` creates a socket in a given family, + the public API. `open()` creates a socket in a given family, `ip_address::family()` reports the family of an address, and socket options receive it when they are applied. The native `AF_*` constants appear only at the native boundary. A socket's family is fixed when it is opened, but socket option types are constructed without a socket in hand. Options therefore - receive the family at application time: `set_option` and + receive the family at application time. `set_option` and `get_option` pass the socket's family to the option's accessors, and family-sensitive options select the matching protocol level and wire representation. Options that mean the same thing in diff --git a/include/boost/corosio/file_base.hpp b/include/boost/corosio/file_base.hpp index 87dc0e972..ddd8b38b8 100644 --- a/include/boost/corosio/file_base.hpp +++ b/include/boost/corosio/file_base.hpp @@ -14,9 +14,7 @@ namespace boost::corosio { -/** Common definitions for file I/O objects. - - Provides open flags and seek origin constants shared +/** Groups the flag and seek-origin constants shared by @ref stream_file and @ref random_access_file. */ struct file_base diff --git a/include/boost/corosio/io/io_object.hpp b/include/boost/corosio/io/io_object.hpp index a5b96a19e..49a251b26 100644 --- a/include/boost/corosio/io/io_object.hpp +++ b/include/boost/corosio/io/io_object.hpp @@ -19,7 +19,9 @@ namespace boost::corosio { -/** Base class for platform I/O objects. +/** Owns the platform-specific handle and execution context that a derived + socket, timer, signal handler, or acceptor type uses to dispatch + operations. Provides common infrastructure for I/O objects that wrap kernel resources (sockets, timers, signal handlers, acceptors). Derived @@ -46,39 +48,35 @@ class BOOST_COROSIO_DECL io_object public: class handle; - /** Base interface for platform I/O implementations. - - Derived classes provide platform-specific operation dispatch. + /** Derived types dispatch platform-specific I/O operations through it. */ struct implementation { + /// Destroy the implementation; called only through @ref io_service. virtual ~implementation() = default; }; - /** Service interface for I/O object lifecycle management. - - Platform backends implement this interface to manage the - creation, closing, and destruction of I/O object - implementations. + /** Constructs, closes, and destroys platform implementations on + behalf of an I/O object. Platform backends implement this + interface. */ struct BOOST_COROSIO_DECL io_service { + /// Destroy the service; the execution context outlives it. virtual ~io_service() = default; /// Construct a new implementation instance. virtual implementation* construct() = 0; /// Destroy the implementation, closing kernel resources and freeing memory. - virtual void destroy(implementation*) = 0; + virtual void destroy(implementation* impl) = 0; /// Close the I/O object, releasing kernel resources without deallocating. - virtual void close(handle&) {} + virtual void close([[maybe_unused]] handle& h) {} }; - /** RAII wrapper for I/O object implementation lifetime. - - Manages ownership of the platform-specific implementation, - automatically destroying it when the handle goes out of scope. + /** Owns a platform-specific I/O implementation and destroys it + when the handle goes out of scope. */ class handle { @@ -133,7 +131,9 @@ class BOOST_COROSIO_DECL io_object return *this; } - handle(handle const&) = delete; + /// Copy construction is disabled; the implementation is uniquely owned. + handle(handle const&) = delete; + /// Copy assignment is disabled; the implementation is uniquely owned. handle& operator=(handle const&) = delete; /// Return true if the handle owns an implementation. @@ -182,6 +182,7 @@ class BOOST_COROSIO_DECL io_object } protected: + /// Destroy the object; protected, so only a derived type destroys one. virtual ~io_object() = default; /// Default construct for virtual base initialization. @@ -220,7 +221,9 @@ class BOOST_COROSIO_DECL io_object return *this; } - io_object(io_object const&) = delete; + /// Copy construction is disabled; the handle is uniquely owned. + io_object(io_object const&) = delete; + /// Copy assignment is disabled; the handle is uniquely owned. io_object& operator=(io_object const&) = delete; /// The platform I/O handle owned by this object. diff --git a/include/boost/corosio/io/io_read_stream.hpp b/include/boost/corosio/io/io_read_stream.hpp index a41d7825e..c77e8238d 100644 --- a/include/boost/corosio/io/io_read_stream.hpp +++ b/include/boost/corosio/io/io_read_stream.hpp @@ -25,7 +25,7 @@ namespace boost::corosio { -/** Abstract base for streams that support async reads. +/** Reads bytes from a stream asynchronously. Provides the `read_some` operation via a pure virtual `do_read_some` dispatch point. Concrete classes override @@ -49,8 +49,8 @@ class BOOST_COROSIO_DECL io_read_stream : virtual public io_object struct read_some_awaitable : detail::bytes_op_base> { - io_read_stream& ios_; - MutableBufferSequence buffers_; + private: + friend io_read_stream; read_some_awaitable( io_read_stream& ios, MutableBufferSequence buffers) noexcept @@ -59,6 +59,11 @@ class BOOST_COROSIO_DECL io_read_stream : virtual public io_object { } + friend detail::bytes_op_base< + read_some_awaitable>; + io_read_stream& ios_; + MutableBufferSequence buffers_; + std::coroutine_handle<> dispatch(std::coroutine_handle<> h, capy::executor_ref ex) const { @@ -79,19 +84,24 @@ class BOOST_COROSIO_DECL io_read_stream : virtual public io_object @return Coroutine handle to resume immediately. */ virtual std::coroutine_handle<> do_read_some( - std::coroutine_handle<>, - capy::executor_ref, - buffer_param, - std::stop_token, - std::error_code*, - std::size_t*) = 0; - + std::coroutine_handle<> h, + capy::executor_ref ex, + buffer_param buffers, + std::stop_token token, + std::error_code* ec, + std::size_t* bytes) = 0; + + /// Default construct; the handle is supplied through @ref io_object. io_read_stream() noexcept = default; - io_read_stream(io_read_stream&&) noexcept = default; + /// Move construct; the handle moves with @ref io_object. + io_read_stream(io_read_stream&&) noexcept = default; + /// Move assignment is disabled; reseating a live stream is not supported. io_read_stream& operator=(io_read_stream&&) noexcept = delete; - io_read_stream(io_read_stream const&) = delete; - io_read_stream& operator=(io_read_stream const&) = delete; + /// Copy construction is disabled; the handle is uniquely owned. + io_read_stream(io_read_stream const&) = delete; + /// Copy assignment is disabled; the handle is uniquely owned. + io_read_stream& operator=(io_read_stream const&) = delete; public: /** Asynchronously read data from the stream. diff --git a/include/boost/corosio/io/io_signal_set.hpp b/include/boost/corosio/io/io_signal_set.hpp index 4eb0f0875..85e72e3c2 100644 --- a/include/boost/corosio/io/io_signal_set.hpp +++ b/include/boost/corosio/io/io_signal_set.hpp @@ -24,7 +24,7 @@ namespace boost::corosio { -/** Abstract base for asynchronous signal sets. +/** Delivers a registered signal to the waiting coroutine. Provides the common signal set interface: `wait` and `cancel`. Concrete classes like @ref signal_set add signal registration @@ -40,6 +40,10 @@ class BOOST_COROSIO_DECL io_signal_set : public io_object { struct wait_awaitable : detail::value_op_base { + private: + friend io_signal_set; + friend detail::value_op_base; + io_signal_set& s_; explicit wait_awaitable(io_signal_set& s) noexcept : s_(s) {} @@ -123,6 +127,10 @@ class BOOST_COROSIO_DECL io_signal_set : public io_object /** Dispatch cancel to the concrete implementation. */ virtual void do_cancel() noexcept = 0; + /** Adopt an existing handle. + + @param h The handle the signal set takes ownership of. + */ explicit io_signal_set(handle h) noexcept : io_object(std::move(h)) {} /// Move construct. @@ -138,7 +146,9 @@ class BOOST_COROSIO_DECL io_signal_set : public io_object return *this; } - io_signal_set(io_signal_set const&) = delete; + /// Copy construction is disabled; the handle is uniquely owned. + io_signal_set(io_signal_set const&) = delete; + /// Copy assignment is disabled; the handle is uniquely owned. io_signal_set& operator=(io_signal_set const&) = delete; private: diff --git a/include/boost/corosio/io/io_stream.hpp b/include/boost/corosio/io/io_stream.hpp index 060e7ba4f..e2eea98f0 100644 --- a/include/boost/corosio/io/io_stream.hpp +++ b/include/boost/corosio/io/io_stream.hpp @@ -25,13 +25,13 @@ namespace boost::corosio { -/** Platform stream with read/write operations. +/** Reads and writes bytes through a platform I/O backend. Combines @ref io_read_stream and @ref io_write_stream into a single bidirectional stream. The `read_some` and `write_some` - operations are inherited from the base classes and dispatch - through `do_read_some` / `do_write_some`, which this class - implements by forwarding to the platform `implementation`. + operations are inherited from the base classes and dispatch through + `do_read_some` / `do_write_some`. This class implements those by + forwarding to the platform `implementation`. The implementation hierarchy stays linear (no diamond): `io_object::implementation` -> `io_stream::implementation` @@ -39,8 +39,8 @@ namespace boost::corosio { @par Semantics Concrete classes wrap direct platform I/O completed by the kernel. - Functions taking `io_stream&` signal "platform implementation - required" - use this when you need actual kernel I/O rather than + Functions taking `io_stream&` signal that platform implementation + is required. Use this when you need actual kernel I/O rather than a mock or test double. For generic stream algorithms that work with test mocks, @@ -61,7 +61,8 @@ class BOOST_COROSIO_DECL io_stream , public io_write_stream { public: - /** Platform-specific stream implementation interface. + /** Declares the read and write operations a platform backend + must implement. Derived classes implement this interface to provide kernel-level read and write operations for each supported platform (IOCP, @@ -69,32 +70,63 @@ class BOOST_COROSIO_DECL io_stream */ struct implementation : io_object::implementation { - /// Initiate platform read operation. + /** Initiate platform read operation. + + @param h Coroutine handle to resume on completion. + @param ex Executor for dispatching the completion. + @param buffers Target buffer sequence. + @param token Stop token for cancellation. + @param ec Output error code. + @param bytes Output bytes transferred. + + @return Coroutine handle to resume immediately. + */ virtual std::coroutine_handle<> read_some( - std::coroutine_handle<>, - capy::executor_ref, - buffer_param, - std::stop_token, - std::error_code*, - std::size_t*) = 0; - - /// Initiate platform write operation. + std::coroutine_handle<> h, + capy::executor_ref ex, + buffer_param buffers, + std::stop_token token, + std::error_code* ec, + std::size_t* bytes) = 0; + + /** Initiate platform write operation. + + @param h Coroutine handle to resume on completion. + @param ex Executor for dispatching the completion. + @param buffers Source buffer sequence. + @param token Stop token for cancellation. + @param ec Output error code. + @param bytes Output bytes transferred. + + @return Coroutine handle to resume immediately. + */ virtual std::coroutine_handle<> write_some( - std::coroutine_handle<>, - capy::executor_ref, - buffer_param, - std::stop_token, - std::error_code*, - std::size_t*) = 0; + std::coroutine_handle<> h, + capy::executor_ref ex, + buffer_param buffers, + std::stop_token token, + std::error_code* ec, + std::size_t* bytes) = 0; }; protected: + /// Default construct; the handle is supplied through @ref io_object. io_stream() noexcept = default; /// Construct stream from a handle. explicit io_stream(handle h) noexcept : io_object(std::move(h)) {} - /// Dispatch read through implementation vtable. + /** Dispatch read through implementation vtable. + + @param h Coroutine handle to resume on completion. + @param ex Executor for dispatching the completion. + @param buffers Target buffer sequence. + @param token Stop token for cancellation. + @param ec Output error code. + @param bytes Output bytes transferred. + + @return Coroutine handle to resume immediately. + */ std::coroutine_handle<> do_read_some( std::coroutine_handle<> h, capy::executor_ref ex, @@ -106,7 +138,17 @@ class BOOST_COROSIO_DECL io_stream return get().read_some(h, ex, buffers, std::move(token), ec, bytes); } - /// Dispatch write through implementation vtable. + /** Dispatch write through implementation vtable. + + @param h Coroutine handle to resume on completion. + @param ex Executor for dispatching the completion. + @param buffers Source buffer sequence. + @param token Stop token for cancellation. + @param ec Output error code. + @param bytes Output bytes transferred. + + @return Coroutine handle to resume immediately. + */ std::coroutine_handle<> do_write_some( std::coroutine_handle<> h, capy::executor_ref ex, diff --git a/include/boost/corosio/io/io_write_stream.hpp b/include/boost/corosio/io/io_write_stream.hpp index 34da5871b..33dcdc159 100644 --- a/include/boost/corosio/io/io_write_stream.hpp +++ b/include/boost/corosio/io/io_write_stream.hpp @@ -25,7 +25,7 @@ namespace boost::corosio { -/** Abstract base for streams that support async writes. +/** Writes bytes to a stream asynchronously. Provides the `write_some` operation via a pure virtual `do_write_some` dispatch point. Concrete classes override @@ -49,8 +49,8 @@ class BOOST_COROSIO_DECL io_write_stream : virtual public io_object struct write_some_awaitable : detail::bytes_op_base> { - io_write_stream& ios_; - ConstBufferSequence buffers_; + private: + friend io_write_stream; write_some_awaitable( io_write_stream& ios, ConstBufferSequence buffers) noexcept @@ -59,6 +59,10 @@ class BOOST_COROSIO_DECL io_write_stream : virtual public io_object { } + friend detail::bytes_op_base>; + io_write_stream& ios_; + ConstBufferSequence buffers_; + std::coroutine_handle<> dispatch(std::coroutine_handle<> h, capy::executor_ref ex) const { @@ -79,19 +83,24 @@ class BOOST_COROSIO_DECL io_write_stream : virtual public io_object @return Coroutine handle to resume immediately. */ virtual std::coroutine_handle<> do_write_some( - std::coroutine_handle<>, - capy::executor_ref, - buffer_param, - std::stop_token, - std::error_code*, - std::size_t*) = 0; - + std::coroutine_handle<> h, + capy::executor_ref ex, + buffer_param buffers, + std::stop_token token, + std::error_code* ec, + std::size_t* bytes) = 0; + + /// Default construct; the handle is supplied through @ref io_object. io_write_stream() noexcept = default; - io_write_stream(io_write_stream&&) noexcept = default; + /// Move construct; the handle moves with @ref io_object. + io_write_stream(io_write_stream&&) noexcept = default; + /// Move assignment is disabled; reseating a live stream is not supported. io_write_stream& operator=(io_write_stream&&) noexcept = delete; - io_write_stream(io_write_stream const&) = delete; - io_write_stream& operator=(io_write_stream const&) = delete; + /// Copy construction is disabled; the handle is uniquely owned. + io_write_stream(io_write_stream const&) = delete; + /// Copy assignment is disabled; the handle is uniquely owned. + io_write_stream& operator=(io_write_stream const&) = delete; public: /** Asynchronously write data to the stream. diff --git a/include/boost/corosio/io_context.hpp b/include/boost/corosio/io_context.hpp index ec2cc2b7a..5211201cb 100644 --- a/include/boost/corosio/io_context.hpp +++ b/include/boost/corosio/io_context.hpp @@ -26,14 +26,14 @@ namespace boost::corosio { -/** Locking-safety tier for an @ref io_context. +/** Selects which internal locks the scheduler and reactor elide, + trading thread-safety guarantees for reduced synchronization + overhead. - Selects which internal locks the scheduler and reactor elide, trading - thread-safety guarantees for reduced synchronization overhead. This is - the analog of Boost.Asio's `SAFE` / `UNSAFE_IO` / `UNSAFE` concurrency - hint constants. The tier is chosen explicitly, not derived from the - `concurrency_hint`. (The reverse does apply: a lockless tier reduces the - effective hint used for performance tuning to 1.) + This is the analog of Boost.Asio's `SAFE` / `UNSAFE_IO` / `UNSAFE` + concurrency hint constants. The tier is chosen explicitly, not derived + from the `concurrency_hint`. (The reverse does apply: a lockless tier + reduces the effective hint used for performance tuning to 1.) @see io_context_options::locking */ @@ -44,10 +44,10 @@ enum class locking_mode safe, /** Disable only the per-descriptor I/O locks; keep scheduler locking. - Equivalent to Boost.Asio's `UNSAFE_IO`. The context must be run - and driven by a single thread, but resolver and POSIX file - services remain available (they rely on scheduler locking, which - stays on). */ + Equivalent to Boost.Asio's `UNSAFE_IO`. A single thread must run + and drive the context. Resolver and POSIX file services remain + available, because they rely on scheduler locking, which stays + on. */ unsafe_io, /** Disable all locking (fully lockless). Equivalent to Boost.Asio's @@ -62,7 +62,7 @@ enum class locking_mode unsafe }; -/** Runtime tuning options for @ref io_context. +/** Configures scheduler and reactor tuning for an @ref io_context. All fields have defaults that match the library's built-in values, so constructing a default `io_context_options` produces @@ -82,7 +82,7 @@ struct io_context_options Controls the buffer size passed to `epoll_wait()` or `kevent()`. Larger values reduce syscall frequency under - high load; smaller values improve fairness between + high load. Smaller values improve fairness between connections. Ignored on IOCP and select backends. */ unsigned max_events_per_poll = 128; @@ -94,10 +94,10 @@ struct io_context_options re-queue. Applies to reactor backends only. @note Constructing an `io_context` with `concurrency_hint > 1` - and all three budget fields at their defaults overrides - them to disable inline completion (post-everything mode), - since multi-thread workloads benefit from cross-thread - work-stealing. Setting any budget field to a non-default + and all three budget fields at their defaults overrides them to + disable inline completion, giving post-everything mode. + Multi-thread workloads benefit from cross-thread work-stealing. + Setting any budget field to a non-default value disables the override. */ unsigned inline_budget_initial = 2; @@ -134,8 +134,8 @@ struct io_context_options /** Enable IORING_SETUP_SQPOLL on the io_uring backend. With SQPOLL, the kernel forks a thread that busy-polls the - submission ring; submission becomes a userspace-only memory - store, eliminating the io_uring_enter syscall on the submit + submission ring. Submission becomes a userspace-only memory + store, which eliminates the `io_uring_enter` syscall on the submit path. Most useful for sustained traffic. Idle thread parks after `sq_thread_idle_ms` of no activity. @@ -148,7 +148,7 @@ struct io_context_options /** SQ-poll idle timeout in milliseconds. After this many ms of no submissions, the kernel polling - thread sleeps; next submit re-wakes it via SQ_WAKEUP. 0 + thread sleeps. The next submit re-wakes it via SQ_WAKEUP. 0 means use the kernel default (1ms). Recommended for bursty workloads: 100-1000ms (avoids park/unpark thrash). @@ -183,9 +183,9 @@ effective_concurrency_hint( } } // namespace detail -/** An I/O context for running asynchronous operations. +/** Runs asynchronous operations and owns the I/O backend that drives them. - The io_context provides an execution environment for async + The `io_context` provides an execution environment for async operations. It maintains a queue of pending work items and processes them when `run()` is called. @@ -202,24 +202,23 @@ effective_concurrency_hint( @par Example @par !example construct - @par Preconditions - The context must outlive every operation posted or dispatched - through its executor, and no thread may be executing a run - variant when the context is destroyed. Posting to the context - concurrently with, or after, its destruction is undefined - behavior. The safe teardown pattern is to stop submitting new - work, let every `run()` call return (each returns once no - outstanding work remains), and join the threads that ran the - loop before destroying the context. Work launched with - `capy::run` / `capy::run_async` is work-tracked, so a normal - `run()` completion already waits for it. + @pre The context must outlive every operation posted or dispatched + through its executor. No thread may be executing a run variant when + the context is destroyed. Posting to the context + concurrently with, or after, its destruction is undefined + behavior. For a safe teardown, first stop submitting new work. + Then let every `run()` call return; each returns once no + outstanding work remains. Finally join the threads that ran the + loop. Only then destroy the context. Work started with + `capy::run` / `capy::run_async` is work-tracked, so a normal + `run()` completion already waits for it. @par Exception Safety - A context that constructs is usable. The infrastructure its - backend needs — the completion port, the ring, the reactor's - wakeup channel — is created during construction, so a system that - refuses it throws from the constructor rather than from the first - operation, and the failed construction leaves nothing open. + A context that constructs is usable. The infrastructure its backend + needs — the completion port, the ring, the reactor's wakeup channel + — is created during construction. A system that refuses it therefore + throws from the constructor rather than from the first operation. + The failed construction leaves nothing open. @par Thread Safety Distinct objects: Safe.@n @@ -236,7 +235,7 @@ class BOOST_COROSIO_DECL io_context : public capy::execution_context /** Create the blocking-I/O thread pool, apply runtime tuning to the scheduler and finish bringing the backend up. The tail of every - options constructor: the backend infrastructure whose setup reads + options constructor. The backend infrastructure whose setup reads these options is created here, so a failure to create it throws from the constructor. */ void apply_options_post_( @@ -244,8 +243,8 @@ class BOOST_COROSIO_DECL io_context : public capy::execution_context /** Create the blocking-I/O thread pool and apply only the decomposed threading configuration (locking tiers), then finish bringing the - backend up. The tail of every plain constructor, which — unlike - the options constructors — deliberately leaves the reactor budget + backend up. The tail of every plain constructor. Unlike the + options constructors, it deliberately leaves the reactor budget at its defaults rather than engaging the multi-thread post-everything heuristic. */ void apply_threading_(io_context_options const& opts); @@ -254,7 +253,8 @@ class BOOST_COROSIO_DECL io_context : public capy::execution_context detail::scheduler* sched_; public: - /** The executor type for this context. */ + /** Dispatches and posts work to this context; see the + executor_type definition below. */ class executor_type; /** Construct with default concurrency and platform backend. @@ -272,7 +272,7 @@ class BOOST_COROSIO_DECL io_context : public capy::execution_context /** Construct with a concurrency hint and platform backend. @param concurrency_hint Hint for the number of threads - that will call `run()`. + that calls `run()`. @throws std::system_error If the backend's infrastructure could not be created. @@ -284,7 +284,7 @@ class BOOST_COROSIO_DECL io_context : public capy::execution_context @param opts Runtime options controlling scheduler and service behavior. @param concurrency_hint Hint for the number of threads - that will call `run()`. + that calls `run()`. @throws std::invalid_argument If `opts.thread_pool_size` is less than 1 (POSIX). @@ -298,10 +298,14 @@ class BOOST_COROSIO_DECL io_context : public capy::execution_context /** Construct with an explicit backend tag. + @tparam Backend A backend tag type that provides a static + `construct(capy::execution_context&, unsigned)` factory + used to build the scheduler. + @param backend The backend tag value selecting the I/O multiplexer (e.g. `corosio::epoll`). @param concurrency_hint Hint for the number of threads - that will call `run()`. + that calls `run()`. @throws std::system_error If the backend's infrastructure could not be created. @@ -322,12 +326,16 @@ class BOOST_COROSIO_DECL io_context : public capy::execution_context /** Construct with an explicit backend tag and runtime options. + @tparam Backend A backend tag type that provides a static + `construct(capy::execution_context&, unsigned)` factory + used to build the scheduler. + @param backend The backend tag value selecting the I/O multiplexer (e.g. `corosio::epoll`). @param opts Runtime options controlling scheduler and service behavior. @param concurrency_hint Hint for the number of threads - that will call `run()`. + that calls `run()`. @throws std::invalid_argument If `opts.thread_pool_size` is less than 1 (POSIX). @@ -352,9 +360,12 @@ class BOOST_COROSIO_DECL io_context : public capy::execution_context apply_options_post_(opts, eff); } + /// Destroy the context; stops the loop and destroys every service. ~io_context(); - io_context(io_context const&) = delete; + /// Copy construction is disabled; the context owns its services. + io_context(io_context const&) = delete; + /// Copy assignment is disabled; the context owns its services. io_context& operator=(io_context const&) = delete; /** Return an executor for this context. @@ -376,10 +387,10 @@ class BOOST_COROSIO_DECL io_context : public capy::execution_context sched_->stop(); } - /** Return whether the context has been stopped. + /** Return whether the context stopped. - @return `true` if `stop()` has been called and `restart()` - has not been called since. + @return `true` after a call to `stop()` with no later + call to `restart()`. */ bool stopped() const noexcept { @@ -389,7 +400,7 @@ class BOOST_COROSIO_DECL io_context : public capy::execution_context /** Restart the context after being stopped. This function must be called before `run()` can be called - again after `stop()` has been called. + again after a call to `stop()`. */ void restart() { @@ -398,8 +409,8 @@ class BOOST_COROSIO_DECL io_context : public capy::execution_context /** Process all pending work items. - This function blocks until all pending work items have been - executed or `stop()` is called. The context is stopped + This function blocks until it executes all pending work items, + or until `stop()` is called. The context is stopped when there is no more outstanding work. @note The context must be restarted with `restart()` before @@ -414,7 +425,7 @@ class BOOST_COROSIO_DECL io_context : public capy::execution_context /** Process at most one pending work item. - This function blocks until one work item has been executed + This function blocks until it executes one work item or `stop()` is called. The context is stopped when there is no more outstanding work. @@ -430,8 +441,8 @@ class BOOST_COROSIO_DECL io_context : public capy::execution_context /** Process work items for the specified duration. - This function blocks until work items have been executed for - the specified duration, or `stop()` is called. The context + This function blocks until it has executed work items for the + specified duration, or until `stop()` is called. The context is stopped when there is no more outstanding work. @note The context must be restarted with `restart()` before @@ -473,7 +484,7 @@ class BOOST_COROSIO_DECL io_context : public capy::execution_context /** Process at most one work item for the specified duration. - This function blocks until one work item has been executed, + This function blocks until it executes one work item, the specified duration has elapsed, or `stop()` is called. The context is stopped when there is no more outstanding work. @@ -492,7 +503,7 @@ class BOOST_COROSIO_DECL io_context : public capy::execution_context /** Process at most one work item until the specified time. - This function blocks until one work item has been executed, + This function blocks until it executes one work item, the specified time is reached, or `stop()` is called. The context is stopped when there is no more outstanding work. @@ -565,7 +576,7 @@ class BOOST_COROSIO_DECL io_context : public capy::execution_context } }; -/** An executor for dispatching work to an I/O context. +/** Dispatches and posts work to an I/O context. The executor provides the interface for posting work items and dispatching coroutines to the associated context. It satisfies @@ -584,10 +595,7 @@ class io_context::executor_type io_context* ctx_ = nullptr; public: - /** Default constructor. - - Constructs an executor not associated with any context. - */ + /** Constructs an executor not associated with any context. */ executor_type() = default; /** Construct an executor from a context. @@ -625,8 +633,7 @@ class io_context::executor_type /** Informs the executor that work has completed. - @par Preconditions - A preceding call to `on_work_started()` on an equal executor. + @pre A preceding call to `on_work_started()` on an equal executor. */ void on_work_finished() const noexcept { @@ -643,10 +650,9 @@ class io_context::executor_type @return A handle for symmetric transfer or `std::noop_coroutine()`. - @par Preconditions - The associated context must outlive this call. Dispatching - concurrently with, or after, the context's destruction is - undefined behavior. + @pre The associated context must outlive this call. Dispatching + concurrently with, or after, the context's destruction is + undefined behavior. */ std::coroutine_handle<> dispatch(capy::continuation& c) const { @@ -661,10 +667,11 @@ class io_context::executor_type Enqueues `c` directly on the scheduler's ready queue. No heap allocation occurs. - @par Preconditions - The associated context must outlive this call. Posting - concurrently with, or after, the context's destruction is - undefined behavior. + @param c The continuation to enqueue. + + @pre The associated context must outlive this call. Posting + concurrently with, or after, the context's destruction is + undefined behavior. */ void post(capy::continuation& c) const { @@ -673,16 +680,16 @@ class io_context::executor_type /** Post a bare coroutine handle for deferred execution. - Heap-allocates a scheduler_op to wrap the handle. A caller - that already owns a `scheduler_op` can post it directly via - the `post(scheduler_op*)` overload to avoid the allocation. + Heap-allocates a `scheduler_op` to wrap the handle. A caller + that already owns a `capy::continuation` can post it directly + via the `post(capy::continuation&)` overload to avoid the + allocation. @param h The coroutine handle to post. - @par Preconditions - The associated context must outlive this call. Posting - concurrently with, or after, the context's destruction is - undefined behavior. + @pre The associated context must outlive this call. Posting + concurrently with, or after, the context's destruction is + undefined behavior. */ void post(std::coroutine_handle<> h) const { diff --git a/include/boost/corosio/ip_address.hpp b/include/boost/corosio/ip_address.hpp index ad5813784..98a2215e7 100644 --- a/include/boost/corosio/ip_address.hpp +++ b/include/boost/corosio/ip_address.hpp @@ -29,12 +29,11 @@ namespace boost::corosio { /** A version-independent IP address. - This class holds either an IPv4 or an IPv6 address, letting - code that works with both families carry one value instead of - branching between @ref ipv4_address and @ref ipv6_address. - Family-generic queries such as @ref is_loopback dispatch to - the held address, and @ref to_v4 / @ref to_v6 recover the - family-specific form. + This class holds either an IPv4 or an IPv6 address. Code that works with + both families carries one value instead of branching between @ref + ipv4_address and @ref ipv6_address. Family-generic queries such as @ref + is_loopback dispatch to the held address, and @ref to_v4 / @ref to_v6 + recover the family-specific form. A v4-mapped IPv6 address (`::ffff:a.b.c.d`) is an IPv6-family value: it does not compare equal to the IPv4 address it maps. @@ -269,7 +268,8 @@ class BOOST_COROSIO_DECL ip_address Addresses are equal if they have the same family and the same value. A v4-mapped IPv6 address is not equal to the - IPv4 address it maps; normalize with @ref to_v4 to compare + IPv4 address it maps; normalize with @ref ip_address::to_v4 + to compare across the mapping. @return `true` if the addresses are equal. @@ -312,12 +312,12 @@ class BOOST_COROSIO_DECL ip_address /** Create an IP address from a string. - This function parses `s` as an IPv4 address in dotted decimal - form, or an IPv6 address in hexadecimal notation, optionally - qualified by a `%zone` suffix (a decimal interface index, or an - interface name where the platform names interfaces). The string - must contain the address alone: port suffixes, surrounding - brackets, and host names are not accepted. + This function parses `s` as an IPv4 address in dotted decimal form, or + an IPv6 address in hexadecimal notation. An IPv6 address may carry a + `%zone` suffix: a decimal interface index, or an interface name where + the platform names interfaces. The string must contain the address + alone: port suffixes, surrounding brackets, and host names are not + accepted. @par Exception Safety Throws nothing. diff --git a/include/boost/corosio/ipv4_address.hpp b/include/boost/corosio/ipv4_address.hpp index 269be7f1c..33b204e23 100644 --- a/include/boost/corosio/ipv4_address.hpp +++ b/include/boost/corosio/ipv4_address.hpp @@ -26,7 +26,7 @@ namespace boost::corosio { -/** An IP version 4 style address. +/** Stores and parses an IP version 4 address. Objects of this type are used to construct, parse, and manipulate IP version 4 addresses. @@ -243,8 +243,7 @@ class BOOST_COROSIO_DECL ipv4_address /** Format the address to an output stream. - IPv4 addresses written to output streams - are written in their dotted decimal format. + This operator writes the address in dotted decimal format. @param os The output stream. @param addr The address to format. diff --git a/include/boost/corosio/ipv6_address.hpp b/include/boost/corosio/ipv6_address.hpp index a5c4aec09..7dcd663e1 100644 --- a/include/boost/corosio/ipv6_address.hpp +++ b/include/boost/corosio/ipv6_address.hpp @@ -79,10 +79,9 @@ class BOOST_COROSIO_DECL ipv6_address /** The number of characters in the longest possible IPv6 string. The longest address body is the IPv4-mapped form - `ffff:ffff:ffff:ffff:ffff:ffff:255.255.255.255` (45 - characters), and a numeric zone suffix adds up to eleven - more (`%4294967295`), for a worst case of 56; the constant - carries a little slack. + `ffff:ffff:ffff:ffff:ffff:ffff:255.255.255.255` (45 characters). A + numeric zone suffix adds up to eleven more (`%4294967295`), for a + worst case of 56; the constant carries a little slack. */ static constexpr std::size_t max_str_len = 60; @@ -129,11 +128,11 @@ class BOOST_COROSIO_DECL ipv6_address /** Return the zone the address belongs to. - Link-local addresses (`fe80::/10`) are unique only per - network link, so the address bits alone do not identify a - destination; the zone — an interface index, written with a - `%` suffix in text form — disambiguates. For global - addresses the zone is 0 and has no meaning. + Link-local addresses (`fe80::/10`) are unique only per network link, + so the address bits alone do not identify a destination. The zone — + an interface index, written with a `%` suffix in text form — + disambiguates. For global addresses the zone is 0 and has no + meaning. @return The zone as an interface index; 0 if unscoped. @@ -383,9 +382,8 @@ class BOOST_COROSIO_DECL ipv6_address /** Create an IPv6 address from a string. - This function attempts to parse the string - as an IPv6 address and returns an error code - if the string does not contain a valid IPv6 address. + This function attempts to parse the string as an IPv6 address. It + returns an error code if the string holds no valid IPv6 address. @par Exception Safety Throws nothing. diff --git a/include/boost/corosio/local_connect_pair.hpp b/include/boost/corosio/local_connect_pair.hpp index 66b740366..095bcbb5e 100644 --- a/include/boost/corosio/local_connect_pair.hpp +++ b/include/boost/corosio/local_connect_pair.hpp @@ -26,8 +26,8 @@ namespace boost::corosio { On POSIX the implementation uses `socketpair(AF_UNIX, SOCK_STREAM)` and adopts the descriptors via `assign()`. On Windows it performs a - private bind/listen/accept on the calling thread paired with a - `connect()` on a short-lived worker thread; the caller's + private bind/listen/accept on the calling thread, paired with a + `connect()` on a short-lived worker thread. The caller's `io_context` is never driven, so it may be running on another thread. diff --git a/include/boost/corosio/local_datagram_socket.hpp b/include/boost/corosio/local_datagram_socket.hpp index 424d313ee..3e1eabc1f 100644 --- a/include/boost/corosio/local_datagram_socket.hpp +++ b/include/boost/corosio/local_datagram_socket.hpp @@ -41,7 +41,7 @@ namespace boost::corosio { -/** An asynchronous Unix datagram socket for coroutine I/O. +/** Sends and receives datagrams over a Unix domain socket, from a coroutine. This class provides asynchronous Unix domain datagram socket operations that return awaitable types. Each operation @@ -61,7 +61,7 @@ namespace boost::corosio { @note Not available on Windows. Windows does not support AF_UNIX datagram sockets (SOCK_DGRAM). Attempting to - open this socket on Windows will fail. + open this socket on Windows fails. @par Cancellation All asynchronous operations support cancellation through @@ -74,10 +74,10 @@ namespace boost::corosio { Distinct objects: Safe.@n Shared objects: Unsafe. A socket must not have concurrent operations of the same type (e.g., two simultaneous - recv_from). One send and one recv may be in flight - simultaneously. Note that recv and recv_from share the - same internal read slot, so they must not overlap; likewise - send and send_to share the write slot. + `recv_from`). One send and one `recv` may be in flight + simultaneously. Both `recv` and `recv_from` share the + same internal read slot, so they must not overlap. Likewise, + send and `send_to` share the write slot. @par Example @par !example connectionless_and_connected @@ -96,12 +96,13 @@ class BOOST_COROSIO_DECL local_datagram_socket : public io_object */ struct implementation : io_object::implementation { - /** Initiate an asynchronous send_to operation. + /** Initiate an asynchronous `send_to` operation. @param h Coroutine handle to resume on completion. @param ex Executor for dispatching the completion. @param buf The buffer data to send. @param dest The destination endpoint. + @param flags Message flags (e.g. `message_flags::do_not_route`). @param token Stop token for cancellation. @param ec Output error code. @param bytes_out Output bytes transferred. @@ -118,12 +119,13 @@ class BOOST_COROSIO_DECL local_datagram_socket : public io_object std::error_code* ec, std::size_t* bytes_out) = 0; - /** Initiate an asynchronous recv_from operation. + /** Initiate an asynchronous `recv_from` operation. @param h Coroutine handle to resume on completion. @param ex Executor for dispatching the completion. @param buf The buffer to receive into. @param source Output endpoint for the sender's address. + @param flags Message flags (e.g. `message_flags::peek`). @param token Stop token for cancellation. @param ec Output error code. @param bytes_out Output bytes transferred. @@ -162,6 +164,7 @@ class BOOST_COROSIO_DECL local_datagram_socket : public io_object @param h Coroutine handle to resume on completion. @param ex Executor for dispatching the completion. @param buf The buffer data to send. + @param flags Message flags (e.g. `message_flags::do_not_route`). @param token Stop token for cancellation. @param ec Output error code. @param bytes_out Output bytes transferred. @@ -177,12 +180,12 @@ class BOOST_COROSIO_DECL local_datagram_socket : public io_object std::error_code* ec, std::size_t* bytes_out) = 0; - /** Initiate an asynchronous connected recv operation. + /** Initiate an asynchronous connected `recv` operation. @param h Coroutine handle to resume on completion. @param ex Executor for dispatching the completion. @param buf The buffer to receive into. - @param flags Message flags (e.g. MSG_PEEK). + @param flags Message flags (e.g. `message_flags::peek`). @param token Stop token for cancellation. @param ec Output error code. @param bytes_out Output bytes transferred. @@ -219,7 +222,12 @@ class BOOST_COROSIO_DECL local_datagram_socket : public io_object std::stop_token token, std::error_code* ec) = 0; - /// Shut down part or all of the socket. + /** Shut down part or all of the socket. + + @param what Which directions to disable. + + @return The error code, empty on success. + */ virtual std::error_code shutdown(shutdown_type what) noexcept = 0; /// Return the platform socket descriptor. @@ -302,10 +310,8 @@ class BOOST_COROSIO_DECL local_datagram_socket : public io_object */ struct send_to_awaitable : detail::bytes_op_base { - local_datagram_socket& s_; - buffer_param buf_; - corosio::local_endpoint dest_; - int flags_; + private: + friend local_datagram_socket; send_to_awaitable( local_datagram_socket& s, @@ -319,6 +325,13 @@ class BOOST_COROSIO_DECL local_datagram_socket : public io_object { } + friend detail::bytes_op_base; + + local_datagram_socket& s_; + buffer_param buf_; + corosio::local_endpoint dest_; + int flags_; + std::coroutine_handle<> dispatch(std::coroutine_handle<> h, capy::executor_ref ex) const { @@ -334,10 +347,8 @@ class BOOST_COROSIO_DECL local_datagram_socket : public io_object */ struct recv_from_awaitable : detail::bytes_op_base { - local_datagram_socket& s_; - buffer_param buf_; - corosio::local_endpoint& source_; - int flags_; + private: + friend local_datagram_socket; recv_from_awaitable( local_datagram_socket& s, @@ -351,6 +362,13 @@ class BOOST_COROSIO_DECL local_datagram_socket : public io_object { } + friend detail::bytes_op_base; + + local_datagram_socket& s_; + buffer_param buf_; + corosio::local_endpoint& source_; + int flags_; + std::coroutine_handle<> dispatch(std::coroutine_handle<> h, capy::executor_ref ex) const { @@ -366,8 +384,8 @@ class BOOST_COROSIO_DECL local_datagram_socket : public io_object */ struct connect_awaitable : detail::void_op_base { - local_datagram_socket& s_; - corosio::local_endpoint endpoint_; + private: + friend local_datagram_socket; connect_awaitable( local_datagram_socket& s, corosio::local_endpoint ep) noexcept @@ -376,6 +394,11 @@ class BOOST_COROSIO_DECL local_datagram_socket : public io_object { } + friend detail::void_op_base; + + local_datagram_socket& s_; + corosio::local_endpoint endpoint_; + std::coroutine_handle<> dispatch(std::coroutine_handle<> h, capy::executor_ref ex) const { @@ -386,8 +409,8 @@ class BOOST_COROSIO_DECL local_datagram_socket : public io_object /// Represent the awaitable returned by @ref wait. struct wait_awaitable : detail::void_op_base { - local_datagram_socket& s_; - wait_type w_; + private: + friend local_datagram_socket; wait_awaitable(local_datagram_socket& s, wait_type w) noexcept : s_(s) @@ -395,6 +418,11 @@ class BOOST_COROSIO_DECL local_datagram_socket : public io_object { } + friend detail::void_op_base; + + local_datagram_socket& s_; + wait_type w_; + std::coroutine_handle<> dispatch(std::coroutine_handle<> h, capy::executor_ref ex) const { @@ -409,9 +437,8 @@ class BOOST_COROSIO_DECL local_datagram_socket : public io_object */ struct send_awaitable : detail::bytes_op_base { - local_datagram_socket& s_; - buffer_param buf_; - int flags_; + private: + friend local_datagram_socket; send_awaitable( local_datagram_socket& s, buffer_param buf, int flags = 0) noexcept @@ -421,6 +448,12 @@ class BOOST_COROSIO_DECL local_datagram_socket : public io_object { } + friend detail::bytes_op_base; + + local_datagram_socket& s_; + buffer_param buf_; + int flags_; + std::coroutine_handle<> dispatch(std::coroutine_handle<> h, capy::executor_ref ex) const { @@ -435,9 +468,8 @@ class BOOST_COROSIO_DECL local_datagram_socket : public io_object */ struct recv_awaitable : detail::bytes_op_base { - local_datagram_socket& s_; - buffer_param buf_; - int flags_; + private: + friend local_datagram_socket; recv_awaitable( local_datagram_socket& s, buffer_param buf, int flags = 0) noexcept @@ -447,6 +479,12 @@ class BOOST_COROSIO_DECL local_datagram_socket : public io_object { } + friend detail::bytes_op_base; + + local_datagram_socket& s_; + buffer_param buf_; + int flags_; + std::coroutine_handle<> dispatch(std::coroutine_handle<> h, capy::executor_ref ex) const { @@ -463,7 +501,7 @@ class BOOST_COROSIO_DECL local_datagram_socket : public io_object /** Construct a socket from an execution context. - @param ctx The execution context that will own this socket. + @param ctx The execution context that owns this socket. */ explicit local_datagram_socket(capy::execution_context& ctx); @@ -471,7 +509,9 @@ class BOOST_COROSIO_DECL local_datagram_socket : public io_object The socket is associated with the executor's context. - @param ex The executor whose context will own the socket. + @tparam Ex A type satisfying capy::Executor. + + @param ex The executor whose context owns the socket. */ template requires(!std:: @@ -510,7 +550,9 @@ class BOOST_COROSIO_DECL local_datagram_socket : public io_object return *this; } - local_datagram_socket(local_datagram_socket const&) = delete; + /// Copy construction is disabled; the handle is uniquely owned. + local_datagram_socket(local_datagram_socket const&) = delete; + /// Copy assignment is disabled; the handle is uniquely owned. local_datagram_socket& operator=(local_datagram_socket const&) = delete; /** Open the socket. @@ -555,7 +597,7 @@ class BOOST_COROSIO_DECL local_datagram_socket : public io_object /** Bind the socket to a local endpoint. Associates the socket with a local address (filesystem path). - Required before calling recv_from in connectionless mode. + Required before calling `recv_from` in connectionless mode. @param ep The local endpoint to bind to. @@ -603,8 +645,7 @@ class BOOST_COROSIO_DECL local_datagram_socket : public io_object A closed socket completes with `errc::bad_file_descriptor`. - @par Preconditions - This socket must outlive the returned awaitable. + @pre This socket must outlive the returned awaitable. */ [[nodiscard]] auto wait(wait_type w) { @@ -613,12 +654,13 @@ class BOOST_COROSIO_DECL local_datagram_socket : public io_object /** Send a datagram to the specified destination. - Completes when the entire datagram has been accepted - by the kernel. The bytes_transferred value equals the + Completes when the transport accepts the entire datagram + by the kernel. The `bytes_transferred` value equals the datagram size on success. @param buf The buffer containing data to send. @param dest The destination endpoint. + @param flags Message flags (e.g. message_flags::do_not_route). @par Cancellation Supports cancellation via stop_token or cancel(). @@ -649,14 +691,14 @@ class BOOST_COROSIO_DECL local_datagram_socket : public io_object /** Receive a datagram and capture the sender's endpoint. - Completes when one datagram has been received. The - bytes_transferred value is the number of bytes copied + Completes when one datagram arrives. The + `bytes_transferred` value is the number of bytes copied into the buffer. If the buffer is smaller than the datagram, excess bytes are discarded (datagram semantics). @param buf The buffer to receive data into. - @param source Reference to an endpoint that will be set to + @param source Reference to an endpoint that receives the sender's address on successful completion. @param flags Message flags (e.g. message_flags::peek). @@ -690,7 +732,7 @@ class BOOST_COROSIO_DECL local_datagram_socket : public io_object /** Send a datagram to the connected peer. - @pre connect() has been called successfully. + @pre connect() succeeded. @param buf The buffer containing data to send. @param flags Message flags. @@ -721,7 +763,7 @@ class BOOST_COROSIO_DECL local_datagram_socket : public io_object /** Receive a datagram from the connected peer. - @pre connect() has been called successfully. + @pre connect() succeeded. @param buf The buffer to receive data into. @param flags Message flags (e.g. message_flags::peek). @@ -859,8 +901,8 @@ class BOOST_COROSIO_DECL local_datagram_socket : public io_object library — from `socketpair()`, received over `SCM_RIGHTS`, or made natively — and registers it with the backend. The socket must be a datagram socket in the `AF_UNIX` family. - Adoption never alters the descriptor's flags or options; the - fd must already be non-blocking. + Adoption never alters the descriptor's flags or options. + The fd must already be non-blocking. If this object is already open, pending operations complete with `errc::operation_canceled` and the held socket is @@ -874,7 +916,7 @@ class BOOST_COROSIO_DECL local_datagram_socket : public io_object ownership of `fd`. @param fd The native socket to adopt. On success the object - owns it and will close it. + owns it and closes it. @return The error code, empty on success. Validation and registration failures are normal runtime conditions when diff --git a/include/boost/corosio/local_endpoint.hpp b/include/boost/corosio/local_endpoint.hpp index 118f57cb2..6e13274a7 100644 --- a/include/boost/corosio/local_endpoint.hpp +++ b/include/boost/corosio/local_endpoint.hpp @@ -23,7 +23,7 @@ namespace boost::corosio { -/** A Unix domain socket endpoint (filesystem path). +/** Holds the filesystem path that names a Unix domain socket. Stores the path in a fixed-size buffer, avoiding heap allocation. The object is trivially copyable. @@ -33,7 +33,7 @@ namespace boost::corosio { null byte is stored. The library does NOT automatically unlink the socket path on - close — callers are responsible for cleanup. + close. Callers are responsible for cleanup. @par Thread Safety Distinct objects: Safe.@n @@ -55,8 +55,8 @@ class BOOST_COROSIO_DECL local_endpoint /** Construct from a path. - An over-long path is a precondition violation: the limit is - the public @ref max_path_length constant, so callers with + An over-long path causes the constructor to throw; the limit + is the public @ref max_path_length constant, so callers with runtime-derived paths can check `path.size() <= max_path_length` before constructing. diff --git a/include/boost/corosio/local_stream_acceptor.hpp b/include/boost/corosio/local_stream_acceptor.hpp index 0dba0a204..a02ae73c2 100644 --- a/include/boost/corosio/local_stream_acceptor.hpp +++ b/include/boost/corosio/local_stream_acceptor.hpp @@ -35,19 +35,18 @@ namespace boost::corosio { -/** Options for @ref local_stream_acceptor::bind(). - - Controls filesystem cleanup behavior before binding - to a Unix domain socket path. +/** Controls whether @ref local_stream_acceptor::bind() unlinks + an existing socket path before binding. */ enum class bind_option { + /// Bind without touching the socket path. none, /// Unlink the socket path before binding (ignored for abstract paths). unlink_existing }; -/** An asynchronous Unix domain stream acceptor for coroutine I/O. +/** Accepts inbound Unix domain stream connections, from a coroutine. This class provides asynchronous Unix domain stream accept operations that return awaitable types. The acceptor binds @@ -71,8 +70,8 @@ class BOOST_COROSIO_DECL local_stream_acceptor : public io_object { struct wait_awaitable : detail::void_op_base { - local_stream_acceptor& acc_; - wait_type w_; + private: + friend local_stream_acceptor; wait_awaitable(local_stream_acceptor& acc, wait_type w) noexcept : acc_(acc) @@ -80,6 +79,11 @@ class BOOST_COROSIO_DECL local_stream_acceptor : public io_object { } + friend detail::void_op_base; + + local_stream_acceptor& acc_; + wait_type w_; + std::coroutine_handle<> dispatch(std::coroutine_handle<> h, capy::executor_ref ex) const { @@ -89,6 +93,10 @@ class BOOST_COROSIO_DECL local_stream_acceptor : public io_object struct move_accept_awaitable : detail::void_op_base { + private: + friend local_stream_acceptor; + friend detail::void_op_base; + local_stream_acceptor& acc_; mutable io_object::implementation* peer_impl_ = nullptr; @@ -97,6 +105,14 @@ class BOOST_COROSIO_DECL local_stream_acceptor : public io_object { } + std::coroutine_handle<> + dispatch(std::coroutine_handle<> h, capy::executor_ref ex) const + { + return acc_.get().accept( + h, ex, this->token_, &this->ec_, &peer_impl_); + } + + public: [[nodiscard]] capy::io_result await_resume() const noexcept { @@ -107,17 +123,14 @@ class BOOST_COROSIO_DECL local_stream_acceptor : public io_object reset_peer_impl(peer, peer_impl_); return {this->ec_, std::move(peer)}; } - - std::coroutine_handle<> - dispatch(std::coroutine_handle<> h, capy::executor_ref ex) const - { - return acc_.get().accept( - h, ex, this->token_, &this->ec_, &peer_impl_); - } }; struct accept_awaitable : detail::void_op_base { + private: + friend local_stream_acceptor; + friend detail::void_op_base; + local_stream_acceptor& acc_; local_stream_socket& peer_; mutable io_object::implementation* peer_impl_ = nullptr; @@ -129,31 +142,30 @@ class BOOST_COROSIO_DECL local_stream_acceptor : public io_object { } - [[nodiscard]] capy::io_result<> await_resume() const noexcept - { - if (!this->ec_ && peer_impl_) - peer_.h_.reset(peer_impl_); - return {this->ec_}; - } - std::coroutine_handle<> dispatch(std::coroutine_handle<> h, capy::executor_ref ex) const { return acc_.get().accept( h, ex, this->token_, &this->ec_, &peer_impl_); } + + public: + [[nodiscard]] capy::io_result<> await_resume() const noexcept + { + if (!this->ec_ && peer_impl_) + peer_.h_.reset(peer_impl_); + return {this->ec_}; + } }; public: - /** Destructor. - - Closes the acceptor if open, cancelling any pending operations. + /** Closes the acceptor if open, cancelling any pending operations. */ ~local_stream_acceptor() override; /** Construct an acceptor from an execution context. - @param ctx The execution context that will own this acceptor. + @param ctx The execution context that owns this acceptor. */ explicit local_stream_acceptor(capy::execution_context& ctx); @@ -163,7 +175,7 @@ class BOOST_COROSIO_DECL local_stream_acceptor : public io_object expression, throwing the codes the piecewise `open()` + `bind()` + `listen()` path returns. - @param ctx The execution context that will own this acceptor. + @param ctx The execution context that owns this acceptor. @param ep The local endpoint to bind to. @param backlog The maximum pending connection queue length. @@ -178,7 +190,7 @@ class BOOST_COROSIO_DECL local_stream_acceptor : public io_object The acceptor is associated with the executor's context. - @param ex The executor whose context will own the acceptor. + @param ex The executor whose context owns the acceptor. @tparam Ex A type satisfying @ref capy::Executor. Must not be `local_stream_acceptor` itself (disables implicit @@ -195,10 +207,12 @@ class BOOST_COROSIO_DECL local_stream_acceptor : public io_object /** Convenience constructor from an executor. - @param ex The executor whose context will own the acceptor. + @param ex The executor whose context owns the acceptor. @param ep The local endpoint to bind to. @param backlog The maximum pending connection queue length. + @tparam Ex A type satisfying @ref capy::Executor. + @throws std::system_error on open, bind, or listen failure. */ template @@ -209,9 +223,8 @@ class BOOST_COROSIO_DECL local_stream_acceptor : public io_object { } - /** Move constructor. - - Transfers ownership of the acceptor resources. + /** Transfers ownership of the acceptor resources from another + acceptor. @param other The acceptor to move from. @@ -224,10 +237,9 @@ class BOOST_COROSIO_DECL local_stream_acceptor : public io_object { } - /** Move assignment operator. - - Closes any existing acceptor and transfers ownership. - Both acceptors must share the same execution context. + /** Closes any existing acceptor and transfers ownership from + another acceptor. Both acceptors must share the same + execution context. @param other The acceptor to move from. @@ -250,7 +262,9 @@ class BOOST_COROSIO_DECL local_stream_acceptor : public io_object return *this; } - local_stream_acceptor(local_stream_acceptor const&) = delete; + /// Copy construction is disabled; the handle is uniquely owned. + local_stream_acceptor(local_stream_acceptor const&) = delete; + /// Copy assignment is disabled; the handle is uniquely owned. local_stream_acceptor& operator=(local_stream_acceptor const&) = delete; /** Create the acceptor socket. @@ -298,7 +312,10 @@ class BOOST_COROSIO_DECL local_stream_acceptor : public io_object */ void close() noexcept; - /// Check if the acceptor has an open socket handle. + /** Check if the acceptor has an open socket handle. + + @return `true` if the acceptor holds an open handle. + */ bool is_open() const noexcept { return h_ && get().is_open(); @@ -333,8 +350,8 @@ class BOOST_COROSIO_DECL local_stream_acceptor : public io_object Suspends until the listen socket is ready in the requested direction. For `wait_type::read`, completion - signals that a subsequent @ref accept will succeed - without blocking; a connection already queued when the + signals that a subsequent @ref accept succeeds + without blocking. A connection already queued when the wait begins completes it immediately. No connection is consumed. @@ -349,8 +366,7 @@ class BOOST_COROSIO_DECL local_stream_acceptor : public io_object A closed acceptor completes with `errc::bad_file_descriptor`. - @par Preconditions - This acceptor must outlive the returned awaitable. + @pre This acceptor must outlive the returned awaitable. */ [[nodiscard]] auto wait(wait_type w) { @@ -371,7 +387,7 @@ class BOOST_COROSIO_DECL local_stream_acceptor : public io_object a default-constructed socket. @return An awaitable that completes with - io_result. + io_result<`local_stream_socket`>. A closed acceptor reports `errc::bad_file_descriptor`. On failure the returned socket is default-constructed and @@ -413,8 +429,7 @@ class BOOST_COROSIO_DECL local_stream_acceptor : public io_object @return The native socket handle, or -1/INVALID_SOCKET if not open. - @par Preconditions - None. May be called on closed acceptors. + @pre None. May be called on closed acceptors. */ native_handle_type native_handle() const noexcept; @@ -443,7 +458,7 @@ class BOOST_COROSIO_DECL local_stream_acceptor : public io_object ownership of `fd`. @param fd The native socket to adopt. On success the object - owns it and will close it. + owns it and closes it. @return The error code, empty on success. Validation and registration failures are normal runtime conditions when @@ -453,9 +468,10 @@ class BOOST_COROSIO_DECL local_stream_acceptor : public io_object /** Return the local endpoint the acceptor is bound to. - Returns a default-constructed (empty) endpoint if the - acceptor is not open or not yet bound. Safe to call in - any state. + Safe to call in any state. + + @return The bound local endpoint, or a default-constructed + endpoint if the acceptor is not open or not yet bound. */ corosio::local_endpoint local_endpoint() const noexcept; @@ -518,10 +534,8 @@ class BOOST_COROSIO_DECL local_stream_acceptor : public io_object return opt; } - /** Backend hooks for local stream acceptor operations. - - Platform backends derive from this to implement - accept, option, and lifecycle management. + /** Backends derive from this to implement accept, option, and + lifecycle management. */ struct implementation : io_object::implementation { @@ -539,16 +553,24 @@ class BOOST_COROSIO_DECL local_stream_acceptor : public io_object @return Coroutine handle to resume immediately. */ virtual std::coroutine_handle<> accept( - std::coroutine_handle<>, - capy::executor_ref, - std::stop_token, - std::error_code*, - io_object::implementation**) = 0; + std::coroutine_handle<> h, + capy::executor_ref ex, + std::stop_token token, + std::error_code* ec, + io_object::implementation** impl_out) = 0; /** Initiate an asynchronous wait for acceptor readiness. Completes when the listen socket becomes ready for the specified direction. No connection is consumed. + + @param h Coroutine handle to resume on completion. + @param ex Executor for dispatching the completion. + @param w The direction to wait on. + @param token Stop token for cancellation. + @param ec Output error code. + + @return Coroutine handle to resume immediately. */ virtual std::coroutine_handle<> wait( std::coroutine_handle<> h, @@ -582,26 +604,54 @@ class BOOST_COROSIO_DECL local_stream_acceptor : public io_object /// Cancel pending accept operations. virtual void cancel() noexcept = 0; - /// Set a raw socket option. + /** Set a raw socket option. + + @param level The protocol level (e.g. `SOL_SOCKET`). + @param optname The option name. + @param data Pointer to the option value. + @param size Size of the option value in bytes. + + @return The error code, empty on success. + */ virtual std::error_code set_option( int level, int optname, void const* data, std::size_t size) noexcept = 0; - /// Get a raw socket option. + /** Get a raw socket option. + + @param level The protocol level (e.g. `SOL_SOCKET`). + @param optname The option name. + @param data Pointer to storage for the option value. + @param size In/out size of the storage, in bytes. + + @return The error code, empty on success. + */ virtual std::error_code get_option(int level, int optname, void* data, std::size_t* size) const noexcept = 0; }; protected: + /** Adopt an existing handle bound to a context. + + @param h The handle the acceptor takes ownership of. + + @param ctx The context the acceptor draws its service from. + */ local_stream_acceptor(handle h, capy::execution_context& ctx) noexcept : io_object(std::move(h)) , ctx_(ctx) { } + /** Move construct, rebinding to a context. + + @param ctx The context the acceptor draws its service from. + + @param other The acceptor to take the handle from. + */ local_stream_acceptor( capy::execution_context& ctx, local_stream_acceptor&& other) noexcept : io_object(std::move(other)) @@ -609,6 +659,16 @@ class BOOST_COROSIO_DECL local_stream_acceptor : public io_object { } + /** Install an accepted implementation into the peer socket. + + Derived acceptors call this to hand the accepted connection to + the caller's socket, which cannot reach @ref io_object::handle + itself. + + @param peer The socket receiving the accepted connection. + + @param impl The accepted implementation, or `nullptr` on failure. + */ static void reset_peer_impl( local_stream_socket& peer, io_object::implementation* impl) noexcept { diff --git a/include/boost/corosio/local_stream_socket.hpp b/include/boost/corosio/local_stream_socket.hpp index a755cff37..54c8f19a1 100644 --- a/include/boost/corosio/local_stream_socket.hpp +++ b/include/boost/corosio/local_stream_socket.hpp @@ -37,7 +37,7 @@ namespace boost::corosio { -/** An asynchronous Unix stream socket for coroutine I/O. +/** Reads and writes a Unix domain stream, from a coroutine. This class provides asynchronous Unix domain stream socket operations that return awaitable types. Each operation @@ -57,7 +57,7 @@ namespace boost::corosio { @par Semantics Wraps the platform Unix domain socket stack. Operations - dispatch to OS socket APIs via the io_context backend + dispatch to OS socket APIs via the `io_context` backend (epoll, kqueue, select, or IOCP). Satisfies @ref capy::Stream. @par Example @@ -69,6 +69,7 @@ class BOOST_COROSIO_DECL local_stream_socket : public io_stream /// The endpoint type used by this socket. using endpoint_type = corosio::local_endpoint; + /// The shutdown direction type used by this socket. using shutdown_type = corosio::shutdown_type; using enum corosio::shutdown_type; @@ -192,8 +193,8 @@ class BOOST_COROSIO_DECL local_stream_socket : public io_stream /// Represent the awaitable returned by @ref connect. struct connect_awaitable : detail::void_op_base { - local_stream_socket& s_; - corosio::local_endpoint endpoint_; + private: + friend local_stream_socket; connect_awaitable( local_stream_socket& s, corosio::local_endpoint ep) noexcept @@ -202,6 +203,11 @@ class BOOST_COROSIO_DECL local_stream_socket : public io_stream { } + friend detail::void_op_base; + + local_stream_socket& s_; + corosio::local_endpoint endpoint_; + std::coroutine_handle<> dispatch(std::coroutine_handle<> h, capy::executor_ref ex) const { @@ -212,8 +218,8 @@ class BOOST_COROSIO_DECL local_stream_socket : public io_stream /// Represent the awaitable returned by @ref wait. struct wait_awaitable : detail::void_op_base { - local_stream_socket& s_; - wait_type w_; + private: + friend local_stream_socket; wait_awaitable(local_stream_socket& s, wait_type w) noexcept : s_(s) @@ -221,6 +227,11 @@ class BOOST_COROSIO_DECL local_stream_socket : public io_stream { } + friend detail::void_op_base; + + local_stream_socket& s_; + wait_type w_; + std::coroutine_handle<> dispatch(std::coroutine_handle<> h, capy::executor_ref ex) const { @@ -237,7 +248,7 @@ class BOOST_COROSIO_DECL local_stream_socket : public io_stream /** Construct a socket from an execution context. - @param ctx The execution context that will own this socket. + @param ctx The execution context that owns this socket. */ explicit local_stream_socket(capy::execution_context& ctx); @@ -245,7 +256,9 @@ class BOOST_COROSIO_DECL local_stream_socket : public io_stream The socket is associated with the executor's context. - @param ex The executor whose context will own the socket. + @tparam Ex A type satisfying capy::Executor. + + @param ex The executor whose context owns the socket. */ template requires(!std::same_as, local_stream_socket>) && @@ -293,7 +306,9 @@ class BOOST_COROSIO_DECL local_stream_socket : public io_stream return *this; } - local_stream_socket(local_stream_socket const&) = delete; + /// Copy construction is disabled; the handle is uniquely owned. + local_stream_socket(local_stream_socket const&) = delete; + /// Copy assignment is disabled; the handle is uniquely owned. local_stream_socket& operator=(local_stream_socket const&) = delete; /** Open the socket. @@ -362,8 +377,7 @@ class BOOST_COROSIO_DECL local_stream_socket : public io_stream A closed socket completes with `errc::bad_file_descriptor`. - @par Preconditions - This socket must outlive the returned awaitable. + @pre This socket must outlive the returned awaitable. */ [[nodiscard]] auto wait(wait_type w) { @@ -424,8 +438,8 @@ class BOOST_COROSIO_DECL local_stream_socket : public io_stream returned error code. A closed socket reports `errc::bad_file_descriptor`. - @param what Determines what operations will no longer - be allowed. + @param what Determines which operations are no longer + allowed. @return The error code, empty on success. */ @@ -504,7 +518,7 @@ class BOOST_COROSIO_DECL local_stream_socket : public io_stream ownership of `fd`. @param fd The native socket to adopt. On success the object - owns it and will close it. + owns it and closes it. @return The error code, empty on success. Validation and registration failures are normal runtime conditions when @@ -533,8 +547,13 @@ class BOOST_COROSIO_DECL local_stream_socket : public io_stream corosio::local_endpoint remote_endpoint() const noexcept; protected: + /// Default construct a closed socket for a derived class to open. local_stream_socket() noexcept = default; + /** Adopt an existing handle. + + @param h The handle the socket takes ownership of. + */ explicit local_stream_socket(handle h) noexcept : io_object(std::move(h)) {} private: diff --git a/include/boost/corosio/message_flags.hpp b/include/boost/corosio/message_flags.hpp index 792f24fa0..469c19e6a 100644 --- a/include/boost/corosio/message_flags.hpp +++ b/include/boost/corosio/message_flags.hpp @@ -12,7 +12,7 @@ namespace boost::corosio { -/** Flags for datagram send/recv operations. +/** Flags for datagram send/`recv` operations. Platform-agnostic flag values that are mapped to native constants (MSG_PEEK, MSG_OOB, MSG_DONTROUTE) at the diff --git a/include/boost/corosio/native/detail/reactor/reactor_acceptor.hpp b/include/boost/corosio/native/detail/reactor/reactor_acceptor.hpp index 0185c294f..d89595b76 100644 --- a/include/boost/corosio/native/detail/reactor/reactor_acceptor.hpp +++ b/include/boost/corosio/native/detail/reactor/reactor_acceptor.hpp @@ -220,7 +220,7 @@ class reactor_acceptor /** Wait for readiness on the listen socket. For `wait_type::read`, completion signals that an incoming - connection is pending and a subsequent accept will succeed + connection is pending and a subsequent accept succeeds without blocking; a connection already queued when the wait begins completes it immediately via an initiation probe. diff --git a/include/boost/corosio/native/detail/reactor/reactor_scheduler.hpp b/include/boost/corosio/native/detail/reactor/reactor_scheduler.hpp index 8a8db0b9f..09d1b21be 100644 --- a/include/boost/corosio/native/detail/reactor/reactor_scheduler.hpp +++ b/include/boost/corosio/native/detail/reactor/reactor_scheduler.hpp @@ -186,8 +186,7 @@ class reactor_scheduler operations are added to the global queue under mutex and a waiter is signaled. - @par Preconditions - work_started() must have been called for each operation. + @pre work_started() must have been called for each operation. @param ops Queue of operations to post. */ diff --git a/include/boost/corosio/native/native_io_context.hpp b/include/boost/corosio/native/native_io_context.hpp index 838253010..46fc06ced 100644 --- a/include/boost/corosio/native/native_io_context.hpp +++ b/include/boost/corosio/native/native_io_context.hpp @@ -38,7 +38,7 @@ namespace boost::corosio { -/** An I/O context with devirtualized event loop methods. +/** Runs asynchronous operations, calling the backend event loop directly. This class template inherits from @ref io_context and shadows all public methods with versions that call the concrete @@ -46,7 +46,7 @@ namespace boost::corosio { is added. A `native_io_context` IS-A `io_context` and can be passed - anywhere an `io_context&` is accepted, in which case virtual + anywhere an `io_context&` is accepted. In that case, virtual dispatch is used transparently. @tparam Backend A backend tag value (e.g., `epoll`, @@ -78,7 +78,7 @@ class native_io_context : public io_context /** Construct with a concurrency hint. @param concurrency_hint Hint for the number of threads that - will call `run()`. + call `run()`. */ explicit native_io_context(unsigned concurrency_hint) : io_context(Backend, concurrency_hint) @@ -90,7 +90,7 @@ class native_io_context : public io_context @param opts Runtime options controlling scheduler and service behavior. @param concurrency_hint Hint for the number of threads that - will call `run()`. + call `run()`. */ explicit native_io_context( io_context_options const& opts, @@ -100,7 +100,9 @@ class native_io_context : public io_context } // Non-copyable, non-movable - native_io_context(native_io_context const&) = delete; + /// Copy construction is disabled; the context owns its services. + native_io_context(native_io_context const&) = delete; + /// Copy assignment is disabled; the context owns its services. native_io_context& operator=(native_io_context const&) = delete; /// Signal the context to stop processing. @@ -109,7 +111,10 @@ class native_io_context : public io_context sched().stop(); } - /// Return whether the context has been stopped. + /** Return whether the context stopped. + + @return `true` if the context has stopped. + */ bool stopped() const noexcept { return const_cast(this)->sched().stopped(); diff --git a/include/boost/corosio/native/native_local_datagram_socket.hpp b/include/boost/corosio/native/native_local_datagram_socket.hpp index 63329ad38..31171062c 100644 --- a/include/boost/corosio/native/native_local_datagram_socket.hpp +++ b/include/boost/corosio/native/native_local_datagram_socket.hpp @@ -39,13 +39,13 @@ namespace boost::corosio { -/** An asynchronous Unix datagram socket with devirtualized I/O. +/** Sends and receives Unix domain datagrams, calling the backend directly. - This class template inherits from @ref local_datagram_socket - and shadows the async operations (`send_to`, `recv_from`, - `connect`, `send`, `recv`) with versions that call the backend - implementation directly, allowing the compiler to inline - through the entire call chain. + This class template inherits from @ref local_datagram_socket. It + shadows the async operations (`send_to`, `recv_from`, `connect`, + `send`, `recv`) with versions that call the backend implementation + directly. The compiler can then inline through the entire call + chain. Non-async operations (`open`, `close`, `cancel`, `bind`, socket options) remain unchanged and dispatch through the @@ -238,7 +238,7 @@ class native_local_datagram_socket : public local_datagram_socket public: /** Construct a native socket from an execution context. - @param ctx The execution context that will own this socket. + @param ctx The execution context that owns this socket. */ explicit native_local_datagram_socket(capy::execution_context& ctx) : local_datagram_socket(create_handle(ctx)) @@ -247,7 +247,7 @@ class native_local_datagram_socket : public local_datagram_socket /** Construct a native socket from an executor. - @param ex The executor whose context will own the socket. + @param ex The executor whose context owns the socket. */ template requires(!std::same_as< @@ -267,7 +267,9 @@ class native_local_datagram_socket : public local_datagram_socket native_local_datagram_socket& operator=(native_local_datagram_socket&&) noexcept = default; + /// Copy construction is disabled; the handle is uniquely owned. native_local_datagram_socket(native_local_datagram_socket const&) = delete; + /// Copy assignment is disabled; the handle is uniquely owned. native_local_datagram_socket& operator=(native_local_datagram_socket const&) = delete; @@ -275,6 +277,12 @@ class native_local_datagram_socket : public local_datagram_socket Calls the backend implementation directly, bypassing virtual dispatch. Otherwise identical to @ref local_datagram_socket::send_to. + + @param buffers The buffer data to send. + @param dest The destination endpoint. + @param flags Message flags (e.g. `message_flags::do_not_route`). + + @return An awaitable yielding the error code and the byte count sent. */ template [[nodiscard]] auto send_to( @@ -300,6 +308,12 @@ class native_local_datagram_socket : public local_datagram_socket Calls the backend implementation directly, bypassing virtual dispatch. Otherwise identical to @ref local_datagram_socket::recv_from. + + @param buffers The buffers to receive into. + @param source Output endpoint for the sender's address. + @param flags Message flags (e.g. `message_flags::peek`). + + @return An awaitable yielding the error code and the byte count received. */ template [[nodiscard]] auto recv_from( @@ -328,6 +342,10 @@ class native_local_datagram_socket : public local_datagram_socket dispatch. Otherwise identical to @ref local_datagram_socket::connect. If the socket is not already open, it is opened automatically. + + @param ep The endpoint to set as the default destination. + + @return An awaitable yielding the error code. */ [[nodiscard]] auto connect(corosio::local_endpoint ep) { @@ -341,6 +359,11 @@ class native_local_datagram_socket : public local_datagram_socket Calls the backend implementation directly, bypassing virtual dispatch. Otherwise identical to @ref local_datagram_socket::send. + + @param buffers The buffer data to send. + @param flags Message flags (e.g. `message_flags::do_not_route`). + + @return An awaitable yielding the error code and the byte count sent. */ template [[nodiscard]] auto send(CB const& buffers, corosio::message_flags flags) @@ -362,6 +385,11 @@ class native_local_datagram_socket : public local_datagram_socket Calls the backend implementation directly, bypassing virtual dispatch. Otherwise identical to @ref local_datagram_socket::recv. + + @param buffers The buffers to receive into. + @param flags Message flags (e.g. `message_flags::peek`). + + @return An awaitable yielding the error code and the byte count received. */ template [[nodiscard]] auto recv(MB const& buffers, corosio::message_flags flags) diff --git a/include/boost/corosio/native/native_local_stream_acceptor.hpp b/include/boost/corosio/native/native_local_stream_acceptor.hpp index 88b8cffeb..605aa9d82 100644 --- a/include/boost/corosio/native/native_local_stream_acceptor.hpp +++ b/include/boost/corosio/native/native_local_stream_acceptor.hpp @@ -39,13 +39,13 @@ namespace boost::corosio { -/** An asynchronous Unix stream acceptor with devirtualized accept. +/** Accepts Unix domain stream connections, calling the backend directly. - This class template inherits from @ref local_stream_acceptor - and shadows both `accept` overloads (the peer-reference form - and the move-return form) with versions that call the backend - implementation directly, allowing the compiler to inline - through the entire call chain. The move-return form yields a + This class template inherits from @ref local_stream_acceptor. It + shadows both `accept` overloads (the peer-reference form and the + move-return form) with versions that call the backend implementation + directly. The compiler can then inline through the entire call + chain. The move-return form yields a @ref native_local_stream_socket so subsequent I/O on the peer is also devirtualized. @@ -161,7 +161,7 @@ class native_local_stream_acceptor : public local_stream_acceptor public: /** Construct a native acceptor from an execution context. - @param ctx The execution context that will own this acceptor. + @param ctx The execution context that owns this acceptor. */ explicit native_local_stream_acceptor(capy::execution_context& ctx) : local_stream_acceptor(create_handle(ctx), ctx) @@ -170,7 +170,7 @@ class native_local_stream_acceptor : public local_stream_acceptor /** Construct a native acceptor from an executor. - @param ex The executor whose context will own the acceptor. + @param ex The executor whose context owns the acceptor. */ template requires(!std::same_as< @@ -190,7 +190,9 @@ class native_local_stream_acceptor : public local_stream_acceptor native_local_stream_acceptor& operator=(native_local_stream_acceptor&&) noexcept = default; + /// Copy construction is disabled; the handle is uniquely owned. native_local_stream_acceptor(native_local_stream_acceptor const&) = delete; + /// Copy assignment is disabled; the handle is uniquely owned. native_local_stream_acceptor& operator=(native_local_stream_acceptor const&) = delete; @@ -228,7 +230,7 @@ class native_local_stream_acceptor : public local_stream_acceptor A closed acceptor reports `errc::bad_file_descriptor`. - @throws std::logic_error If the acceptor has been moved from. + @throws std::logic_error If the acceptor is moved-from. This acceptor must outlive the returned awaitable. */ diff --git a/include/boost/corosio/native/native_local_stream_socket.hpp b/include/boost/corosio/native/native_local_stream_socket.hpp index 4e05d5947..dba151926 100644 --- a/include/boost/corosio/native/native_local_stream_socket.hpp +++ b/include/boost/corosio/native/native_local_stream_socket.hpp @@ -39,13 +39,12 @@ namespace boost::corosio { -/** An asynchronous Unix stream socket with devirtualized I/O operations. +/** Reads and writes a Unix domain stream, calling the backend directly. - This class template inherits from @ref local_stream_socket and - shadows the async operations (`read_some`, `write_some`, - `connect`) with versions that call the backend implementation - directly, allowing the compiler to inline through the entire - call chain. + This class template inherits from @ref local_stream_socket. It + shadows the async operations (`read_some`, `write_some`, `connect`) + with versions that call the backend implementation directly. The + compiler can then inline through the entire call chain. Non-async operations (`open`, `close`, `cancel`, socket options) remain unchanged and dispatch through the compiled library. @@ -168,7 +167,7 @@ class native_local_stream_socket : public local_stream_socket public: /** Construct a native socket from an execution context. - @param ctx The execution context that will own this socket. + @param ctx The execution context that owns this socket. */ explicit native_local_stream_socket(capy::execution_context& ctx) : io_object(create_handle(ctx)) @@ -177,7 +176,7 @@ class native_local_stream_socket : public local_stream_socket /** Construct a native socket from an executor. - @param ex The executor whose context will own the socket. + @param ex The executor whose context owns the socket. */ template requires(!std::same_as< @@ -196,7 +195,9 @@ class native_local_stream_socket : public local_stream_socket native_local_stream_socket& operator=(native_local_stream_socket&&) noexcept = default; + /// Copy construction is disabled; the handle is uniquely owned. native_local_stream_socket(native_local_stream_socket const&) = delete; + /// Copy assignment is disabled; the handle is uniquely owned. native_local_stream_socket& operator=(native_local_stream_socket const&) = delete; diff --git a/include/boost/corosio/native/native_random_access_file.hpp b/include/boost/corosio/native/native_random_access_file.hpp index a06016e3d..4ea06a27c 100644 --- a/include/boost/corosio/native/native_random_access_file.hpp +++ b/include/boost/corosio/native/native_random_access_file.hpp @@ -32,12 +32,12 @@ namespace boost::corosio { -/** A random-access file with devirtualized async I/O operations. +/** Reads and writes a file at arbitrary offsets, calling the backend directly. - This class template inherits from @ref random_access_file and - shadows `read_some_at` / `write_some_at` with versions that - call the backend implementation directly, allowing the compiler - to inline through the entire call chain. + This class template inherits from @ref random_access_file. It + shadows `read_some_at` / `write_some_at` with versions that call the + backend implementation directly. The compiler can then inline + through the entire call chain. Non-async operations (`open`, `close`, `size`, `resize`, `sync_data`, `sync_all`) remain unchanged and dispatch through @@ -47,9 +47,9 @@ namespace boost::corosio { can be passed to any function expecting `random_access_file&`, in which case virtual dispatch is used transparently. - @note On POSIX platforms, file I/O is dispatched to a thread - pool regardless of the chosen reactor backend, so all three - reactor tags (`epoll`, `select`, `kqueue`) resolve to the same + @note On POSIX platforms, file I/O is dispatched to a thread pool + regardless of the chosen reactor backend. All three reactor tags + (`epoll`, `select`, `kqueue`) therefore resolve to the same underlying implementation. The `Backend` template parameter exists for API symmetry with @ref native_tcp_socket and friends. The vtable savings are smaller relative to the thread-pool / @@ -134,7 +134,7 @@ class native_random_access_file : public random_access_file public: /** Construct a native random-access file from an execution context. - @param ctx The execution context that will own this file. + @param ctx The execution context that owns this file. */ explicit native_random_access_file(capy::execution_context& ctx) : random_access_file(create_handle(ctx)) @@ -143,7 +143,7 @@ class native_random_access_file : public random_access_file /** Construct a native random-access file from an executor. - @param ex The executor whose context will own this file. + @param ex The executor whose context owns this file. */ template requires(!std::same_as< @@ -162,7 +162,9 @@ class native_random_access_file : public random_access_file native_random_access_file& operator=(native_random_access_file&&) noexcept = default; + /// Copy construction is disabled; the handle is uniquely owned. native_random_access_file(native_random_access_file const&) = delete; + /// Copy assignment is disabled; the handle is uniquely owned. native_random_access_file& operator=(native_random_access_file const&) = delete; @@ -170,6 +172,11 @@ class native_random_access_file : public random_access_file Calls the backend implementation directly, bypassing virtual dispatch. Otherwise identical to @ref random_access_file::read_some_at. + + @param offset The byte offset to read at. + @param buffers The buffers to read into. + + @return An awaitable yielding the error code and the byte count read. */ template [[nodiscard]] auto read_some_at(std::uint64_t offset, MB const& buffers) @@ -181,6 +188,11 @@ class native_random_access_file : public random_access_file Calls the backend implementation directly, bypassing virtual dispatch. Otherwise identical to @ref random_access_file::write_some_at. + + @param offset The byte offset to write at. + @param buffers The buffer data to write. + + @return An awaitable yielding the error code and the byte count written. */ template [[nodiscard]] auto write_some_at(std::uint64_t offset, CB const& buffers) diff --git a/include/boost/corosio/native/native_resolver.hpp b/include/boost/corosio/native/native_resolver.hpp index d73b8831a..d517466eb 100644 --- a/include/boost/corosio/native/native_resolver.hpp +++ b/include/boost/corosio/native/native_resolver.hpp @@ -28,12 +28,12 @@ namespace boost::corosio { -/** An asynchronous DNS resolver with devirtualized operations. +/** Resolves host names to endpoints, calling the backend directly. - This class template inherits from @ref resolver and shadows - the `resolve` operations with versions that call the backend - implementation directly, allowing the compiler to inline - through the entire call chain. + This class template inherits from @ref resolver. It shadows the + `resolve` operations with versions that call the backend + implementation directly. The compiler can then inline through the + entire call chain. Non-async operations (`cancel`) remain unchanged and dispatch through the compiled library. @@ -116,13 +116,13 @@ class native_resolver : public resolver public: /** Construct a native resolver from an execution context. - @param ctx The execution context that will own this resolver. + @param ctx The execution context that owns this resolver. */ explicit native_resolver(capy::execution_context& ctx) : resolver(ctx) {} /** Construct a native resolver from an executor. - @param ex The executor whose context will own the resolver. + @param ex The executor whose context owns the resolver. */ template requires(!std::same_as, native_resolver>) && @@ -133,15 +133,17 @@ class native_resolver : public resolver /** Move construct. - @pre No awaitables returned by @p other's `resolve` methods + @pre No awaitables returned by the source's `resolve` methods exist. - @pre The execution context associated with @p other must + @pre The execution context associated with the source must outlive this resolver. */ native_resolver(native_resolver&&) noexcept = default; /** Move assign. + @return Reference to this resolver. + @pre No awaitables returned by either `*this` or the source's `resolve` methods exist. @pre The execution context associated with the source must @@ -149,7 +151,9 @@ class native_resolver : public resolver */ native_resolver& operator=(native_resolver&&) noexcept = default; - native_resolver(native_resolver const&) = delete; + /// Copy construction is disabled; the handle is uniquely owned. + native_resolver(native_resolver const&) = delete; + /// Copy assignment is disabled; the handle is uniquely owned. native_resolver& operator=(native_resolver const&) = delete; /** Asynchronously resolve a host and service to endpoints. diff --git a/include/boost/corosio/native/native_signal_set.hpp b/include/boost/corosio/native/native_signal_set.hpp index 7879c6952..df0f391ce 100644 --- a/include/boost/corosio/native/native_signal_set.hpp +++ b/include/boost/corosio/native/native_signal_set.hpp @@ -27,12 +27,12 @@ namespace boost::corosio { -/** An asynchronous signal set with devirtualized wait operations. +/** Waits for a registered signal, calling the backend directly. - This class template inherits from @ref signal_set and shadows - the `wait` operation with a version that calls the backend - implementation directly, allowing the compiler to inline - through the entire call chain. + This class template inherits from @ref signal_set. It shadows the + `wait` operation with a version that calls the backend + implementation directly. The compiler can then inline through the + entire call chain. Non-async operations (`add`, `remove`, `clear`, `cancel`) remain unchanged and dispatch through the compiled library. @@ -79,7 +79,7 @@ class native_signal_set : public signal_set public: /** Construct a native signal set from an execution context. - @param ctx The execution context that will own this signal set. + @param ctx The execution context that owns this signal set. */ explicit native_signal_set(capy::execution_context& ctx) : signal_set(ctx) { @@ -87,7 +87,7 @@ class native_signal_set : public signal_set /** Construct a native signal set with initial signals. - @param ctx The execution context that will own this signal set. + @param ctx The execution context that owns this signal set. @param signal First signal number to add. @param signals Additional signal numbers to add. @@ -105,26 +105,24 @@ class native_signal_set : public signal_set /** Move construct. - @param other The signal set to move from. - - @pre No awaitables returned by @p other's methods exist. - @pre The execution context associated with @p other must + @pre No awaitables returned by the source's methods exist. + @pre The execution context associated with the source must outlive this signal set. */ native_signal_set(native_signal_set&&) noexcept = default; /** Move assign. - @param other The signal set to move from. - - @pre No awaitables returned by either `*this` or @p other's + @pre No awaitables returned by either `*this` or the source's methods exist. - @pre The execution context associated with @p other must + @pre The execution context associated with the source must outlive this signal set. */ native_signal_set& operator=(native_signal_set&&) noexcept = default; - native_signal_set(native_signal_set const&) = delete; + /// Copy construction is disabled; the handle is uniquely owned. + native_signal_set(native_signal_set const&) = delete; + /// Copy assignment is disabled; the handle is uniquely owned. native_signal_set& operator=(native_signal_set const&) = delete; /** Wait for a signal to be delivered. diff --git a/include/boost/corosio/native/native_socket_option.hpp b/include/boost/corosio/native/native_socket_option.hpp index 733497a7b..300585c06 100644 --- a/include/boost/corosio/native/native_socket_option.hpp +++ b/include/boost/corosio/native/native_socket_option.hpp @@ -246,8 +246,8 @@ class integer /** A boolean socket option with single-byte storage. Some BSD-derived kernels (macOS, FreeBSD) require certain IPv4 multicast - options (`IP_MULTICAST_LOOP`) to be set with a one-byte value and return - `EINVAL` for the four-byte form that Linux accepts. This template + options (`IP_MULTICAST_LOOP`) to be set with a one-byte value. They + return `EINVAL` for the four-byte form that Linux accepts. This template provides `unsigned char` storage so the option works on every platform. @tparam Level The protocol level. @@ -490,7 +490,7 @@ using reuse_port = boolean; /** Enable loopback of outgoing multicast (IP_MULTICAST_LOOP / IPV6_MULTICAST_LOOP). - The socket's family selects the wire rendering: a single byte + The socket's family selects the wire rendering. A single byte at `IPPROTO_IP` for IPv4 (BSD-derived kernels reject the four-byte form), an `int` at `IPPROTO_IPV6` for IPv6. */ @@ -670,10 +670,10 @@ class multicast_hops /** A multicast membership request. - The group's family — not the socket's — selects the wire - struct and protocol level: a v4 group renders as an `ip_mreq` - at the IPv4 level even when applied to a dual-stack v6 socket, - which is the level such a join actually targets. + The group's family — not the socket's — selects the wire struct and + protocol level. A v4 group renders as an `ip_mreq` at the IPv4 level + even when applied to a dual-stack v6 socket. That is the level such a + join actually targets. @tparam Level4 The IPv4 protocol level. @tparam Name4 The IPv4 option name. @@ -793,10 +793,10 @@ using leave_group = membership_request< /** Set the outgoing multicast interface (IP_MULTICAST_IF / IPV6_MULTICAST_IF). - The two families name interfaces differently on the wire — IPv4 - by interface address, IPv6 by interface index — so the option - stores both renderings and the socket's family selects one; the - other stays at its default (any address, kernel-chosen index). + The two families name interfaces differently on the wire: IPv4 by + interface address, IPv6 by interface index. The option stores both + renderings and the socket's family selects one; the other stays at its + default (any address, kernel-chosen index). */ class multicast_interface { diff --git a/include/boost/corosio/native/native_stream_file.hpp b/include/boost/corosio/native/native_stream_file.hpp index 0d60c0da7..c580930a5 100644 --- a/include/boost/corosio/native/native_stream_file.hpp +++ b/include/boost/corosio/native/native_stream_file.hpp @@ -32,12 +32,12 @@ namespace boost::corosio { -/** A sequential file with devirtualized async I/O operations. +/** Reads and writes a file sequentially, calling the backend directly. - This class template inherits from @ref stream_file and shadows + This class template inherits from @ref stream_file. It shadows `read_some` / `write_some` with versions that call the backend - implementation directly, allowing the compiler to inline through - the entire call chain. + implementation directly. The compiler can then inline through the + entire call chain. Non-async operations (`open`, `close`, `size`, `resize`, `seek`, `sync_data`, `sync_all`) remain unchanged and dispatch through @@ -47,9 +47,9 @@ namespace boost::corosio { any function expecting `stream_file&` or `io_stream&`, in which case virtual dispatch is used transparently. - @note On POSIX platforms, file I/O is dispatched to a thread - pool regardless of the chosen reactor backend, so all three - reactor tags (`epoll`, `select`, `kqueue`) resolve to the same + @note On POSIX platforms, file I/O is dispatched to a thread pool + regardless of the chosen reactor backend. All three reactor tags + (`epoll`, `select`, `kqueue`) therefore resolve to the same underlying implementation. The `Backend` template parameter exists for API symmetry with @ref native_tcp_socket and friends. The vtable savings are smaller relative to the thread-pool / @@ -124,7 +124,7 @@ class native_stream_file : public stream_file public: /** Construct a native stream file from an execution context. - @param ctx The execution context that will own this file. + @param ctx The execution context that owns this file. */ explicit native_stream_file(capy::execution_context& ctx) : io_object(create_handle(ctx)) @@ -133,7 +133,7 @@ class native_stream_file : public stream_file /** Construct a native stream file from an executor. - @param ex The executor whose context will own this file. + @param ex The executor whose context owns this file. */ template requires(!std::same_as, native_stream_file>) && @@ -148,13 +148,19 @@ class native_stream_file : public stream_file /// Move assign. native_stream_file& operator=(native_stream_file&&) noexcept = default; - native_stream_file(native_stream_file const&) = delete; + /// Copy construction is disabled; the handle is uniquely owned. + native_stream_file(native_stream_file const&) = delete; + /// Copy assignment is disabled; the handle is uniquely owned. native_stream_file& operator=(native_stream_file const&) = delete; /** Asynchronously read data from the file. Calls the backend implementation directly, bypassing virtual dispatch. Otherwise identical to @ref io_stream::read_some. + + @param buffers The buffers to read into. + + @return An awaitable yielding the error code and the byte count read. */ template [[nodiscard]] auto read_some(MB const& buffers) @@ -166,6 +172,10 @@ class native_stream_file : public stream_file Calls the backend implementation directly, bypassing virtual dispatch. Otherwise identical to @ref io_stream::write_some. + + @param buffers The buffer data to write. + + @return An awaitable yielding the error code and the byte count written. */ template [[nodiscard]] auto write_some(CB const& buffers) diff --git a/include/boost/corosio/native/native_tcp_acceptor.hpp b/include/boost/corosio/native/native_tcp_acceptor.hpp index dea4b677d..9077973b9 100644 --- a/include/boost/corosio/native/native_tcp_acceptor.hpp +++ b/include/boost/corosio/native/native_tcp_acceptor.hpp @@ -38,12 +38,12 @@ namespace boost::corosio { -/** An asynchronous TCP acceptor with devirtualized accept operations. +/** Accepts TCP connections, calling the backend directly. - This class template inherits from @ref tcp_acceptor and shadows - the `accept` operation with a version that calls the backend - implementation directly, allowing the compiler to inline through - the entire call chain. + This class template inherits from @ref tcp_acceptor. It shadows the + `accept` operation with a version that calls the backend + implementation directly. The compiler can then inline through the + entire call chain. Non-async operations (`listen`, `close`, `cancel`) remain unchanged and dispatch through the compiled library. @@ -148,7 +148,7 @@ class native_tcp_acceptor : public tcp_acceptor public: /** Construct a native acceptor from an execution context. - @param ctx The execution context that will own this acceptor. + @param ctx The execution context that owns this acceptor. */ explicit native_tcp_acceptor(capy::execution_context& ctx) : tcp_acceptor(create_handle(ctx)) @@ -157,7 +157,11 @@ class native_tcp_acceptor : public tcp_acceptor /** Construct a native acceptor from an executor. - @param ex The executor whose context will own the acceptor. + @param ex The executor whose context owns the acceptor. + + @tparam Ex A type satisfying @ref capy::Executor. Must not + be `native_tcp_acceptor` itself (disables implicit + conversion from move). */ template requires(!std::same_as, native_tcp_acceptor>) && @@ -188,7 +192,9 @@ class native_tcp_acceptor : public tcp_acceptor */ native_tcp_acceptor& operator=(native_tcp_acceptor&&) noexcept = default; - native_tcp_acceptor(native_tcp_acceptor const&) = delete; + /// Copy construction is disabled; the handle is uniquely owned. + native_tcp_acceptor(native_tcp_acceptor const&) = delete; + /// Copy assignment is disabled; the handle is uniquely owned. native_tcp_acceptor& operator=(native_tcp_acceptor const&) = delete; /** Asynchronously accept an incoming connection. @@ -222,7 +228,7 @@ class native_tcp_acceptor : public tcp_acceptor A closed acceptor reports `errc::bad_file_descriptor`. - @throws std::logic_error If the acceptor has been moved from. + @throws std::logic_error If the acceptor is moved-from. This acceptor must outlive the returned awaitable. */ diff --git a/include/boost/corosio/native/native_tcp_socket.hpp b/include/boost/corosio/native/native_tcp_socket.hpp index 445c5f9a1..9ab417f70 100644 --- a/include/boost/corosio/native/native_tcp_socket.hpp +++ b/include/boost/corosio/native/native_tcp_socket.hpp @@ -39,19 +39,19 @@ namespace boost::corosio { -/** An asynchronous TCP socket with devirtualized I/O operations. +/** Connects, reads, and writes over TCP, calling the backend directly. - This class template inherits from @ref tcp_socket and shadows - the async operations (`read_some`, `write_some`, `connect`) with - versions that call the backend implementation directly, allowing - the compiler to inline through the entire call chain. + This class template inherits from @ref tcp_socket. It shadows the + async operations (`read_some`, `write_some`, `connect`) with + versions that call the backend implementation directly. The compiler + can then inline through the entire call chain. Non-async operations (`open`, `close`, `cancel`, socket options) remain unchanged and dispatch through the compiled library. A `native_tcp_socket` IS-A `tcp_socket` and can be passed to - any function expecting `tcp_socket&` or `io_stream&`, in which - case virtual dispatch is used transparently. + any function expecting `tcp_socket&` or `io_stream&`. In that + case, virtual dispatch is used transparently. @tparam Backend A backend tag value (e.g., `epoll`, `iocp`) whose type provides the concrete implementation @@ -162,7 +162,7 @@ class native_tcp_socket : public tcp_socket public: /** Construct a native socket from an execution context. - @param ctx The execution context that will own this socket. + @param ctx The execution context that owns this socket. */ explicit native_tcp_socket(capy::execution_context& ctx) : io_object(create_handle(ctx)) @@ -171,7 +171,11 @@ class native_tcp_socket : public tcp_socket /** Construct a native socket from an executor. - @param ex The executor whose context will own the socket. + @param ex The executor whose context owns the socket. + + @tparam Ex A type satisfying @ref capy::Executor. Must not + be `native_tcp_socket` itself (disables implicit + conversion from move). */ template requires(!std::same_as, native_tcp_socket>) && @@ -205,7 +209,9 @@ class native_tcp_socket : public tcp_socket */ native_tcp_socket& operator=(native_tcp_socket&&) noexcept = default; - native_tcp_socket(native_tcp_socket const&) = delete; + /// Copy construction is disabled; the handle is uniquely owned. + native_tcp_socket(native_tcp_socket const&) = delete; + /// Copy assignment is disabled; the handle is uniquely owned. native_tcp_socket& operator=(native_tcp_socket const&) = delete; /** Asynchronously read data from the socket. diff --git a/include/boost/corosio/native/native_udp_socket.hpp b/include/boost/corosio/native/native_udp_socket.hpp index be1c54db9..60dadf4da 100644 --- a/include/boost/corosio/native/native_udp_socket.hpp +++ b/include/boost/corosio/native/native_udp_socket.hpp @@ -39,13 +39,12 @@ namespace boost::corosio { -/** An asynchronous UDP socket with devirtualized I/O operations. +/** Sends and receives UDP datagrams, calling the backend directly. - This class template inherits from @ref udp_socket and shadows - the async operations (`send_to`, `recv_from`, `connect`, `send`, - `recv`) with versions that call the backend implementation - directly, allowing the compiler to inline through the entire - call chain. + This class template inherits from @ref udp_socket. It shadows the + async operations (`send_to`, `recv_from`, `connect`, `send`, `recv`) + with versions that call the backend implementation directly. The + compiler can then inline through the entire call chain. Non-async operations (`open`, `close`, `cancel`, `bind`, socket options) remain unchanged and dispatch through the @@ -234,7 +233,7 @@ class native_udp_socket : public udp_socket public: /** Construct a native UDP socket from an execution context. - @param ctx The execution context that will own this socket. + @param ctx The execution context that owns this socket. */ explicit native_udp_socket(capy::execution_context& ctx) : udp_socket(create_handle(ctx)) @@ -243,7 +242,11 @@ class native_udp_socket : public udp_socket /** Construct a native UDP socket from an executor. - @param ex The executor whose context will own the socket. + @param ex The executor whose context owns the socket. + + @tparam Ex A type satisfying @ref capy::Executor. Must not + be `native_udp_socket` itself (disables implicit + conversion from move). */ template requires(!std::same_as, native_udp_socket>) && @@ -252,13 +255,30 @@ class native_udp_socket : public udp_socket { } - /// Move construct. + /** Move construct. + + @param other The socket to move from. + + @pre No awaitables returned by @p other's methods exist. + @pre The execution context associated with @p other must + outlive this socket. + */ native_udp_socket(native_udp_socket&&) noexcept = default; - /// Move assign. + /** Move assign. + + @param other The socket to move from. + + @pre No awaitables returned by either `*this` or @p other's + methods exist. + @pre The execution context associated with @p other must + outlive this socket. + */ native_udp_socket& operator=(native_udp_socket&&) noexcept = default; - native_udp_socket(native_udp_socket const&) = delete; + /// Copy construction is disabled; the handle is uniquely owned. + native_udp_socket(native_udp_socket const&) = delete; + /// Copy assignment is disabled; the handle is uniquely owned. native_udp_socket& operator=(native_udp_socket const&) = delete; /** Send a datagram to the specified destination. @@ -298,7 +318,7 @@ class native_udp_socket : public udp_socket dispatch. Otherwise identical to @ref udp_socket::recv_from. @param buffers The buffer sequence to receive data into. - @param source Reference to an endpoint that will be set to + @param source Reference to an endpoint that receives the sender's address on successful completion. @param flags Message flags (e.g. message_flags::peek). diff --git a/include/boost/corosio/openssl_stream.hpp b/include/boost/corosio/openssl_stream.hpp index add61822e..8d39b7be3 100644 --- a/include/boost/corosio/openssl_stream.hpp +++ b/include/boost/corosio/openssl_stream.hpp @@ -25,7 +25,7 @@ namespace boost::corosio { -/** A TLS stream using OpenSSL. +/** Encrypts and decrypts a stream using OpenSSL. This class wraps an underlying stream satisfying `capy::Stream` and provides TLS encryption using the OpenSSL library. @@ -38,21 +38,21 @@ namespace boost::corosio { Two construction modes are supported: - - **Owning**: Pass stream by value. The openssl_stream takes - ownership and the stream is moved into internal storage. + - **Owning**: Pass stream by value. The `openssl_stream` takes + ownership. The stream is moved into internal storage. - - **Reference**: Pass stream by pointer. The openssl_stream - does not own the stream; the caller must ensure the stream + - **Reference**: Pass stream by pointer. The `openssl_stream` + does not own the stream. The caller must ensure the stream outlives this object. @par Thread Safety Distinct objects: Safe.@n Shared objects: Unsafe, with one exception: one read operation and one write operation may be in flight simultaneously. `shutdown()` - may overlap a pending read. When the execution context runs on - multiple threads, all operations on one stream must be performed - within the same `capy::strand` (or otherwise never run - concurrently); a single-threaded context needs no strand. + may overlap a pending read. On a multi-threaded execution context, + all operations on one stream must run within the same + `capy::strand`, or must otherwise never run concurrently. A + single-threaded context needs no strand. @par Example @par !example openssl_stream @@ -72,11 +72,12 @@ class BOOST_COROSIO_DECL openssl_stream final : public tls_stream /** Construct an OpenSSL stream (owning mode). Takes ownership of the underlying stream by moving it into - internal storage. The stream will be destroyed when this - openssl_stream is destroyed. + internal storage. The stream is destroyed when this + `openssl_stream` is destroyed. @param stream The stream to take ownership of. Must satisfy - `capy::Stream`. + `capy::Stream` and must not be an `openssl_stream`; that + case binds to the move constructor instead. @param ctx The TLS context containing configuration. */ template @@ -91,7 +92,7 @@ class BOOST_COROSIO_DECL openssl_stream final : public tls_stream Wraps the underlying stream without taking ownership. The caller must ensure the stream remains valid for the lifetime - of this openssl_stream. + of this `openssl_stream`. @param stream Pointer to the stream to wrap. Must satisfy `capy::Stream`. @@ -104,7 +105,7 @@ class BOOST_COROSIO_DECL openssl_stream final : public tls_stream { } - /** Destructor. + /** Destroy the OpenSSL stream. Releases the underlying OpenSSL resources. If constructed in owning mode, also destroys the underlying stream. @@ -133,9 +134,8 @@ class BOOST_COROSIO_DECL openssl_stream final : public tls_stream completes, an error occurs, or the operation is cancelled via stop token. - @par Preconditions - The underlying stream must be connected. No other - TLS operation may be in progress on this stream. + @pre The underlying stream must be connected. No other + TLS operation may be in progress on this stream. @param role The handshake role, client or server. @@ -149,16 +149,15 @@ class BOOST_COROSIO_DECL openssl_stream final : public tls_stream close_notify response. Supports cancellation via stop token. - @par Preconditions - A handshake must have completed successfully. May overlap - a pending read; the read completes with `capy::error::eof` - when the peer answers the close_notify. No concurrent write - may be in progress. + @pre A handshake must have completed successfully. May overlap + a pending read. That read completes with `capy::error::eof` + when the peer answers the close_notify. No concurrent write + may be in progress. @par Postconditions If the transport ends before the peer's close_notify is received, the result is `capy::error::stream_truncated`, not - success. A shutdown stopped mid-flight reports canceled; any + success. A shutdown stopped mid-flight reports canceled. Any other transport error propagates unchanged. @return An awaitable yielding `(error_code)`. @@ -173,8 +172,7 @@ class BOOST_COROSIO_DECL openssl_stream final : public tls_stream resumed, so a handshake after `reset()` is always a full handshake. - @par Preconditions - No TLS operation may be in progress on this stream. + @pre No TLS operation may be in progress on this stream. @note If the backend cannot restore a clean session state, subsequent handshakes fail rather than proceed on a @@ -182,7 +180,11 @@ class BOOST_COROSIO_DECL openssl_stream final : public tls_stream */ void reset() override; - /// Set the peer hostname for SNI and certificate verification. + /** Set the peer hostname for SNI and certificate verification. + + @param hostname The peer name to send as SNI and match against the + certificate. + */ void set_hostname(std::string_view hostname) override; /// Return the underlying stream. @@ -204,10 +206,12 @@ class BOOST_COROSIO_DECL openssl_stream final : public tls_stream std::string_view alpn_protocol() const noexcept override; protected: + /// @copydoc tls_stream::do_read_some capy::io_task do_read_some( capy::detail::mutable_buffer_array buffers) override; + /// @copydoc tls_stream::do_write_some capy::io_task do_write_some( capy::detail::const_buffer_array buffers) override; @@ -222,8 +226,8 @@ class BOOST_COROSIO_DECL openssl_stream final : public tls_stream Errors reported by @ref openssl_stream that originate from the OpenSSL error queue (`ERR_get_error`) are assigned this category. Its `message()` decodes the packed OpenSSL error code using OpenSSL's own - diagnostic strings, so printing such an `error_code` yields a readable - description (for example, "certificate verify failed"). + diagnostic strings. Printing such an `error_code` therefore yields a + readable description, for example "certificate verify failed". OpenSSL errors whose library is `ERR_LIB_SYS` are reported with `std::system_category()` instead, since their reason code is a genuine diff --git a/include/boost/corosio/random_access_file.hpp b/include/boost/corosio/random_access_file.hpp index 14cb8f418..047600d8e 100644 --- a/include/boost/corosio/random_access_file.hpp +++ b/include/boost/corosio/random_access_file.hpp @@ -36,7 +36,7 @@ namespace boost::corosio { -/** An asynchronous random-access file for coroutine I/O. +/** Reads and writes a file at arbitrary offsets, from a coroutine. Provides asynchronous read and write operations at explicit byte offsets, without maintaining an implicit file position. @@ -47,10 +47,8 @@ namespace boost::corosio { @par Thread Safety Distinct objects: Safe.@n - Shared objects: Unsafe. Multiple concurrent reads and writes - are supported from coroutines sharing the same file object, - but external synchronization is required for non-async - operations (open, close, size, resize, etc.). + Shared objects: Unsafe. Coroutines sharing the same file object may + run multiple concurrent reads and writes. Non-async operations such as open, close, size, and resize require external synchronization. @par Example @par !example random_access_file @@ -58,7 +56,8 @@ namespace boost::corosio { class BOOST_COROSIO_DECL random_access_file : public io_object { public: - /** Platform-specific random-access file implementation interface. + /** Declares the offset-based file operations a platform backend + must implement. Backends derive from this to provide offset-based file I/O. */ @@ -113,19 +112,36 @@ class BOOST_COROSIO_DECL random_access_file : public io_object /// Return the file size in bytes. virtual std::uint64_t size() const = 0; - /// Resize the file to @p new_size bytes. + /** Resize the file to @p new_size bytes. + + @param new_size The requested size in bytes. + + @return The error code, empty on success. + */ virtual std::error_code resize(std::uint64_t new_size) noexcept = 0; - /// Synchronize file data to stable storage. + /** Synchronize file data to stable storage. + + @return The error code, empty on success. + */ virtual std::error_code sync_data() noexcept = 0; - /// Synchronize file data and metadata to stable storage. + /** Synchronize file data and metadata to stable storage. + + @return The error code, empty on success. + */ virtual std::error_code sync_all() noexcept = 0; /// Release ownership of the native handle. virtual native_handle_type release() = 0; - /// Adopt an existing native handle. + /** Adopt an existing native handle. + + @param handle The native handle to adopt. The implementation takes + ownership and closes it. + + @return The error code, empty on success. + */ virtual std::error_code assign(native_handle_type handle) noexcept = 0; }; @@ -134,6 +150,11 @@ class BOOST_COROSIO_DECL random_access_file : public io_object struct read_some_at_awaitable : detail::bytes_op_base> { + private: + friend random_access_file; + friend detail::bytes_op_base< + read_some_at_awaitable>; + random_access_file& f_; std::uint64_t offset_; MutableBufferSequence buffers_; @@ -165,6 +186,11 @@ class BOOST_COROSIO_DECL random_access_file : public io_object struct write_some_at_awaitable : detail::bytes_op_base> { + private: + friend random_access_file; + friend detail::bytes_op_base< + write_some_at_awaitable>; + random_access_file& f_; std::uint64_t offset_; ConstBufferSequence buffers_; @@ -200,13 +226,13 @@ class BOOST_COROSIO_DECL random_access_file : public io_object /** Construct from an execution context. - @param ctx The execution context that will own this file. + @param ctx The execution context that owns this file. */ explicit random_access_file(capy::execution_context& ctx); /** Construct from an executor. - @param ex The executor whose context will own this file. + @param ex The executor whose context owns this file. */ template requires(!std::same_as, random_access_file>) && @@ -232,7 +258,9 @@ class BOOST_COROSIO_DECL random_access_file : public io_object return *this; } - random_access_file(random_access_file const&) = delete; + /// Copy construction is disabled; the handle is uniquely owned. + random_access_file(random_access_file const&) = delete; + /// Copy assignment is disabled; the handle is uniquely owned. random_access_file& operator=(random_access_file const&) = delete; /** Open a file. @@ -254,12 +282,17 @@ class BOOST_COROSIO_DECL random_access_file : public io_object /** Close the file. - Releases file resources. Any pending operations complete - with `errc::operation_canceled`. + Releases file resources. Pending operations complete through the + same path as @ref cancel: one still in flight completes with + `errc::operation_canceled`. An operation whose result is already + decided reports that result. */ void close() noexcept; - /** Check if the file is open. */ + /** Check if the file is open. + + @return `true` if the file holds an open handle. + */ bool is_open() const noexcept { #if BOOST_COROSIO_HAS_IOCP && !defined(BOOST_COROSIO_MRDOCS) @@ -313,6 +346,8 @@ class BOOST_COROSIO_DECL random_access_file : public io_object /** Return the file size in bytes. + @return The current size of the file, in bytes. + @throws std::system_error If the file is not open, or if the underlying size query fails. */ diff --git a/include/boost/corosio/resolver.hpp b/include/boost/corosio/resolver.hpp index 0533aba1f..3bcac6874 100644 --- a/include/boost/corosio/resolver.hpp +++ b/include/boost/corosio/resolver.hpp @@ -37,7 +37,7 @@ namespace boost::corosio { /** Bitmask flags for resolver queries. - These flags correspond to the hints parameter of getaddrinfo. + These flags correspond to the hints parameter of `getaddrinfo`. */ enum class resolve_flags : unsigned int { @@ -69,7 +69,7 @@ enum class resolve_flags : unsigned int all_matching = 0x100 }; -/** Combine two resolve_flags. */ +/** Combine two `resolve_flags`. */ inline resolve_flags operator|(resolve_flags a, resolve_flags b) noexcept { @@ -77,7 +77,7 @@ operator|(resolve_flags a, resolve_flags b) noexcept static_cast(a) | static_cast(b)); } -/** Combine two resolve_flags. */ +/** Combine two `resolve_flags`. */ inline resolve_flags& operator|=(resolve_flags& a, resolve_flags b) noexcept { @@ -85,7 +85,7 @@ operator|=(resolve_flags& a, resolve_flags b) noexcept return a; } -/** Intersect two resolve_flags. */ +/** Intersect two `resolve_flags`. */ inline resolve_flags operator&(resolve_flags a, resolve_flags b) noexcept { @@ -93,7 +93,7 @@ operator&(resolve_flags a, resolve_flags b) noexcept static_cast(a) & static_cast(b)); } -/** Intersect two resolve_flags. */ +/** Intersect two `resolve_flags`. */ inline resolve_flags& operator&=(resolve_flags& a, resolve_flags b) noexcept { @@ -103,7 +103,7 @@ operator&=(resolve_flags& a, resolve_flags b) noexcept /** Bitmask flags for reverse resolver queries. - These flags correspond to the flags parameter of getnameinfo. + These flags correspond to the flags parameter of `getnameinfo`. */ enum class reverse_flags : unsigned int { @@ -123,7 +123,7 @@ enum class reverse_flags : unsigned int datagram_service = 0x08 }; -/** Combine two reverse_flags. */ +/** Combine two `reverse_flags`. */ inline reverse_flags operator|(reverse_flags a, reverse_flags b) noexcept { @@ -131,7 +131,7 @@ operator|(reverse_flags a, reverse_flags b) noexcept static_cast(a) | static_cast(b)); } -/** Combine two reverse_flags. */ +/** Combine two `reverse_flags`. */ inline reverse_flags& operator|=(reverse_flags& a, reverse_flags b) noexcept { @@ -139,7 +139,7 @@ operator|=(reverse_flags& a, reverse_flags b) noexcept return a; } -/** Intersect two reverse_flags. */ +/** Intersect two `reverse_flags`. */ inline reverse_flags operator&(reverse_flags a, reverse_flags b) noexcept { @@ -147,7 +147,7 @@ operator&(reverse_flags a, reverse_flags b) noexcept static_cast(a) & static_cast(b)); } -/** Intersect two reverse_flags. */ +/** Intersect two `reverse_flags`. */ inline reverse_flags& operator&=(reverse_flags& a, reverse_flags b) noexcept { @@ -171,7 +171,7 @@ struct endpoint_name std::string service_name; }; -/** An asynchronous DNS resolver for coroutine I/O. +/** Resolves host names and services to endpoints, from a coroutine. This class provides asynchronous DNS resolution operations that return awaitable types. Each operation participates in the affine awaitable @@ -183,8 +183,8 @@ struct endpoint_name operations. @par Semantics - Wraps platform DNS resolution (getaddrinfo/getnameinfo). - Operations dispatch to OS resolver APIs via the io_context + Wraps platform DNS resolution (`getaddrinfo`/`getnameinfo`). + Operations dispatch to OS resolver APIs via the `io_context` thread pool. @par Example @@ -195,10 +195,8 @@ class BOOST_COROSIO_DECL resolver : public io_object struct resolve_awaitable : detail::value_op_base> { - resolver& r_; - std::string host_; - std::string service_; - resolve_flags flags_; + private: + friend resolver; resolve_awaitable( resolver& r, @@ -212,6 +210,12 @@ class BOOST_COROSIO_DECL resolver : public io_object { } + friend detail::value_op_base>; + resolver& r_; + std::string host_; + std::string service_; + resolve_flags flags_; + std::coroutine_handle<> dispatch(std::coroutine_handle<> h, capy::executor_ref ex) const { @@ -223,9 +227,8 @@ class BOOST_COROSIO_DECL resolver : public io_object struct resolve_host_awaitable : detail::value_op_base> { - resolver& r_; - std::string host_; - resolve_flags flags_; + private: + friend resolver; resolve_host_awaitable( resolver& r, std::string_view host, resolve_flags flags) noexcept @@ -235,6 +238,12 @@ class BOOST_COROSIO_DECL resolver : public io_object { } + friend detail:: + value_op_base>; + resolver& r_; + std::string host_; + resolve_flags flags_; + std::coroutine_handle<> dispatch(std::coroutine_handle<> h, capy::executor_ref ex) const { @@ -244,6 +253,7 @@ class BOOST_COROSIO_DECL resolver : public io_object h, ex, host_, {}, flags_, token_, &ec_, &value_); } + public: // Shadows the base: the endpoint result is reshaped into // the honest address list [[nodiscard]] capy::io_result> @@ -276,9 +286,8 @@ class BOOST_COROSIO_DECL resolver : public io_object struct reverse_resolve_awaitable : detail::value_op_base { - resolver& r_; - endpoint ep_; - reverse_flags flags_; + private: + friend resolver; reverse_resolve_awaitable( resolver& r, endpoint const& ep, reverse_flags flags) noexcept @@ -288,6 +297,12 @@ class BOOST_COROSIO_DECL resolver : public io_object { } + friend detail::value_op_base; + + resolver& r_; + endpoint ep_; + reverse_flags flags_; + std::coroutine_handle<> dispatch(std::coroutine_handle<> h, capy::executor_ref ex) const { @@ -305,7 +320,7 @@ class BOOST_COROSIO_DECL resolver : public io_object /** Construct a resolver from an execution context. - @param ctx The execution context that will own this resolver. + @param ctx The execution context that owns this resolver. */ explicit resolver(capy::execution_context& ctx); @@ -313,7 +328,7 @@ class BOOST_COROSIO_DECL resolver : public io_object The resolver is associated with the executor's context. - @param ex The executor whose context will own the resolver. + @param ex The executor whose context owns the resolver. */ template requires(!std::same_as, resolver>) && @@ -359,7 +374,9 @@ class BOOST_COROSIO_DECL resolver : public io_object return *this; } - resolver(resolver const&) = delete; + /// Copy construction is disabled; the handle is uniquely owned. + resolver(resolver const&) = delete; + /// Copy assignment is disabled; the handle is uniquely owned. resolver& operator=(resolver const&) = delete; /** Initiate an asynchronous resolve operation. @@ -485,46 +502,74 @@ class BOOST_COROSIO_DECL resolver : public io_object /** Cancel any pending asynchronous operations. - Operations still in flight complete with `errc::operation_canceled`; - an operation whose result is already decided reports that result. - Check `ec == cond::canceled` for portable comparison. + A resolve transfers no bytes, so a cancellation always wins. An + operation reports `errc::operation_canceled` even when the lookup + had already completed when the cancellation landed. Check + `ec == cond::canceled` for a portable comparison. */ void cancel() noexcept; public: - /** Backend interface for DNS resolution operations. + /** Define backend hooks for DNS resolution operations. Platform backends derive from this to implement forward and - reverse DNS resolution via getaddrinfo/getnameinfo. + reverse DNS resolution via `getaddrinfo`/`getnameinfo`. */ struct implementation : io_object::implementation { - /// Initiate an asynchronous forward DNS resolution. + /** Initiate an asynchronous forward DNS resolution. + + @param h Coroutine handle to resume on completion. + @param ex Executor for dispatching the completion. + @param host The host name or address literal to resolve. + @param service The service name or port number. + @param flags Flags controlling the lookup. + @param token Stop token for cancellation. + @param ec Output error code. + @param results Output resolver results. + + @return Coroutine handle to resume immediately. + */ virtual std::coroutine_handle<> resolve( - std::coroutine_handle<>, - capy::executor_ref, + std::coroutine_handle<> h, + capy::executor_ref ex, std::string_view host, std::string_view service, resolve_flags flags, - std::stop_token, - std::error_code*, - std::vector*) = 0; - - /// Initiate an asynchronous reverse DNS resolution. + std::stop_token token, + std::error_code* ec, + std::vector* results) = 0; + + /** Initiate an asynchronous reverse DNS resolution. + + @param h Coroutine handle to resume on completion. + @param ex Executor for dispatching the completion. + @param ep The endpoint to resolve. + @param flags Flags controlling the lookup. + @param token Stop token for cancellation. + @param ec Output error code. + @param result Output reverse-resolution result. + + @return Coroutine handle to resume immediately. + */ virtual std::coroutine_handle<> reverse_resolve( - std::coroutine_handle<>, - capy::executor_ref, + std::coroutine_handle<> h, + capy::executor_ref ex, endpoint const& ep, reverse_flags flags, - std::stop_token, - std::error_code*, - endpoint_name*) = 0; + std::stop_token token, + std::error_code* ec, + endpoint_name* result) = 0; /// Cancel pending resolve operations. virtual void cancel() noexcept = 0; }; protected: + /** Adopt an existing handle. + + @param h The handle the resolver takes ownership of. + */ explicit resolver(handle h) noexcept : io_object(std::move(h)) {} private: diff --git a/include/boost/corosio/shutdown_type.hpp b/include/boost/corosio/shutdown_type.hpp index 275d56726..2f4401850 100644 --- a/include/boost/corosio/shutdown_type.hpp +++ b/include/boost/corosio/shutdown_type.hpp @@ -14,8 +14,8 @@ namespace boost::corosio { /** Different ways a socket may be shutdown. - Used by tcp_socket, local_stream_socket, and - local_datagram_socket to specify the direction of + Used by `tcp_socket`, `udp_socket`, `local_stream_socket`, and + `local_datagram_socket` to specify the direction of communication to disable. The enumerator values match the POSIX SHUT_RD / SHUT_WR / diff --git a/include/boost/corosio/signal_set.hpp b/include/boost/corosio/signal_set.hpp index 860dfcb11..cbee7c5ba 100644 --- a/include/boost/corosio/signal_set.hpp +++ b/include/boost/corosio/signal_set.hpp @@ -51,7 +51,7 @@ namespace boost::corosio { -/** An asynchronous signal set for coroutine I/O. +/** Waits from a coroutine for one of a registered set of signals. This class provides the ability to perform an asynchronous wait for one or more signals to occur. The signal set registers for @@ -60,13 +60,13 @@ namespace boost::corosio { @par Thread Safety Distinct objects: Safe.@n - Shared objects: Unsafe. A signal_set must not have concurrent + Shared objects: Unsafe. A `signal_set` must not have concurrent wait operations. @par Semantics - Wraps platform signal handling (sigaction on POSIX, C runtime + Wraps platform signal handling (`sigaction` on POSIX, C runtime signal() on Windows). Operations dispatch to OS signal APIs - via the io_context reactor. + via the `io_context` reactor. @par Supported Signals On Windows, the following signals are supported: @@ -84,7 +84,7 @@ class BOOST_COROSIO_DECL signal_set : public io_signal_set flags can be combined using the bitwise OR operator. @note Flags only have effect on POSIX systems. On Windows, - only `none` and `dont_care` are supported; other flags return + only `none` and `dont_care` are supported. Other flags return `operation_not_supported`. */ enum flags_t : unsigned @@ -154,7 +154,7 @@ class BOOST_COROSIO_DECL signal_set : public io_signal_set /** Define backend hooks for signal set operations. Platform backends derive from this to provide signal - registration via sigaction (POSIX) or the C runtime + registration via `sigaction` (POSIX) or the C runtime signal() function (Windows). */ struct implementation : io_signal_set::implementation @@ -191,13 +191,13 @@ class BOOST_COROSIO_DECL signal_set : public io_signal_set /** Construct an empty signal set. - @param ctx The execution context that will own this signal set. + @param ctx The execution context that owns this signal set. */ explicit signal_set(capy::execution_context& ctx); /** Construct a signal set with initial signals. - @param ctx The execution context that will own this signal set. + @param ctx The execution context that owns this signal set. @param signal First signal number to add. @param signals Additional signal numbers to add. @@ -222,7 +222,7 @@ class BOOST_COROSIO_DECL signal_set : public io_signal_set The signal set is associated with the executor's context. - @param ex The executor whose context will own this signal set. + @param ex The executor whose context owns this signal set. */ template requires(!std::same_as, signal_set>) && @@ -235,7 +235,7 @@ class BOOST_COROSIO_DECL signal_set : public io_signal_set The signal set is associated with the executor's context. - @param ex The executor whose context will own this signal set. + @param ex The executor whose context owns this signal set. @param signal First signal number to add. @param signals Additional signal numbers to add. @@ -278,7 +278,9 @@ class BOOST_COROSIO_DECL signal_set : public io_signal_set */ signal_set& operator=(signal_set&& other) noexcept; - signal_set(signal_set const&) = delete; + /// Copy construction is disabled; the handle is uniquely owned. + signal_set(signal_set const&) = delete; + /// Copy assignment is disabled; the handle is uniquely owned. signal_set& operator=(signal_set const&) = delete; /** Add a signal to the signal set. @@ -287,20 +289,19 @@ class BOOST_COROSIO_DECL signal_set : public io_signal_set specified flags. It has no effect if the signal is already in the set with the same flags. - If the signal is already registered globally (by another - signal_set) and the flags differ, an error is returned - unless one of them has the `dont_care` flag. + Another `signal_set` may already have registered the signal + globally. If the flags then differ, an error is returned unless + one of them has the `dont_care` flag. - The first signal registration on an execution context - installs the process signal-delivery pipe; if that - installation fails the error is returned, and the next - call retries it. + The first signal registration on an execution context installs + the process signal-delivery pipe. If that installation fails, + the error is returned. The next call retries it. @param signal_number The signal to be added to the set. @param flags The flags to apply when registering the signal. On POSIX systems, these map to sigaction() flags. - On Windows, only `none` and `dont_care` are supported; - other flags cause `errc::operation_not_supported` to + On Windows, only `none` and `dont_care` are supported. + Other flags cause `errc::operation_not_supported` to be returned. @return Success, or an error if the signal could not be added. @@ -343,6 +344,10 @@ class BOOST_COROSIO_DECL signal_set : public io_signal_set [[nodiscard]] std::error_code clear(); protected: + /** Adopt an existing handle. + + @param h The handle the signal set takes ownership of. + */ explicit signal_set(handle h) noexcept : io_signal_set(std::move(h)) {} private: diff --git a/include/boost/corosio/socket_option.hpp b/include/boost/corosio/socket_option.hpp index f120672c5..bcb42a985 100644 --- a/include/boost/corosio/socket_option.hpp +++ b/include/boost/corosio/socket_option.hpp @@ -184,7 +184,10 @@ class BOOST_COROSIO_DECL integer_option class BOOST_COROSIO_DECL no_delay : public boolean_option { public: + /// Inherit the base constructors. using boolean_option::boolean_option; + + /// Inherit assignment from the base. using boolean_option::operator=; /// Return the protocol level. @@ -202,7 +205,10 @@ class BOOST_COROSIO_DECL no_delay : public boolean_option class BOOST_COROSIO_DECL keep_alive : public boolean_option { public: + /// Inherit the base constructors. using boolean_option::boolean_option; + + /// Inherit assignment from the base. using boolean_option::operator=; /// Return the protocol level. @@ -224,7 +230,10 @@ class BOOST_COROSIO_DECL keep_alive : public boolean_option class BOOST_COROSIO_DECL v6_only : public boolean_option { public: + /// Inherit the base constructors. using boolean_option::boolean_option; + + /// Inherit assignment from the base. using boolean_option::operator=; /// Return the protocol level. @@ -242,7 +251,10 @@ class BOOST_COROSIO_DECL v6_only : public boolean_option class BOOST_COROSIO_DECL reuse_address : public boolean_option { public: + /// Inherit the base constructors. using boolean_option::boolean_option; + + /// Inherit assignment from the base. using boolean_option::operator=; /// Return the protocol level. @@ -264,7 +276,10 @@ class BOOST_COROSIO_DECL reuse_address : public boolean_option class BOOST_COROSIO_DECL broadcast : public boolean_option { public: + /// Inherit the base constructors. using boolean_option::boolean_option; + + /// Inherit assignment from the base. using boolean_option::operator=; /// Return the protocol level. @@ -285,7 +300,10 @@ class BOOST_COROSIO_DECL broadcast : public boolean_option class BOOST_COROSIO_DECL reuse_port : public boolean_option { public: + /// Inherit the base constructors. using boolean_option::boolean_option; + + /// Inherit assignment from the base. using boolean_option::operator=; /// Return the protocol level. @@ -303,7 +321,10 @@ class BOOST_COROSIO_DECL reuse_port : public boolean_option class BOOST_COROSIO_DECL receive_buffer_size : public integer_option { public: + /// Inherit the base constructors. using integer_option::integer_option; + + /// Inherit assignment from the base. using integer_option::operator=; /// Return the protocol level. @@ -321,7 +342,10 @@ class BOOST_COROSIO_DECL receive_buffer_size : public integer_option class BOOST_COROSIO_DECL send_buffer_size : public integer_option { public: + /// Inherit the base constructors. using integer_option::integer_option; + + /// Inherit assignment from the base. using integer_option::operator=; /// Return the protocol level. @@ -362,13 +386,19 @@ class BOOST_COROSIO_DECL linger /// Return whether linger is enabled. bool enabled() const noexcept; - /// Set whether linger is enabled. + /** Set whether linger is enabled. + + @param v `true` to linger on close. + */ void enabled(bool v) noexcept; /// Return the linger timeout in seconds. int timeout() const noexcept; - /// Set the linger timeout in seconds. + /** Set the linger timeout in seconds. + + @param v The timeout in seconds. + */ void timeout(int v) noexcept; /// Return the protocol level. @@ -395,8 +425,6 @@ class BOOST_COROSIO_DECL linger /** Normalize after `getsockopt`. No-op — `struct linger` is always returned at full size. - - @param s The number of bytes actually written by `getsockopt`. */ void resize(family, std::size_t) noexcept {} }; @@ -404,7 +432,7 @@ class BOOST_COROSIO_DECL linger /** Enable loopback of outgoing multicast (IP_MULTICAST_LOOP / IPV6_MULTICAST_LOOP). - The socket's family selects the wire rendering: a single byte + The socket's family selects the wire rendering. A single byte at the IPv4 level (BSD-derived kernels reject the four-byte form), an `int` at the IPv6 level. @@ -577,10 +605,10 @@ class BOOST_COROSIO_DECL multicast_hops /** Join a multicast group (IP_ADD_MEMBERSHIP / IPV6_JOIN_GROUP). - The group's family — not the socket's — selects the wire - struct and protocol level: a v4 group renders as an `ip_mreq` - at the IPv4 level even when applied to a dual-stack v6 socket, - which is the level such a join actually targets. + The group's family — not the socket's — selects the wire struct and + protocol level. A v4 group renders as an `ip_mreq` at the IPv4 level + even when applied to a dual-stack v6 socket. That is the level such a + join actually targets. @par Example @par !example join_group @@ -702,10 +730,10 @@ class BOOST_COROSIO_DECL leave_group /** Set the outgoing multicast interface (IP_MULTICAST_IF / IPV6_MULTICAST_IF). - The two families name interfaces differently on the wire — IPv4 - by interface address, IPv6 by interface index — so the option - stores both renderings and the socket's family selects one; the - other stays at its default (any address, kernel-chosen index). + The two families name interfaces differently on the wire: IPv4 by + interface address, IPv6 by interface index. The option stores both + renderings and the socket's family selects one; the other stays at its + default (any address, kernel-chosen index). @par Example @par !example multicast_interface diff --git a/include/boost/corosio/stream_file.hpp b/include/boost/corosio/stream_file.hpp index c89c34c44..53cee16bc 100644 --- a/include/boost/corosio/stream_file.hpp +++ b/include/boost/corosio/stream_file.hpp @@ -27,7 +27,7 @@ namespace boost::corosio { -/** An asynchronous sequential file for coroutine I/O. +/** Reads and writes a file sequentially, from a coroutine. Provides asynchronous read and write operations on a regular file with an implicit position that advances after each @@ -52,7 +52,7 @@ namespace boost::corosio { class BOOST_COROSIO_DECL stream_file : public io_stream { public: - /** Platform-specific file implementation interface. + /** Defines the file operations a platform backend implements. Backends derive from this to provide file I/O. `read_some` and `write_some` are inherited from @@ -66,22 +66,49 @@ class BOOST_COROSIO_DECL stream_file : public io_stream /// Cancel pending asynchronous operations. virtual void cancel() noexcept = 0; - /// Return the file size in bytes. + /** Return the file size in bytes. + + @return The current size of the file, in bytes. + + @throws std::system_error if the underlying size query fails. + */ virtual std::uint64_t size() const = 0; - /// Resize the file to @p new_size bytes. + /** Resize the file to @p new_size bytes. + + @param new_size The requested size in bytes. + + @return The error code, empty on success. + */ virtual std::error_code resize(std::uint64_t new_size) noexcept = 0; - /// Synchronize file data to stable storage. + /** Synchronize file data to stable storage. + + @return The error code, empty on success. + */ virtual std::error_code sync_data() noexcept = 0; - /// Synchronize file data and metadata to stable storage. + /** Synchronize file data and metadata to stable storage. + + @return The error code, empty on success. + */ virtual std::error_code sync_all() noexcept = 0; - /// Release ownership of the native handle. + /** Release ownership of the native handle. + + @return The native handle, which the caller now owns. + + @throws std::system_error if the file is not open. + */ virtual native_handle_type release() = 0; - /// Adopt an existing native handle. + /** Adopt an existing native handle. + + @param handle The native handle to adopt. The implementation takes + ownership and closes it. + + @return The error code, empty on success. + */ virtual std::error_code assign(native_handle_type handle) noexcept = 0; /** Move the file position. @@ -94,21 +121,19 @@ class BOOST_COROSIO_DECL stream_file : public io_stream seek(std::int64_t offset, file_base::seek_basis origin) noexcept = 0; }; - /** Destructor. - - Closes the file if open, cancelling any pending operations. + /** Closes the file if open, cancelling any pending operations. */ ~stream_file() override; /** Construct from an execution context. - @param ctx The execution context that will own this file. + @param ctx The execution context that owns this file. */ explicit stream_file(capy::execution_context& ctx); /** Construct from an executor. - @param ex The executor whose context will own this file. + @param ex The executor whose context owns this file. */ template requires(!std::same_as, stream_file>) && @@ -117,15 +142,13 @@ class BOOST_COROSIO_DECL stream_file : public io_stream { } - /** Move constructor. - - Transfers ownership of the file resources. + /** Transfers ownership of the file resources. */ stream_file(stream_file&& other) noexcept : io_object(std::move(other)) {} - /** Move assignment operator. + /** Closes any existing file and transfers ownership. - Closes any existing file and transfers ownership. + @return Reference to this object. */ stream_file& operator=(stream_file&& other) noexcept { @@ -137,7 +160,9 @@ class BOOST_COROSIO_DECL stream_file : public io_stream return *this; } - stream_file(stream_file const&) = delete; + /// Copy construction is disabled; the handle is uniquely owned. + stream_file(stream_file const&) = delete; + /// Copy assignment is disabled; the handle is uniquely owned. stream_file& operator=(stream_file const&) = delete; // read_some() inherited from io_read_stream @@ -162,8 +187,10 @@ class BOOST_COROSIO_DECL stream_file : public io_stream /** Close the file. - Releases file resources. Any pending operations complete - with `errc::operation_canceled`. + Releases file resources. Pending operations complete through the + same path as @ref cancel: one still in flight completes with + `errc::operation_canceled`. An operation whose result is already + decided reports that result. */ void close() noexcept; @@ -197,6 +224,8 @@ class BOOST_COROSIO_DECL stream_file : public io_stream /** Return the file size in bytes. + @return The file size in bytes. + @throws std::system_error If the file is not open, or if the underlying size query fails. */ @@ -250,8 +279,8 @@ class BOOST_COROSIO_DECL stream_file : public io_stream Closes any currently open file before adopting. The file object takes ownership of the handle. Handles - created elsewhere may be unsuitable for asynchronous I/O; - such failures are reported through the returned error code. + created elsewhere may be unsuitable for asynchronous I/O. + Such failures are reported through the returned error code. @param handle The native file descriptor or handle. @@ -279,7 +308,10 @@ class BOOST_COROSIO_DECL stream_file : public io_stream /// Default-construct (for derived types that initialize io_object directly). stream_file() noexcept = default; - /// Construct from a pre-built handle (for native_stream_file). + /** Construct from a pre-built handle (for native_stream_file). + + @param h The pre-built handle to adopt. + */ explicit stream_file(handle h) noexcept : io_object(std::move(h)) {} private: diff --git a/include/boost/corosio/tcp_acceptor.hpp b/include/boost/corosio/tcp_acceptor.hpp index 4eb682dec..cbd8886f3 100644 --- a/include/boost/corosio/tcp_acceptor.hpp +++ b/include/boost/corosio/tcp_acceptor.hpp @@ -37,7 +37,7 @@ namespace boost::corosio { -/** An asynchronous TCP acceptor for coroutine I/O. +/** Accepts inbound TCP connections, from a coroutine. This class provides asynchronous TCP accept operations that return awaitable types. The acceptor binds to a local endpoint and listens @@ -53,7 +53,7 @@ namespace boost::corosio { @par Semantics Wraps the platform TCP listener. Operations dispatch to - OS accept APIs via the io_context reactor. + OS accept APIs via the `io_context` reactor. @par Example @par !example convenience_construction @@ -65,8 +65,8 @@ class BOOST_COROSIO_DECL tcp_acceptor : public io_object { struct wait_awaitable : detail::void_op_base { - tcp_acceptor& acc_; - wait_type w_; + private: + friend tcp_acceptor; wait_awaitable(tcp_acceptor& acc, wait_type w) noexcept : acc_(acc) @@ -74,6 +74,11 @@ class BOOST_COROSIO_DECL tcp_acceptor : public io_object { } + friend detail::void_op_base; + + tcp_acceptor& acc_; + wait_type w_; + std::coroutine_handle<> dispatch(std::coroutine_handle<> h, capy::executor_ref ex) const { @@ -83,6 +88,10 @@ class BOOST_COROSIO_DECL tcp_acceptor : public io_object struct accept_awaitable : detail::void_op_base { + private: + friend tcp_acceptor; + friend detail::void_op_base; + tcp_acceptor& acc_; tcp_socket& peer_; mutable io_object::implementation* peer_impl_ = nullptr; @@ -93,23 +102,28 @@ class BOOST_COROSIO_DECL tcp_acceptor : public io_object { } - [[nodiscard]] capy::io_result<> await_resume() const noexcept - { - if (!this->ec_ && peer_impl_) - peer_.h_.reset(peer_impl_); - return {this->ec_}; - } - std::coroutine_handle<> dispatch(std::coroutine_handle<> h, capy::executor_ref ex) const { return acc_.get().accept( h, ex, this->token_, &this->ec_, &peer_impl_); } + + public: + [[nodiscard]] capy::io_result<> await_resume() const noexcept + { + if (!this->ec_ && peer_impl_) + peer_.h_.reset(peer_impl_); + return {this->ec_}; + } }; struct accept_value_awaitable : detail::void_op_base { + private: + friend tcp_acceptor; + friend detail::void_op_base; + tcp_acceptor& acc_; mutable io_object::implementation* peer_impl_ = nullptr; @@ -117,6 +131,14 @@ class BOOST_COROSIO_DECL tcp_acceptor : public io_object { } + std::coroutine_handle<> + dispatch(std::coroutine_handle<> h, capy::executor_ref ex) const + { + return acc_.get().accept( + h, ex, this->token_, &this->ec_, &peer_impl_); + } + + public: [[nodiscard]] capy::io_result await_resume() noexcept { // The peer is built only on success: error paths must not @@ -128,44 +150,35 @@ class BOOST_COROSIO_DECL tcp_acceptor : public io_object peer.h_.reset(peer_impl_); return {this->ec_, std::move(peer)}; } - - std::coroutine_handle<> - dispatch(std::coroutine_handle<> h, capy::executor_ref ex) const - { - return acc_.get().accept( - h, ex, this->token_, &this->ec_, &peer_impl_); - } }; public: - /** Destructor. - - Closes the acceptor if open, cancelling any pending operations. + /** Closes the acceptor if open, cancelling any pending operations. */ ~tcp_acceptor() override; /** Construct an acceptor from an execution context. - @param ctx The execution context that will own this acceptor. + @param ctx The execution context that owns this acceptor. */ explicit tcp_acceptor(capy::execution_context& ctx); /** Convenience constructor: open + configure + bind + listen. - Creates a fully-bound listening acceptor in a single + Creates a fully bound listening acceptor in a single expression, throwing the codes the piecewise `open()` + `set_option()` + `bind()` + `listen()` path reports. The address family is deduced from @p ep. - Before binding, the constructor configures address reuse so - a server can rebind its port immediately after a restart: - `SO_REUSEADDR` on POSIX, `SO_EXCLUSIVEADDRUSE` on Windows - ( where `SO_REUSEADDR` instead grants other sockets - bind-over rights ). A second listener on an occupied - endpoint therefore throws `errc::address_in_use` on every - platform. + Before binding, the constructor configures address reuse so a + server can rebind its port immediately after a restart. It + sets `SO_REUSEADDR` on POSIX and `SO_EXCLUSIVEADDRUSE` on + Windows. Windows does not use `SO_REUSEADDR` because it + instead grants other sockets bind-over rights. A second + listener on an occupied endpoint therefore throws + `errc::address_in_use` on every platform. - @param ctx The execution context that will own this acceptor. + @param ctx The execution context that owns this acceptor. @param ep The local endpoint to bind to. @param backlog The maximum pending connection queue length. @@ -176,9 +189,10 @@ class BOOST_COROSIO_DECL tcp_acceptor : public io_object /** Construct an acceptor from an executor. - The acceptor is associated with the executor's context. + The acceptor is associated with the executor's context. `Ex` + must satisfy `capy::Executor`. - @param ex The executor whose context will own the acceptor. + @param ex The executor whose context owns the acceptor. */ template requires(!std::same_as, tcp_acceptor>) && @@ -189,7 +203,22 @@ class BOOST_COROSIO_DECL tcp_acceptor : public io_object /** Convenience constructor from an executor. - @param ex The executor whose context will own the acceptor. + Creates a fully bound listening acceptor in a single + expression, throwing the codes the piecewise `open()` + + `set_option()` + `bind()` + `listen()` path reports. The + address family is deduced from @p ep. + + Before binding, the constructor configures address reuse so a + server can rebind its port immediately after a restart. It + sets `SO_REUSEADDR` on POSIX and `SO_EXCLUSIVEADDRUSE` on + Windows. Windows does not use `SO_REUSEADDR` because it + instead grants other sockets bind-over rights. A second + listener on an occupied endpoint therefore throws + `errc::address_in_use` on every platform. + + `Ex` must satisfy `capy::Executor`. + + @param ex The executor whose context owns the acceptor. @param ep The local endpoint to bind to. @param backlog The maximum pending connection queue length. @@ -203,9 +232,7 @@ class BOOST_COROSIO_DECL tcp_acceptor : public io_object { } - /** Move constructor. - - Transfers ownership of the acceptor resources. + /** Transfers ownership of the acceptor resources. @param other The acceptor to move from. @@ -215,9 +242,7 @@ class BOOST_COROSIO_DECL tcp_acceptor : public io_object */ tcp_acceptor(tcp_acceptor&& other) noexcept : io_object(std::move(other)) {} - /** Move assignment operator. - - Closes any existing acceptor and transfers ownership. + /** Closes any existing acceptor and transfers ownership. @param other The acceptor to move from. @@ -238,13 +263,15 @@ class BOOST_COROSIO_DECL tcp_acceptor : public io_object return *this; } - tcp_acceptor(tcp_acceptor const&) = delete; + /// Copy construction is disabled; the handle is uniquely owned. + tcp_acceptor(tcp_acceptor const&) = delete; + /// Copy assignment is disabled; the handle is uniquely owned. tcp_acceptor& operator=(tcp_acceptor const&) = delete; /** Create the acceptor socket without binding or listening. Creates a TCP socket with dual-stack enabled for IPv6. - Does not set SO_REUSEADDR — call `set_option` explicitly + Does not set SO_REUSEADDR. Call `set_option` explicitly if needed. If the acceptor is already open, this function is a no-op. @@ -281,8 +308,7 @@ class BOOST_COROSIO_DECL tcp_acceptor : public io_object on any local interface. @li `errc::permission_denied`: Insufficient privileges to bind to the endpoint (e.g., privileged port). - - A closed acceptor reports `errc::bad_file_descriptor`. + @li `errc::bad_file_descriptor`: The acceptor is not open. */ [[nodiscard]] std::error_code bind(endpoint ep) noexcept; @@ -329,18 +355,17 @@ class BOOST_COROSIO_DECL tcp_acceptor : public io_object `errc::operation_canceled`. @param peer The socket to receive the accepted connection. Any - existing connection on this socket will be closed. + existing connection on this socket is closed. @return An awaitable that completes with `io_result<>`. Returns success on successful accept, or an error code on failure including: - - operation_canceled: Cancelled via stop_token or cancel(). + - `operation_canceled`: Cancelled via stop_token or cancel(). Check `ec == cond::canceled` for portable comparison. A closed acceptor completes with `errc::bad_file_descriptor`. - @par Preconditions - The peer socket must be associated with the same execution context. + @pre The peer socket must be associated with the same execution context. Both this acceptor and @p peer must outlive the returned awaitable. @@ -364,7 +389,7 @@ class BOOST_COROSIO_DECL tcp_acceptor : public io_object socket for it, associated with this acceptor's execution context. The acceptor must be listening before calling this function. - The caller does not pre-construct the peer socket; the returned + The caller does not pre-construct the peer socket. The returned socket shares this acceptor's execution context. The operation supports cancellation via `std::stop_token` through @@ -376,15 +401,14 @@ class BOOST_COROSIO_DECL tcp_acceptor : public io_object On success the payload is the connected peer socket; on failure (including cancellation) the error code is set and the payload socket is unconnected. Errors include: - - operation_canceled: Cancelled via stop_token or cancel(). + - `operation_canceled`: Cancelled via stop_token or cancel(). Check `ec == cond::canceled` for portable comparison. A closed acceptor completes with `errc::bad_file_descriptor`. On failure the returned socket is default-constructed and may only be destroyed or assigned. - @par Preconditions - This acceptor must outlive the returned awaitable. + @pre This acceptor must outlive the returned awaitable. @par Example @par !example accept_returning_a_new_socket @@ -404,7 +428,7 @@ class BOOST_COROSIO_DECL tcp_acceptor : public io_object Suspends until the listen socket is ready in the requested direction, or an error condition is reported. For `wait_type::read`, completion signals that a - subsequent @ref accept will succeed without blocking; a + subsequent @ref accept succeeds without blocking. A connection already queued when the wait begins completes it immediately. No connection is consumed. @@ -419,8 +443,7 @@ class BOOST_COROSIO_DECL tcp_acceptor : public io_object A closed acceptor completes with `errc::bad_file_descriptor`. - @par Preconditions - This acceptor must outlive the returned awaitable. + @pre This acceptor must outlive the returned awaitable. */ [[nodiscard]] auto wait(wait_type w) { @@ -432,9 +455,10 @@ class BOOST_COROSIO_DECL tcp_acceptor : public io_object /** Cancel any pending asynchronous operations. - Operations still in flight complete with `errc::operation_canceled`; - an operation whose result is already decided reports that result. - Check `ec == cond::canceled` for portable comparison. + Accept and wait transfer no bytes, so a cancellation always wins: + an operation reports `errc::operation_canceled` even when it had + already succeeded when the cancellation landed. Check + `ec == cond::canceled` for portable comparison. */ void cancel() noexcept; @@ -446,17 +470,17 @@ class BOOST_COROSIO_DECL tcp_acceptor : public io_object @return The native socket handle, or -1/INVALID_SOCKET if not open. - @par Preconditions - None. May be called on closed acceptors. + @pre None. May be called on closed acceptors. */ native_handle_type native_handle() const noexcept; /** Assign an existing native socket to this acceptor. - Adopts a listening socket created outside the library — - received from a service manager, inherited, or made natively — - and registers it with the backend. The socket must be a - listening stream socket in the `AF_INET` or `AF_INET6` family. + Adopts a listening socket created outside the library. The + socket may come from a service manager, be inherited, or be + created natively. Adoption registers the socket with the + backend. The socket must be a listening stream socket in the + `AF_INET` or `AF_INET6` family. Adoption never alters the descriptor's flags or options: on POSIX the fd must already be non-blocking, and on Windows the socket must be overlapped-capable. @@ -476,7 +500,7 @@ class BOOST_COROSIO_DECL tcp_acceptor : public io_object ownership of `fd`. @param fd The native socket to adopt. On success the object - owns it and will close it. + owns it and closes it. @return The error code, empty on success. Validation and registration failures are normal runtime conditions when @@ -584,13 +608,22 @@ class BOOST_COROSIO_DECL tcp_acceptor : public io_object */ struct implementation : io_object::implementation { - /// Initiate an asynchronous accept operation. + /** Initiate an asynchronous accept operation. + + @param h Coroutine handle to resume on completion. + @param ex Executor for dispatching the completion. + @param token Stop token for cancellation. + @param ec Output error code. + @param impl_out Output implementation for the accepted peer. + + @return Coroutine handle to resume immediately. + */ virtual std::coroutine_handle<> accept( - std::coroutine_handle<>, - capy::executor_ref, - std::stop_token, - std::error_code*, - io_object::implementation**) = 0; + std::coroutine_handle<> h, + capy::executor_ref ex, + std::stop_token token, + std::error_code* ec, + io_object::implementation** impl_out) = 0; /** Initiate an asynchronous wait for acceptor readiness. @@ -598,6 +631,14 @@ class BOOST_COROSIO_DECL tcp_acceptor : public io_object the specified direction (typically `wait_type::read` for an incoming connection), or an error condition is reported. No connection is consumed. + + @param h Coroutine handle to resume on completion. + @param ex Executor for dispatching the completion. + @param w The direction to wait on. + @param token Stop token for cancellation. + @param ec Output error code. + + @return Coroutine handle to resume immediately. */ virtual std::coroutine_handle<> wait( std::coroutine_handle<> h, @@ -606,13 +647,22 @@ class BOOST_COROSIO_DECL tcp_acceptor : public io_object std::stop_token token, std::error_code* ec) = 0; - /// Returns the cached local endpoint. + /** Returns the cached local endpoint. + + @return The cached local endpoint. + */ virtual endpoint local_endpoint() const noexcept = 0; - /// Return true if the acceptor has a kernel resource open. + /** Return true if the acceptor has a kernel resource open. + + @return true if the acceptor has a kernel resource open. + */ virtual bool is_open() const noexcept = 0; - /// Return the native handle, or the platform sentinel if closed. + /** Return the native handle, or the platform sentinel if closed. + + @return The native handle, or the platform sentinel if closed. + */ virtual native_handle_type native_handle() const noexcept = 0; /** Return the socket's address family. @@ -623,14 +673,17 @@ class BOOST_COROSIO_DECL tcp_acceptor : public io_object */ virtual corosio::family family() const noexcept = 0; - /// Release and return the native handle without closing. + /** Release and return the native handle without closing. + + @return The native handle. + */ virtual native_handle_type release_socket() noexcept = 0; /** Cancel any pending asynchronous operations. - Operations still in flight complete with `operation_canceled`; - an operation whose result is already decided reports that - result. + Accept and wait transfer no bytes, so a cancellation always + wins: an operation reports `operation_canceled` even when it + had already succeeded when the cancellation landed. */ virtual void cancel() noexcept = 0; @@ -663,9 +716,17 @@ class BOOST_COROSIO_DECL tcp_acceptor : public io_object }; protected: + /** Adopt an existing handle. + + @param h The handle the acceptor takes ownership of. + */ explicit tcp_acceptor(handle h) noexcept : io_object(std::move(h)) {} - /// Transfer accepted peer impl to the peer socket. + /** Transfer the accepted peer implementation to the peer socket. + + @param peer The socket that receives the transferred implementation. + @param impl The accepted peer implementation, or null to do nothing. + */ static void reset_peer_impl(tcp_socket& peer, io_object::implementation* impl) noexcept { diff --git a/include/boost/corosio/tcp_server.hpp b/include/boost/corosio/tcp_server.hpp index 89e0b859d..494cde682 100644 --- a/include/boost/corosio/tcp_server.hpp +++ b/include/boost/corosio/tcp_server.hpp @@ -38,7 +38,7 @@ namespace boost::corosio { #pragma warning(disable : 4251) // class needs to have dll-interface #endif -/** TCP server with pooled workers. +/** Manages a pool of reusable workers that handle incoming TCP connections. This class manages a pool of reusable worker objects that handle incoming connections. When a connection arrives, an idle worker @@ -70,13 +70,13 @@ namespace boost::corosio { @par !example running_the_server @par Graceful Shutdown - To shut down gracefully, call @ref stop then drain the io_context: + To shut down gracefully, call @ref stop then drain the `io_context`: @par !example graceful_shutdown @par Restart After Stop The server can be restarted after a complete shutdown cycle. - You must drain the io_context, call @ref join, and restart the - io_context itself (`ioc.restart()`) before restarting: + You must drain the `io_context`, call @ref join, and restart the + `io_context` itself (`ioc.restart()`) before restarting: @par !example restart_after_stop @par WARNING: What NOT to Do @@ -400,12 +400,15 @@ class BOOST_COROSIO_DECL tcp_server capy::task do_accept(tcp_acceptor& acc); public: - /** Abstract base class for connection handlers. + /** Handles one accepted connection using a socket the derived class owns. Derive from this class to implement custom connection handling. Each worker owns a socket and is reused across multiple connections to avoid per-connection allocation. + @par Thread Safety + run() and socket() execute on the server's executor. + @see tcp_server, launcher */ class BOOST_COROSIO_DECL worker_base @@ -430,7 +433,7 @@ class BOOST_COROSIO_DECL tcp_server connection. The implementation must invoke the launcher exactly once to start the handling coroutine. - @param launch Handle to launch the connection coroutine. + @param launch Handle to start the connection coroutine. */ virtual void run(launcher launch) = 0; @@ -438,11 +441,12 @@ class BOOST_COROSIO_DECL tcp_server virtual corosio::tcp_socket& socket() = 0; }; - /** Move-only handle to launch a worker coroutine. + /** Starts a worker's connection-handling coroutine and returns the + worker to the idle pool automatically. Passed to @ref worker_base::run to start the connection-handling coroutine. The launcher ensures the worker returns to the idle - pool when the coroutine completes or if launching fails. + pool when the coroutine completes or if starting fails. The launcher must be invoked exactly once via `operator()`. If destroyed without invoking, the worker is returned to the @@ -462,28 +466,38 @@ class BOOST_COROSIO_DECL tcp_server } public: - /// Return the worker to the pool if not launched. + /// Return the worker to the pool if not started. ~launcher() { if (w_) srv_->push_sync(*w_); } + /** Move construct, transferring the borrowed worker. + + @param o The launcher to take the worker from. It is left + holding none, so only one of the two returns it. + */ launcher(launcher&& o) noexcept : srv_(o.srv_) , w_(std::exchange(o.w_, nullptr)) { } - launcher(launcher const&) = delete; + /// Copy construction is disabled; a launcher holds a borrowed worker it must return exactly once. + launcher(launcher const&) = delete; + /// Copy assignment is disabled; a launcher holds a borrowed worker it must return exactly once. launcher& operator=(launcher const&) = delete; - launcher& operator=(launcher&&) = delete; + /// Move assignment is disabled; a launcher is moved, never reassigned. + launcher& operator=(launcher&&) = delete; - /** Launch the connection-handling coroutine. + /** Start the connection-handling coroutine. Starts the given coroutine on the specified executor. When the coroutine completes, the worker is automatically returned to the idle pool. + @tparam Executor Executor type satisfying capy::Executor. + @param ex The executor to run the coroutine on. @param task The coroutine to execute. @@ -546,7 +560,9 @@ class BOOST_COROSIO_DECL tcp_server /// Destroy the server, stopping all accept loops. ~tcp_server(); - tcp_server(tcp_server const&) = delete; + /// Copy construction is disabled; the server owns its worker storage. + tcp_server(tcp_server const&) = delete; + /// Copy assignment is disabled; the server owns its worker storage. tcp_server& operator=(tcp_server const&) = delete; /** Move construct from another server. @@ -573,7 +589,8 @@ class BOOST_COROSIO_DECL tcp_server @param ep The local endpoint to bind to. - @return The error code if binding fails. + @return An error code indicating success, or the reason binding + failed. */ [[nodiscard]] std::error_code bind(endpoint ep); @@ -615,16 +632,15 @@ class BOOST_COROSIO_DECL tcp_server /** Start accepting connections. - Launches accept loops for all bound endpoints. Incoming + Starts accept loops for all bound endpoints. Incoming connections are dispatched to idle workers from the pool. Calling `start()` on an already-running server has no effect. - @par Preconditions - - At least one endpoint bound via @ref bind. - - Workers provided via @ref set_workers. - - If restarting, @ref join must have completed first, and the - io_context must have been restarted (`ioc.restart()`). + @pre At least one endpoint bound via @ref bind. + @pre Workers provided via @ref set_workers. + @pre If restarting, @ref join must have completed first, and the + `io_context` must be restarted (`ioc.restart()`). @par Effects Creates one accept coroutine per bound endpoint. Each coroutine @@ -656,7 +672,7 @@ class BOOST_COROSIO_DECL tcp_server Requests the accept loops' stop token and requests cancellation of active workers via their stop tokens. The acceptors are not - closed; a suspended accept completes once more before its loop + closed. A suspended accept completes once more before its loop observes the stop token and ends. This function returns immediately; it does not wait for workers @@ -672,7 +688,7 @@ class BOOST_COROSIO_DECL tcp_server - Workers observing their stop token should exit promptly. @par Postconditions - No new connections will be accepted. Active workers continue + The server accepts no new connections. Active workers continue until they observe their stop token or complete naturally. @par What Happens Next @@ -690,13 +706,12 @@ class BOOST_COROSIO_DECL tcp_server /** Block until all accept loops complete. - Blocks the calling thread until all accept coroutines launched + Blocks the calling thread until all accept coroutines started by @ref start have finished executing. This synchronizes the shutdown sequence, ensuring the server is fully stopped before restarting or destroying it. - @par Preconditions - @ref stop has been called and `ioc.run()` has returned. + @pre @ref stop was called and `ioc.run()` returned. @par Postconditions All accept loops have completed. The server is in the stopped @@ -711,8 +726,8 @@ class BOOST_COROSIO_DECL tcp_server @par !example deadlock_scenarios @par Thread Safety - May be called from any thread, but will deadlock if called - from within the io_context event loop or from a worker coroutine. + May be called from any thread. It deadlocks if called + from within the `io_context` event loop or from a worker coroutine. @see stop, start */ diff --git a/include/boost/corosio/tcp_socket.hpp b/include/boost/corosio/tcp_socket.hpp index d578037b0..7699e9646 100644 --- a/include/boost/corosio/tcp_socket.hpp +++ b/include/boost/corosio/tcp_socket.hpp @@ -39,7 +39,7 @@ namespace boost::corosio { -/** An asynchronous TCP socket for coroutine I/O. +/** Connects, reads, and writes over TCP, from a coroutine. This class provides asynchronous TCP socket operations that return awaitable types. Each operation participates in the affine awaitable @@ -57,7 +57,7 @@ namespace boost::corosio { @par Semantics Wraps the platform TCP/IP stack. Operations dispatch to - OS socket APIs via the io_context reactor (epoll, IOCP, + OS socket APIs via the `io_context` reactor (epoll, IOCP, kqueue). Satisfies @ref capy::Stream. @par Example @@ -69,6 +69,7 @@ class BOOST_COROSIO_DECL tcp_socket : public io_stream /// The endpoint type used by this socket. using endpoint_type = corosio::endpoint; + /// The shutdown direction type used by this socket. using shutdown_type = corosio::shutdown_type; using enum corosio::shutdown_type; @@ -191,8 +192,8 @@ class BOOST_COROSIO_DECL tcp_socket : public io_stream /// Represent the awaitable returned by @ref connect. struct connect_awaitable : detail::void_op_base { - tcp_socket& s_; - endpoint endpoint_; + private: + friend tcp_socket; connect_awaitable(tcp_socket& s, endpoint ep) noexcept : s_(s) @@ -200,6 +201,11 @@ class BOOST_COROSIO_DECL tcp_socket : public io_stream { } + friend detail::void_op_base; + + tcp_socket& s_; + endpoint endpoint_; + std::coroutine_handle<> dispatch(std::coroutine_handle<> h, capy::executor_ref ex) const { @@ -210,11 +216,16 @@ class BOOST_COROSIO_DECL tcp_socket : public io_stream /// Represent the awaitable returned by @ref wait. struct wait_awaitable : detail::void_op_base { - tcp_socket& s_; - wait_type w_; + private: + friend tcp_socket; wait_awaitable(tcp_socket& s, wait_type w) noexcept : s_(s), w_(w) {} + friend detail::void_op_base; + + tcp_socket& s_; + wait_type w_; + std::coroutine_handle<> dispatch(std::coroutine_handle<> h, capy::executor_ref ex) const { @@ -223,15 +234,12 @@ class BOOST_COROSIO_DECL tcp_socket : public io_stream }; public: - /** Destructor. - - Closes the socket if open, cancelling any pending operations. - */ + /** Closes the socket if open, cancelling any pending operations. */ ~tcp_socket() override; /** Construct a socket from an execution context. - @param ctx The execution context that will own this socket. + @param ctx The execution context that owns this socket. */ explicit tcp_socket(capy::execution_context& ctx); @@ -239,7 +247,9 @@ class BOOST_COROSIO_DECL tcp_socket : public io_stream The socket is associated with the executor's context. - @param ex The executor whose context will own the socket. + @tparam Ex A type satisfying capy::Executor. + + @param ex The executor whose context owns the socket. */ template requires(!std::same_as, tcp_socket>) && @@ -287,16 +297,18 @@ class BOOST_COROSIO_DECL tcp_socket : public io_stream return *this; } - tcp_socket(tcp_socket const&) = delete; + /// Copy construction is disabled; the handle is uniquely owned. + tcp_socket(tcp_socket const&) = delete; + /// Copy assignment is disabled; the handle is uniquely owned. tcp_socket& operator=(tcp_socket const&) = delete; /** Open the socket. Creates a TCP socket and associates it with the platform reactor (IOCP on Windows). Calling @ref connect on a closed - socket opens it automatically with the endpoint's address family, - so explicit `open()` is only needed when socket options must be - set before connecting. + socket opens it automatically with the endpoint's address family. + An explicit `open()` is therefore needed only when socket options + must be set before connecting. Failures such as descriptor exhaustion are normal runtime conditions and are reported through the returned error code. @@ -327,8 +339,7 @@ class BOOST_COROSIO_DECL tcp_socket : public io_stream available on any local interface. @li `errc::permission_denied`: Insufficient privileges to bind to the endpoint (e.g., privileged port). - - A closed socket reports `errc::bad_file_descriptor`. + @li `errc::bad_file_descriptor`: The socket is closed. */ [[nodiscard]] std::error_code bind(endpoint ep) noexcept; @@ -366,19 +377,18 @@ class BOOST_COROSIO_DECL tcp_socket : public io_stream @param ep The remote endpoint to connect to. @return An awaitable that completes with `io_result<>`. - Returns success (default error_code) on successful connection, + Returns success (default `error_code`) on successful connection, or an error code on failure including: - - connection_refused: No server listening at endpoint - - timed_out: Connection attempt timed out - - network_unreachable: No route to host - - operation_canceled: Cancelled via stop_token or cancel(). + - `connection_refused`: No server listening at endpoint + - `timed_out`: Connection attempt timed out + - `network_unreachable`: No route to host + - `operation_canceled`: Cancelled via stop_token or cancel(). Check `ec == cond::canceled` for portable comparison. If the socket needs to be opened and the open fails, the awaitable completes immediately with that error. - @par Preconditions - This socket must outlive the returned awaitable. + @pre This socket must outlive the returned awaitable. @par Example @par !example connect @@ -394,10 +404,10 @@ class BOOST_COROSIO_DECL tcp_socket : public io_stream /** Wait for the socket to become ready in a given direction. Suspends until the socket is ready for the requested - direction, or an error condition is reported. No bytes - are transferred — useful for integrating with C libraries - that own the I/O on a nonblocking fd and only need - readiness notification (e.g. libpq async, libssh). + direction, or an error condition is reported. No bytes are + transferred. This suits C libraries that own the I/O on a + nonblocking fd and need only readiness notification, such as + libpq async and libssh. The operation supports cancellation via `std::stop_token` through the affine awaitable protocol. If the associated @@ -407,14 +417,13 @@ class BOOST_COROSIO_DECL tcp_socket : public io_stream @param w The wait direction (read, write, or error). @return An awaitable that completes with `io_result<>`. - On success, no bytes have been consumed from the + On success, the wait consumes no bytes from the stream; a subsequent `read_some` (for read waits) returns the available data. A closed socket completes with `errc::bad_file_descriptor`. - @par Preconditions - This socket must outlive the returned awaitable. + @pre This socket must outlive the returned awaitable. */ [[nodiscard]] auto wait(wait_type w) { @@ -437,8 +446,7 @@ class BOOST_COROSIO_DECL tcp_socket : public io_stream @return The native socket handle, or -1/INVALID_SOCKET if not open. - @par Preconditions - None. May be called on closed sockets. + @pre None. May be called on closed sockets. */ native_handle_type native_handle() const noexcept; @@ -464,7 +472,7 @@ class BOOST_COROSIO_DECL tcp_socket : public io_stream ownership of `fd`. @param fd The native socket to adopt. On success the object - owns it and will close it. + owns it and closes it. @return The error code, empty on success. Validation and registration failures are normal runtime conditions when @@ -500,8 +508,8 @@ class BOOST_COROSIO_DECL tcp_socket : public io_stream close() to ensure graceful connection termination. @li @ref shutdown_receive disables reading on the socket. This - does NOT send anything to the peer - they are not informed - and may continue sending data. Subsequent reads will fail + does not send anything to the peer. The peer is not informed + and may continue sending data. Subsequent reads fail or return end-of-file. Incoming data may be discarded or buffered depending on the operating system. @@ -509,18 +517,19 @@ class BOOST_COROSIO_DECL tcp_socket : public io_stream disables reading. When the peer shuts down their send direction (sends a FIN), - subsequent read operations will complete with `capy::cond::eof`. + subsequent read operations complete with `capy::cond::eof`. Use the portable condition test rather than comparing error codes directly: @par !example shutdown + @par Error Conditions Failures such as a peer that already disconnected are normal runtime conditions and are reported through the returned error code. A closed socket reports `errc::bad_file_descriptor`. - @param what Determines what operations will no longer be allowed. + @param what Determines which operations are no longer allowed. @return The error code, empty on success. */ @@ -617,8 +626,13 @@ class BOOST_COROSIO_DECL tcp_socket : public io_stream endpoint remote_endpoint() const noexcept; protected: + /// Default construct a closed socket for a derived class to open. tcp_socket() noexcept = default; + /** Adopt an existing handle. + + @param h The handle the socket takes ownership of. + */ explicit tcp_socket(handle h) noexcept : io_object(std::move(h)) {} private: diff --git a/include/boost/corosio/test/mocket.hpp b/include/boost/corosio/test/mocket.hpp index e2c409f43..577dc410c 100644 --- a/include/boost/corosio/test/mocket.hpp +++ b/include/boost/corosio/test/mocket.hpp @@ -36,7 +36,7 @@ namespace boost::corosio::test { -/** A mock socket for testing I/O operations. +/** Stages data for reads and validates data written, to test I/O code. This class provides a testable socket-like interface where data can be staged for reading and expected data can be validated on @@ -93,6 +93,8 @@ class basic_mocket @param f The fuse for error injection testing. @param max_read_size Maximum bytes per read operation. @param max_write_size Maximum bytes per write operation. + + @throws std::logic_error if @p max_read_size or @p max_write_size is 0. */ basic_mocket( capy::execution_context& ctx, @@ -162,7 +164,7 @@ class basic_mocket /** Stage data for reads. Appends the given string to this mocket's provide buffer. - When `read_some` is called, it will receive this data first + When `read_some` is called, it receives this data first before reading from the underlying socket. @param s The data to provide. @@ -266,7 +268,10 @@ class basic_mocket @param buffers The buffer sequence containing data to write. - @return An awaitable yielding `(error_code, std::size_t)`. + @return An awaitable yielding `(error_code, std::size_t)`. The + count is the number of bytes validated against the expect + script. It is a partial count when the request is longer than + the script has left. */ template [[nodiscard]] auto write_some(ConstBufferSequence const& buffers) @@ -552,7 +557,7 @@ class basic_mocket::write_some_awaitable supports provide/expect buffers for test instrumentation. The socket is the "peer" end with no test instrumentation. - Optional max_read_size and max_write_size parameters limit the + Optional `max_read_size` and `max_write_size` parameters limit the number of bytes transferred per I/O operation on the mocket, simulating chunked network delivery for testing purposes. @@ -566,6 +571,9 @@ class basic_mocket::write_some_awaitable @return A pair of (mocket, socket). + @throws std::runtime_error if opening, binding, listening, accepting, + or connecting fails. + @note Mockets are not thread-safe and must be used in a single-threaded, deterministic context. */ diff --git a/include/boost/corosio/test/socket_pair.hpp b/include/boost/corosio/test/socket_pair.hpp index 87430803c..8e0855d99 100644 --- a/include/boost/corosio/test/socket_pair.hpp +++ b/include/boost/corosio/test/socket_pair.hpp @@ -32,10 +32,15 @@ namespace boost::corosio::test { @tparam Socket The socket type (default `tcp_socket`). @tparam Acceptor The acceptor type (default `tcp_acceptor`). + @tparam Linger Whether to enable `SO_LINGER` with a zero timeout + on both sockets (default `true`). @param ctx The I/O context for the sockets. @return A pair of connected sockets. + + @throws std::runtime_error if opening, binding, listening, + accepting, or connecting fails. */ template< class Socket = tcp_socket, diff --git a/include/boost/corosio/timeout.hpp b/include/boost/corosio/timeout.hpp index c2d4f769c..3a39d6ee5 100644 --- a/include/boost/corosio/timeout.hpp +++ b/include/boost/corosio/timeout.hpp @@ -30,20 +30,19 @@ namespace boost::corosio { `ec` compares equal to `capy::cond::timeout` (with a default-initialized payload) is produced. - Exceptions from the inner awaitable always propagate; they are - never swallowed by the timer. + Exceptions from the inner awaitable always propagate to the caller. - @par Preconditions - The awaiting coroutine's executor must belong to an - `io_context`; any other execution context terminates with a - diagnostic. + @pre The awaiting coroutine's executor must belong to an + `io_context`. Any other execution context terminates with a + diagnostic, because silently running without a timer would + drop the requested timeout. @par Cancellation If the parent's stop token is activated, the inner awaitable is cancelled and its cancellation result is returned. Requesting stop from another thread requires a multi-threaded-capable - io_context; a context running in single_threaded mode - (auto-enabled at concurrency_hint == 1) does not permit + `io_context`. A context running in `single_threaded` mode + (auto-enabled at `concurrency_hint` == 1) does not permit cross-thread cancellation. @par Example diff --git a/include/boost/corosio/tls_context.hpp b/include/boost/corosio/tls_context.hpp index b8f3f3c49..b7b3fdb10 100644 --- a/include/boost/corosio/tls_context.hpp +++ b/include/boost/corosio/tls_context.hpp @@ -115,7 +115,7 @@ enum class tls_password_purpose class tls_context; -/** A non-owning view of certificate verification state. +/** Exposes the certificate and error state to a verification callback. An instance is passed to the callback installed via tls_context::set_verify_callback during the TLS handshake. It @@ -175,7 +175,7 @@ class verify_context /** Return the DER encoding of the certificate being verified. This is the portable way to inspect the peer certificate from a - verification callback: it works identically on every backend, + verification callback. It works identically on every backend, without depending on backend-specific build options. A DER certificate is an ASN.1 `SEQUENCE`, so the first byte is `0x30`. @@ -199,7 +199,7 @@ tls_context_data const& get_tls_context_data(tls_context const&) noexcept; #pragma warning(disable : 4251) // shared_ptr needs dll-interface #endif -/** A portable TLS context for certificate and settings storage. +/** Configures the certificates, keys, and protocol settings a TLS stream uses. The `tls_context` class provides a backend-agnostic interface for configuring TLS connections. It stores credentials (certificates and @@ -211,13 +211,13 @@ tls_context_data const& get_tls_context_data(tls_context const&) noexcept; by value and shared across multiple TLS streams. This class abstracts the configuration phase of TLS across multiple - backend implementations (OpenSSL, WolfSSL, mbedTLS, Schannel, etc.), - allowing portable code that works regardless of which TLS library - is linked. + backend implementations, among them OpenSSL, WolfSSL, mbedTLS and + Schannel. Portable code therefore works regardless of which TLS + library is linked. @par Modification After Stream Creation - Modifying a context after a TLS stream has been created from it + Modifying a context after creating a TLS stream from it results in undefined behavior. The context's configuration is captured when the first stream is constructed, and subsequent modifications are not reflected in existing or new streams @@ -259,18 +259,14 @@ class BOOST_COROSIO_DECL tls_context */ tls_context(); - /** Copy constructor. - - Creates a new handle that shares ownership of the underlying + /** Creates a new handle that shares ownership of the underlying TLS context state with `other`. @param other The context to copy from. */ tls_context(tls_context const& other) = default; - /** Copy assignment operator. - - Releases the current context's shared ownership and acquires + /** Releases the current context's shared ownership and acquires shared ownership of `other`'s underlying state. @param other The context to copy from. @@ -279,18 +275,14 @@ class BOOST_COROSIO_DECL tls_context */ tls_context& operator=(tls_context const& other) = default; - /** Move constructor. - - Transfers ownership of the TLS context from another instance. + /** Transfers ownership of the TLS context from another instance. After the move, `other` is in a valid but empty state. @param other The context to move from. */ tls_context(tls_context&& other) noexcept = default; - /** Move assignment operator. - - Releases the current context's shared ownership and transfers + /** Releases the current context's shared ownership and transfers ownership from another instance. After the move, `other` is in a valid but empty state. @@ -300,9 +292,7 @@ class BOOST_COROSIO_DECL tls_context */ tls_context& operator=(tls_context&& other) noexcept = default; - /** Destructor. - - Releases this handle's shared ownership of the underlying + /** Releases this handle's shared ownership of the underlying context. The context state is destroyed when the last handle is released. */ @@ -409,9 +399,9 @@ class BOOST_COROSIO_DECL tls_context @param format The encoding format of the key data. @return Success. The key is recorded and decoded when the native - context is first built; a malformed key, a missing password - callback for an encrypted key, or a certificate mismatch - surfaces as a handshake failure. + context is first built. Three faults surface only as a handshake + failure: a malformed key, a missing password callback for an + encrypted key, and a certificate mismatch. @see use_private_key_file @see set_password_callback @@ -457,9 +447,9 @@ class BOOST_COROSIO_DECL tls_context @param passphrase The password protecting the bundle. @return Success. The bundle is recorded and decoded into the - certificate, private key, and chain when the native context is - first built; a malformed bundle or wrong passphrase surfaces as - a handshake failure. + certificate, private key, and chain when the native context is + first built. A malformed bundle or a wrong passphrase surfaces as a + handshake failure. @note Intermediate certificates inside the bundle are loaded and sent during the handshake on both backends. @@ -544,16 +534,16 @@ class BOOST_COROSIO_DECL tls_context this context. The expected directory layout depends on the backend. OpenSSL - performs on-demand lookups and requires each certificate file to - be named by its subject-name hash (as generated by - `openssl rehash` or `c_rehash`); WolfSSL loads every certificate - file in the directory. + performs on-demand lookups. Each certificate file must be named + by its subject-name hash, as generated by `openssl rehash` or + `c_rehash`. WolfSSL loads every certificate file in the + directory. @param path Path to the directory of CA certificates. @return Success. The path is recorded and applied when the native - context is built; a directory that cannot be read at that time - is skipped rather than reported here. + context is built. A directory that cannot be read at that time is + skipped rather than reported here. @par Example @par !example add_verify_path @@ -576,10 +566,10 @@ class BOOST_COROSIO_DECL tls_context name, `tls_stream::set_hostname()`. @return Success. The request is recorded and applied when the - native context is built; if the system store cannot be loaded - at that time it is skipped rather than reported here, so a - context that must reject unverified peers should also use - `set_verify_mode( tls_verify_mode::peer )`. + native context is built. A system store that cannot be loaded at + that time is skipped rather than reported here. A context that + must reject unverified peers should therefore also use + `set_verify_mode( tls_verify_mode::peer )`. @note The OpenSSL backend honors the `SSL_CERT_FILE` and `SSL_CERT_DIR` environment variables. The WolfSSL backend @@ -601,7 +591,7 @@ class BOOST_COROSIO_DECL tls_context /** Set the minimum TLS protocol version. - Connections will reject protocol versions older than this. + Connections reject protocol versions older than this. The default allows TLS 1.2 and newer. @param v The minimum protocol version to accept. @@ -618,7 +608,7 @@ class BOOST_COROSIO_DECL tls_context /** Set the maximum TLS protocol version. - Connections will not negotiate protocol versions newer than this. + Connections do not negotiate protocol versions newer than this. The default allows the newest supported version. @param v The maximum protocol version to accept. @@ -627,9 +617,9 @@ class BOOST_COROSIO_DECL tls_context native context is first built. @note On WolfSSL the ceiling is applied by selecting a - version-specific method (no native set-max API exists); an - invalid window where the minimum exceeds the maximum yields a - context that fails the handshake. + version-specific method, because no native set-max API exists. An + invalid window, where the minimum exceeds the maximum, yields a + context that fails the handshake. @see set_min_protocol_version */ @@ -735,6 +725,9 @@ class BOOST_COROSIO_DECL tls_context @return Success. The depth is recorded and applied when the native context is first built. + + @par Example + @par !example set_verify_depth */ [[nodiscard]] std::error_code set_verify_depth(int depth); @@ -746,7 +739,7 @@ class BOOST_COROSIO_DECL tls_context results. The callback receives the built-in verification result so far and - a verify_context describing the certificate being verified. Return + a `verify_context` describing the certificate being verified. Return `true` to accept the certificate, `false` to reject. Inspect the certificate portably via `verify_context::certificate()` (its DER encoding) — for example to pin a specific certificate. @@ -757,17 +750,18 @@ class BOOST_COROSIO_DECL tls_context - OpenSSL: the callback runs once per certificate in the chain, including certificates that passed the built-in checks. It can - therefore both relax verification (return `true` for a - certificate the library rejected) and tighten it (return `false` - for a certificate the library accepted, e.g. pinning). + therefore relax verification by returning `true` for a + certificate the library rejected. It can also tighten + verification by returning `false` for a certificate the library + accepted, as pinning does. - WolfSSL built with `WOLFSSL_ALWAYS_VERIFY_CB` (implied by `--enable-opensslextra`): same as OpenSSL. - WolfSSL without that option: the library invokes the callback only on verification *failure*, so it cannot be honored on a - successful handshake. To avoid silently ignoring a - verification-tightening callback (which would fail open), a - context that carries a callback instead **fails the handshake** - with `std::errc::function_not_supported` on such a build. Rebuild + successful handshake. Silently ignoring a verification-tightening + callback would fail open. On such a build, a context that carries + a callback instead **fails the handshake** with + `std::errc::function_not_supported`. Rebuild WolfSSL with `WOLFSSL_ALWAYS_VERIFY_CB`, or omit the callback. @tparam Callback A callable with signature @@ -830,7 +824,7 @@ class BOOST_COROSIO_DECL tls_context /** Add a Certificate Revocation List from memory. Adds a CRL to the verification store for checking whether - certificates have been revoked. CRLs are typically fetched + certificates are revoked. CRLs are typically fetched from the URLs in a certificate's CRL Distribution Points extension. @@ -854,7 +848,7 @@ class BOOST_COROSIO_DECL tls_context /** Add a Certificate Revocation List from a file. Adds a CRL to the verification store for checking whether - certificates have been revoked. + certificates are revoked. @param filename Path to a CRL file (DER or PEM format). @@ -909,7 +903,7 @@ class BOOST_COROSIO_DECL tls_context loading encrypted key material. @tparam Callback A callable with signature - `std::string( std::size_t max_length, password_purpose purpose )`. + `std::string( std::size_t max_length, tls_password_purpose purpose )`. @param callback The password callback. It receives the maximum password length and the purpose (reading or writing), and diff --git a/include/boost/corosio/tls_stream.hpp b/include/boost/corosio/tls_stream.hpp index e6203f957..ee1df6a60 100644 --- a/include/boost/corosio/tls_stream.hpp +++ b/include/boost/corosio/tls_stream.hpp @@ -38,17 +38,17 @@ enum class tls_role server }; -/** Abstract base class for TLS streams. +/** Reads, writes, and manages the handshake lifecycle of a TLS + session over an underlying stream. This class provides a runtime-polymorphic interface for TLS - implementations. Derived classes (openssl_stream, wolfssl_stream) + implementations. Derived classes (`openssl_stream`, `wolfssl_stream`) implement the virtual functions to provide backend-specific TLS functionality. - Unlike @ref io_stream which represents OS-level I/O completed - by the kernel, TLS streams are coroutine-based: their operations - are implemented as coroutines that orchestrate sub-operations - on the underlying stream. + An @ref io_stream represents OS-level I/O completed by the kernel. + TLS streams are coroutine-based instead: their operations are + coroutines that orchestrate sub-operations on the underlying stream. The non-virtual template wrappers (`read_some`, `write_some`) satisfy the `capy::Stream` concept, enabling TLS streams to @@ -58,10 +58,10 @@ enum class tls_role Distinct objects: Safe.@n Shared objects: Unsafe, with one exception: one read operation and one write operation may be in flight simultaneously. `shutdown()` - may overlap a pending read. When the execution context runs on - multiple threads, all operations on one stream must be performed - within the same `capy::strand` (or otherwise never run - concurrently); a single-threaded context needs no strand. + may overlap a pending read. On a multi-threaded execution context, + all operations on one stream must run within the same + `capy::strand`, or must otherwise never run concurrently. A + single-threaded context needs no strand. @see openssl_stream, wolfssl_stream */ @@ -71,13 +71,15 @@ class BOOST_COROSIO_DECL tls_stream /// Destroy the TLS stream. virtual ~tls_stream() = default; - tls_stream(tls_stream const&) = delete; + /// Copy construction is disabled; copying a stream would slice the derived session. + tls_stream(tls_stream const&) = delete; + /// Copy assignment is disabled; copying a stream would slice the derived session. tls_stream& operator=(tls_stream const&) = delete; /** Initiate an asynchronous read operation. Reads decrypted data into the provided buffer sequence. The - operation completes when at least one byte has been read, + operation completes when it reads at least one byte, or an error occurs. This non-virtual template wrapper satisfies the `capy::Stream` @@ -102,8 +104,8 @@ class BOOST_COROSIO_DECL tls_stream /** Initiate an asynchronous write operation. Encrypts and writes data from the provided buffer sequence. - The operation completes when at least one byte has been - written, or an error occurs. + The operation completes when it writes at least one byte, + or an error occurs. This non-virtual template wrapper satisfies the `capy::Stream` concept by delegating to the virtual `do_write_some`. @@ -131,14 +133,13 @@ class BOOST_COROSIO_DECL tls_stream For server connections, this waits for the ClientHello and sends the server's response. - A handshake attempt, successful or not, consumes the stream - state: a subsequent call behaves as if `reset()` had been - called first and performs a fresh handshake using the - current configuration. + A handshake attempt consumes the stream state, whether it + succeeds or not. A subsequent call behaves as if `reset()` ran + first, and performs a fresh handshake using the current + configuration. - @par Preconditions - The underlying stream must be connected. No other TLS - operation may be in progress on this stream. + @pre The underlying stream must be connected. No other TLS + operation may be in progress on this stream. @param role The handshake role, client or server. @@ -151,17 +152,16 @@ class BOOST_COROSIO_DECL tls_stream Initiates the TLS shutdown sequence by sending a close_notify alert and waiting for the peer's close_notify response. - @par Preconditions - A handshake must have completed successfully. May overlap - a pending read. No concurrent write may be in progress. + @pre A handshake must have completed successfully. May overlap + a pending read. No concurrent write may be in progress. @par Postconditions - If the transport ends before the peer's close_notify is - received, the result is `capy::error::stream_truncated`, not - success: an unannounced close is indistinguishable from a - truncation attack and must not be reported as a clean - shutdown. A shutdown stopped mid-flight reports canceled; - any other transport error propagates unchanged. + If the transport ends before the peer's close_notify arrives, + the result is `capy::error::stream_truncated`, not success. An + unannounced close is indistinguishable from a truncation attack, + so it must not be reported as a clean shutdown. A shutdown + stopped mid-flight reports canceled. Any other transport + error propagates unchanged. @return An awaitable yielding `(error_code)`. */ @@ -178,16 +178,15 @@ class BOOST_COROSIO_DECL tls_stream implicitly performs a reset first, so explicit calls are only needed to eagerly release session state. - @par Preconditions - No TLS operation (handshake, read, write, shutdown) is - in progress. + @pre No TLS operation (handshake, read, write, shutdown) is + in progress. @par Thread Safety Not thread safe. The caller must ensure no concurrent operations are in progress on this stream. @note If called mid-session before `shutdown()`, pending - TLS data is discarded and the peer will observe a + TLS data is discarded and the peer observes a truncated stream. */ virtual void reset() = 0; @@ -206,8 +205,8 @@ class BOOST_COROSIO_DECL tls_stream verification. If `hostname` is an IP literal (IPv4 or IPv6), it is matched - against the certificate's iPAddress entries instead of its - DNS names, and no SNI is sent (RFC 6066 excludes literals). + against the certificate's iPAddress entries instead of its DNS + names. No SNI is sent, because RFC 6066 excludes literals. A backend build that cannot match iPAddress entries fails the handshake with `std::errc::function_not_supported` rather than skip verification. @@ -257,10 +256,10 @@ class BOOST_COROSIO_DECL tls_stream during the TLS handshake, from the list supplied via @ref tls_context::set_alpn. - @return The negotiated protocol, or an empty view if no - protocol was negotiated, ALPN was not offered, the - handshake has not completed, or the backend/build does - not support ALPN. + @return The negotiated protocol, or an empty view. It is empty + if no protocol was negotiated or ALPN was not offered. It is + also empty if the handshake has not completed, or if the build + lacks ALPN support. @par Thread Safety Safe to call after the handshake completes; not safe to call @@ -272,9 +271,10 @@ class BOOST_COROSIO_DECL tls_stream } // LCOV_EXCL_LINE every concrete stream overrides this; the base default is never called protected: + /// Default construct; a derived class supplies the session. tls_stream() = default; - /** Virtual read implementation. + /** Perform the backend-specific decrypted read. Derived classes override this to perform TLS decryption and read operations. @@ -287,7 +287,7 @@ class BOOST_COROSIO_DECL tls_stream capy::detail::mutable_buffer_array buffers) = 0; - /** Virtual write implementation. + /** Perform the backend-specific encrypted write. Derived classes override this to perform TLS encryption and write operations. diff --git a/include/boost/corosio/udp_socket.hpp b/include/boost/corosio/udp_socket.hpp index 4d2512b31..b39fefe5e 100644 --- a/include/boost/corosio/udp_socket.hpp +++ b/include/boost/corosio/udp_socket.hpp @@ -39,7 +39,7 @@ namespace boost::corosio { -/** An asynchronous UDP socket for coroutine I/O. +/** Sends and receives datagrams over UDP, from a coroutine. This class provides asynchronous UDP datagram operations that return awaitable types. Each operation participates in the affine @@ -60,8 +60,8 @@ namespace boost::corosio { @par Thread Safety Distinct objects: Safe.@n Shared objects: Unsafe. A socket must not have concurrent - operations of the same type (e.g., two simultaneous recv_from). - One send_to and one recv_from may be in flight simultaneously. + operations of the same type (e.g., two simultaneous `recv_from`). + One `send_to` and one `recv_from` may be in flight simultaneously. @par Example @par !example udp_socket @@ -69,6 +69,7 @@ namespace boost::corosio { class BOOST_COROSIO_DECL udp_socket : public io_object { public: + /// The shutdown direction type used by this socket. using shutdown_type = corosio::shutdown_type; using enum corosio::shutdown_type; @@ -79,13 +80,15 @@ class BOOST_COROSIO_DECL udp_socket : public io_object */ struct implementation : io_object::implementation { - /** Initiate an asynchronous send_to operation. + /** Initiate an asynchronous `send_to` operation. @param h Coroutine handle to resume on completion. @param ex Executor for dispatching the completion. @param buf The buffer data to send. @param dest The destination endpoint. - @param flags Platform message flags (e.g. `MSG_DONTWAIT`). + @param flags Portable @ref message_flags bits (for example + `message_flags::do_not_route`). The backend translates + these to native `MSG_*` constants. @param token Stop token for cancellation. @param ec Output error code. @param bytes_out Output bytes transferred. @@ -102,13 +105,15 @@ class BOOST_COROSIO_DECL udp_socket : public io_object std::error_code* ec, std::size_t* bytes_out) = 0; - /** Initiate an asynchronous recv_from operation. + /** Initiate an asynchronous `recv_from` operation. @param h Coroutine handle to resume on completion. @param ex Executor for dispatching the completion. @param buf The buffer to receive into. @param source Output endpoint for the sender's address. - @param flags Platform message flags (e.g. `MSG_PEEK`). + @param flags Portable @ref message_flags bits (for example + `message_flags::peek`). The backend translates these to + native `MSG_*` constants. @param token Stop token for cancellation. @param ec Output error code. @param bytes_out Output bytes transferred. @@ -154,7 +159,12 @@ class BOOST_COROSIO_DECL udp_socket : public io_object */ virtual void cancel() noexcept = 0; - /// Shut down the socket in one or both directions. + /** Shut down the socket in one or both directions. + + @param what Which directions to disable. + + @return The error code, empty on success. + */ virtual std::error_code shutdown(shutdown_type what) noexcept = 0; /** Set a socket option. @@ -212,7 +222,9 @@ class BOOST_COROSIO_DECL udp_socket : public io_object @param h Coroutine handle to resume on completion. @param ex Executor for dispatching the completion. @param buf The buffer data to send. - @param flags Platform message flags (e.g. `MSG_DONTWAIT`). + @param flags Portable @ref message_flags bits (for example + `message_flags::do_not_route`). The backend translates + these to native `MSG_*` constants. @param token Stop token for cancellation. @param ec Output error code. @param bytes_out Output bytes transferred. @@ -228,12 +240,14 @@ class BOOST_COROSIO_DECL udp_socket : public io_object std::error_code* ec, std::size_t* bytes_out) = 0; - /** Initiate an asynchronous connected recv operation. + /** Initiate an asynchronous connected `recv` operation. @param h Coroutine handle to resume on completion. @param ex Executor for dispatching the completion. @param buf The buffer to receive into. - @param flags Platform message flags (e.g. `MSG_PEEK`). + @param flags Portable @ref message_flags bits (for example + `message_flags::peek`). The backend translates these to + native `MSG_*` constants. @param token Stop token for cancellation. @param ec Output error code. @param bytes_out Output bytes transferred. @@ -278,10 +292,8 @@ class BOOST_COROSIO_DECL udp_socket : public io_object */ struct send_to_awaitable : detail::bytes_op_base { - udp_socket& s_; - buffer_param buf_; - endpoint dest_; - int flags_; + private: + friend udp_socket; send_to_awaitable( udp_socket& s, @@ -295,6 +307,13 @@ class BOOST_COROSIO_DECL udp_socket : public io_object { } + friend detail::bytes_op_base; + + udp_socket& s_; + buffer_param buf_; + endpoint dest_; + int flags_; + std::coroutine_handle<> dispatch(std::coroutine_handle<> h, capy::executor_ref ex) const { @@ -310,10 +329,8 @@ class BOOST_COROSIO_DECL udp_socket : public io_object */ struct recv_from_awaitable : detail::bytes_op_base { - udp_socket& s_; - buffer_param buf_; - endpoint& source_; - int flags_; + private: + friend udp_socket; recv_from_awaitable( udp_socket& s, @@ -327,6 +344,13 @@ class BOOST_COROSIO_DECL udp_socket : public io_object { } + friend detail::bytes_op_base; + + udp_socket& s_; + buffer_param buf_; + endpoint& source_; + int flags_; + std::coroutine_handle<> dispatch(std::coroutine_handle<> h, capy::executor_ref ex) const { @@ -338,8 +362,8 @@ class BOOST_COROSIO_DECL udp_socket : public io_object /// Represent the awaitable returned by @ref connect. struct connect_awaitable : detail::void_op_base { - udp_socket& s_; - endpoint endpoint_; + private: + friend udp_socket; connect_awaitable(udp_socket& s, endpoint ep) noexcept : s_(s) @@ -347,6 +371,11 @@ class BOOST_COROSIO_DECL udp_socket : public io_object { } + friend detail::void_op_base; + + udp_socket& s_; + endpoint endpoint_; + std::coroutine_handle<> dispatch(std::coroutine_handle<> h, capy::executor_ref ex) const { @@ -357,11 +386,16 @@ class BOOST_COROSIO_DECL udp_socket : public io_object /// Represent the awaitable returned by @ref wait. struct wait_awaitable : detail::void_op_base { - udp_socket& s_; - wait_type w_; + private: + friend udp_socket; wait_awaitable(udp_socket& s, wait_type w) noexcept : s_(s), w_(w) {} + friend detail::void_op_base; + + udp_socket& s_; + wait_type w_; + std::coroutine_handle<> dispatch(std::coroutine_handle<> h, capy::executor_ref ex) const { @@ -372,9 +406,8 @@ class BOOST_COROSIO_DECL udp_socket : public io_object /// Represent the awaitable returned by @ref send. struct send_awaitable : detail::bytes_op_base { - udp_socket& s_; - buffer_param buf_; - int flags_; + private: + friend udp_socket; send_awaitable(udp_socket& s, buffer_param buf, int flags = 0) noexcept : s_(s) @@ -383,6 +416,12 @@ class BOOST_COROSIO_DECL udp_socket : public io_object { } + friend detail::bytes_op_base; + + udp_socket& s_; + buffer_param buf_; + int flags_; + std::coroutine_handle<> dispatch(std::coroutine_handle<> h, capy::executor_ref ex) const { @@ -393,9 +432,8 @@ class BOOST_COROSIO_DECL udp_socket : public io_object /// Represent the awaitable returned by @ref recv. struct recv_awaitable : detail::bytes_op_base { - udp_socket& s_; - buffer_param buf_; - int flags_; + private: + friend udp_socket; recv_awaitable(udp_socket& s, buffer_param buf, int flags = 0) noexcept : s_(s) @@ -404,6 +442,12 @@ class BOOST_COROSIO_DECL udp_socket : public io_object { } + friend detail::bytes_op_base; + + udp_socket& s_; + buffer_param buf_; + int flags_; + std::coroutine_handle<> dispatch(std::coroutine_handle<> h, capy::executor_ref ex) const { @@ -412,15 +456,13 @@ class BOOST_COROSIO_DECL udp_socket : public io_object }; public: - /** Destructor. - - Closes the socket if open, cancelling any pending operations. + /** Closes the socket if open, cancelling any pending operations. */ ~udp_socket() override; /** Construct a socket from an execution context. - @param ctx The execution context that will own this socket. + @param ctx The execution context that owns this socket. */ explicit udp_socket(capy::execution_context& ctx); @@ -428,7 +470,7 @@ class BOOST_COROSIO_DECL udp_socket : public io_object The socket is associated with the executor's context. - @param ex The executor whose context will own the socket. + @param ex The executor whose context owns the socket. */ template requires(!std::same_as, udp_socket>) && @@ -437,17 +479,13 @@ class BOOST_COROSIO_DECL udp_socket : public io_object { } - /** Move constructor. - - Transfers ownership of the socket resources. + /** Transfers ownership of the socket resources. @param other The socket to move from. */ udp_socket(udp_socket&& other) noexcept : io_object(std::move(other)) {} - /** Move assignment operator. - - Closes any existing socket and transfers ownership. + /** Closes any existing socket and transfers ownership. @param other The socket to move from. @return Reference to this socket. @@ -462,7 +500,9 @@ class BOOST_COROSIO_DECL udp_socket : public io_object return *this; } - udp_socket(udp_socket const&) = delete; + /// Copy construction is disabled; the handle is uniquely owned. + udp_socket(udp_socket const&) = delete; + /// Copy assignment is disabled; the handle is uniquely owned. udp_socket& operator=(udp_socket const&) = delete; /** Open the socket. @@ -521,7 +561,7 @@ class BOOST_COROSIO_DECL udp_socket : public io_object conditions and are reported through the returned error code. A closed socket reports `errc::bad_file_descriptor`. - @param what Determines what operations will no longer be + @param what Determines which operations are no longer allowed. @return The error code, empty on success. @@ -565,7 +605,7 @@ class BOOST_COROSIO_DECL udp_socket : public io_object ownership of `fd`. @param fd The native socket to adopt. On success the object - owns it and will close it. + owns it and closes it. @return The error code, empty on success. Validation and registration failures are normal runtime conditions when @@ -644,7 +684,7 @@ class BOOST_COROSIO_DECL udp_socket : public io_object @param buf The buffer containing data to send. @param dest The destination endpoint. - @param flags Message flags (e.g. message_flags::dont_route). + @param flags Message flags (e.g. message_flags::do_not_route). @return An awaitable that completes with `io_result`. @@ -671,7 +711,7 @@ class BOOST_COROSIO_DECL udp_socket : public io_object /** Receive a datagram and capture the sender's endpoint. @param buf The buffer to receive data into. - @param source Reference to an endpoint that will be set to + @param source Reference to an endpoint that receives the sender's address on successful completion. @param flags Message flags (e.g. message_flags::peek). @@ -731,8 +771,7 @@ class BOOST_COROSIO_DECL udp_socket : public io_object A closed socket completes with `errc::bad_file_descriptor`. - @par Preconditions - This socket must outlive the returned awaitable. + @pre This socket must outlive the returned awaitable. */ [[nodiscard]] auto wait(wait_type w) { diff --git a/include/boost/corosio/wait_traits.hpp b/include/boost/corosio/wait_traits.hpp index f0486f220..b923732b4 100644 --- a/include/boost/corosio/wait_traits.hpp +++ b/include/boost/corosio/wait_traits.hpp @@ -17,9 +17,7 @@ namespace boost::corosio { -/** Default wait traits for clock-based delays. - - Controls how much of the remaining time a single underlying +/** Controls how much of the remaining time a single underlying steady-clock wait may cover before `Clock::now()` is re-read. A larger value costs fewer wakeups; a smaller value bounds how late an adjustment of `Clock` ( e.g. a stepped time-of-day @@ -42,10 +40,9 @@ struct wait_traits Should return a positive duration when @p d is positive; a non-positive result degrades to reactor-rate re-checking. - @par Preconditions - Must not throw and must not block — invoked on the - io_context's run thread, including from the timer - completion path. + @pre Must not throw and must not block — invoked on the + `io_context`'s run thread, including from the timer + completion path. @param d The remaining time until the deadline. @@ -61,7 +58,8 @@ struct wait_traits Satisfied when `Traits::to_wait_duration` accepts a `Clock::duration` and returns something convertible back to it. - `Traits::to_wait_duration` must not throw. + `Traits::to_wait_duration` is expected not to throw; the + requires-expression above does not enforce this. */ template concept WaitTraits = requires(typename Clock::duration d) { diff --git a/include/boost/corosio/wait_type.hpp b/include/boost/corosio/wait_type.hpp index e253b4c08..8e1128f4a 100644 --- a/include/boost/corosio/wait_type.hpp +++ b/include/boost/corosio/wait_type.hpp @@ -25,12 +25,12 @@ enum class wait_type /// Wait until the descriptor is ready for a non-blocking write. write, - /// Wait until an error condition has been reported by the kernel + /// Wait until the kernel reports an error condition /// (e.g. SO_ERROR is non-zero or an exceptional event is pending). /// Error events are not buffered across operations: an error that /// fires before wait(error) is registered may be lost. Kernel /// semantics for what counts as an "error condition" vary by - /// platform; treat the contract as best-effort. + /// platform. Treat the contract as best-effort. error }; diff --git a/include/boost/corosio/wolfssl_stream.hpp b/include/boost/corosio/wolfssl_stream.hpp index e8979b750..f0196ff4d 100644 --- a/include/boost/corosio/wolfssl_stream.hpp +++ b/include/boost/corosio/wolfssl_stream.hpp @@ -25,7 +25,7 @@ namespace boost::corosio { -/** A TLS stream using WolfSSL. +/** Encrypts and decrypts a stream using WolfSSL. This class wraps an underlying stream satisfying `capy::Stream` and provides TLS encryption using the WolfSSL library. @@ -38,21 +38,21 @@ namespace boost::corosio { Two construction modes are supported: - - **Owning**: Pass stream by value. The wolfssl_stream takes - ownership and the stream is moved into internal storage. + - **Owning**: Pass stream by value. The `wolfssl_stream` takes + ownership. The stream is moved into internal storage. - - **Reference**: Pass stream by pointer. The wolfssl_stream - does not own the stream; the caller must ensure the stream + - **Reference**: Pass stream by pointer. The `wolfssl_stream` + does not own the stream. The caller must ensure the stream outlives this object. @par Thread Safety Distinct objects: Safe.@n Shared objects: Unsafe, with one exception: one read operation and one write operation may be in flight simultaneously. `shutdown()` - may overlap a pending read. When the execution context runs on - multiple threads, all operations on one stream must be performed - within the same `capy::strand` (or otherwise never run - concurrently); a single-threaded context needs no strand. + may overlap a pending read. On a multi-threaded execution context, + all operations on one stream must run within the same + `capy::strand`, or must otherwise never run concurrently. A + single-threaded context needs no strand. @par Example @par !example wolfssl_stream @@ -72,8 +72,8 @@ class BOOST_COROSIO_DECL wolfssl_stream final : public tls_stream /** Construct a WolfSSL stream (owning mode). Takes ownership of the underlying stream by moving it into - internal storage. The stream will be destroyed when this - wolfssl_stream is destroyed. + internal storage. The stream is destroyed when this + `wolfssl_stream` is destroyed. @param stream The stream to take ownership of. Must satisfy `capy::Stream`. @@ -91,7 +91,7 @@ class BOOST_COROSIO_DECL wolfssl_stream final : public tls_stream Wraps the underlying stream without taking ownership. The caller must ensure the stream remains valid for the lifetime - of this wolfssl_stream. + of this `wolfssl_stream`. @param stream Pointer to the stream to wrap. Must satisfy `capy::Stream`. @@ -104,7 +104,7 @@ class BOOST_COROSIO_DECL wolfssl_stream final : public tls_stream { } - /** Destructor. + /** Destroy the WolfSSL stream. Releases the underlying WolfSSL resources. If constructed in owning mode, also destroys the underlying stream. @@ -133,9 +133,8 @@ class BOOST_COROSIO_DECL wolfssl_stream final : public tls_stream completes, an error occurs, or the operation is cancelled via stop token. - @par Preconditions - The underlying stream must be connected. No other - TLS operation may be in progress on this stream. + @pre The underlying stream must be connected. No other + TLS operation may be in progress on this stream. @param role The handshake role, client or server. @@ -149,16 +148,15 @@ class BOOST_COROSIO_DECL wolfssl_stream final : public tls_stream close_notify response. Supports cancellation via stop token. - @par Preconditions - A handshake must have completed successfully. May overlap - a pending read; the read completes with `capy::error::eof` - when the peer answers the close_notify. No concurrent write - may be in progress. + @pre A handshake must have completed successfully. May overlap + a pending read. That read completes with `capy::error::eof` + when the peer answers the close_notify. No concurrent write + may be in progress. @par Postconditions If the transport ends before the peer's close_notify is received, the result is `capy::error::stream_truncated`, not - success. A shutdown stopped mid-flight reports canceled; any + success. A shutdown stopped mid-flight reports canceled. Any other transport error propagates unchanged. @return An awaitable yielding `(error_code)`. @@ -173,12 +171,15 @@ class BOOST_COROSIO_DECL wolfssl_stream final : public tls_stream resumed, so a handshake after `reset()` is always a full handshake. - @par Preconditions - No TLS operation may be in progress on this stream. + @pre No TLS operation may be in progress on this stream. */ void reset() override; - /// Set the peer hostname for SNI and certificate verification. + /** Set the peer hostname for SNI and certificate verification. + + @param hostname The peer name to send as SNI and match against the + certificate. + */ void set_hostname(std::string_view hostname) override; /// Return the underlying stream. @@ -200,10 +201,12 @@ class BOOST_COROSIO_DECL wolfssl_stream final : public tls_stream std::string_view alpn_protocol() const noexcept override; protected: + /// @copydoc tls_stream::do_read_some capy::io_task do_read_some( capy::detail::mutable_buffer_array buffers) override; + /// @copydoc tls_stream::do_write_some capy::io_task do_write_some( capy::detail::const_buffer_array buffers) override; @@ -218,8 +221,8 @@ class BOOST_COROSIO_DECL wolfssl_stream final : public tls_stream Errors reported by @ref wolfssl_stream that originate from `wolfSSL_get_error` are assigned this category. Its `message()` decodes the WolfSSL error code using WolfSSL's own diagnostic - strings, so printing such an `error_code` yields a readable - description (for example, "ASN no signer error to confirm failure"). + strings. Printing such an `error_code` therefore yields a readable + description, for example "ASN no signer error to confirm failure". @return A reference to a static category object with name `"corosio.wolfssl"`. @@ -233,14 +236,14 @@ BOOST_COROSIO_DECL std::error_category const& wolfssl_category() noexcept; was built with `WOLFSSL_ALWAYS_VERIFY_CB` (implied by `--enable-opensslextra`). On a build without it, WolfSSL invokes the callback only on verification failure, so a callback that tightens - verification would silently fail open; the @ref wolfssl_stream backend + verification would silently fail open. The @ref wolfssl_stream backend instead fails the handshake with `std::errc::function_not_supported` when a callback is present. This function lets callers detect that situation up front. @return `true` if verify callbacks are fully supported by this build, - `false` if installing one will cause the handshake to fail. + `false` if installing one causes the handshake to fail. @see tls_context::set_verify_callback */ @@ -254,7 +257,7 @@ BOOST_COROSIO_DECL bool wolfssl_supports_verify_callback() noexcept; silently negotiating nothing. @return `true` if ALPN is supported by this build, `false` if - offering protocols will cause the handshake to fail. + offering protocols causes the handshake to fail. @see tls_context::set_alpn, tls_stream::alpn_protocol */ @@ -276,14 +279,14 @@ BOOST_COROSIO_DECL bool wolfssl_supports_crl() noexcept; /** Report whether this WolfSSL build can verify IP-literal hostnames. Matching an IP literal against a certificate's iPAddress entries - requires a WolfSSL built with both `OPENSSL_EXTRA` (routes the - address into the verify parameters the certificate check consults) - and `WOLFSSL_IP_ALT_NAME` (records iPAddress entries during - parsing). On a build lacking either, `wolfSSL_check_ip_address` - reports success but verification silently checks nothing, so a - handshake with an IP literal set via @ref tls_stream::set_hostname - fails with `std::errc::function_not_supported` rather than proceed - unverified. + requires a WolfSSL built with both `OPENSSL_EXTRA` and + `WOLFSSL_IP_ALT_NAME`. The first routes the address into the verify + parameters the certificate check consults. The second records + iPAddress entries during parsing. On a build lacking either, + `wolfSSL_check_ip_address` reports success. Verification silently + checks nothing. A handshake with an IP literal set via @ref + tls_stream::set_hostname therefore fails with + `std::errc::function_not_supported` rather than proceed unverified. @return `true` if IP-literal verification is supported by this build. diff --git a/test/doc/reference/tls_context__set_verify_depth.function.cpp b/test/doc/reference/tls_context__set_verify_depth.function.cpp new file mode 100644 index 000000000..860d02884 --- /dev/null +++ b/test/doc/reference/tls_context__set_verify_depth.function.cpp @@ -0,0 +1,38 @@ +// +// Copyright (c) 2026 Michael Vandeberg +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/corosio +// + +// Reference example injected into include/boost/corosio/tls_context.hpp's +// documentation for tls_context::set_verify_depth, by +// doc/addons/extensions/reference-snippets.lua. The tagged region is what the +// reference renders; scaffolding stays outside the tags. + +#include "../doc_warnings.hpp" + +#include + +namespace corosio = boost::corosio; + +namespace { + +// tag::set_verify_depth[] +// Configure before any stream is created from ctx; modifying a context +// afterwards is undefined behavior. +void +limit_the_chain_depth(corosio::tls_context& ctx) +{ + // Reject chains with more than four intermediate certificates between + // the peer certificate and a trusted root. The default, around 100, is + // permissive; lower it once the deployment's actual chain depth is + // known, since a chain that exceeds the limit fails the handshake. + if (auto ec = ctx.set_verify_depth(4)) + return; // report the error +} +// end::set_verify_depth[] + +} // namespace diff --git a/test/doc/snippets/3d_tls_context.cpp b/test/doc/snippets/3d_tls_context.cpp index e3d029879..95970b51e 100644 --- a/test/doc/snippets/3d_tls_context.cpp +++ b/test/doc/snippets/3d_tls_context.cpp @@ -44,7 +44,6 @@ #include namespace corosio = boost::corosio; -using namespace boost::corosio; // end::assume[] #include @@ -70,13 +69,13 @@ construction() { // tag::construction[] // Create a default context - tls_context ctx; + corosio::tls_context ctx; // Copy shares the same underlying state - tls_context ctx2 = ctx; // ctx and ctx2 share state + corosio::tls_context ctx2 = ctx; // ctx and ctx2 share state // Move transfers ownership - tls_context ctx3 = std::move(ctx); + corosio::tls_context ctx3 = std::move(ctx); // ctx is now empty // end::construction[] } @@ -85,12 +84,13 @@ void typical_setup() { // tag::typical_setup[] - tls_context ctx; + corosio::tls_context ctx; // 1. Load credentials (for servers, or clients using client certs) if (auto ec = ctx.use_certificate_chain_file("server.crt")) return; - if (auto ec = ctx.use_private_key_file("server.key", tls_file_format::pem)) + if (auto ec = ctx.use_private_key_file( + "server.key", corosio::tls_file_format::pem)) return; // 2. Configure trust anchors (for verifying peer certificates) @@ -98,17 +98,17 @@ typical_setup() return; // 3. Set verification mode - if (auto ec = ctx.set_verify_mode(tls_verify_mode::peer)) + if (auto ec = ctx.set_verify_mode(corosio::tls_verify_mode::peer)) return; // 4. Configure protocol options (optional) - if (auto ec = ctx.set_min_protocol_version(tls_version::tls_1_2)) + if (auto ec = ctx.set_min_protocol_version(corosio::tls_version::tls_1_2)) return; // end::typical_setup[] } void -load_separate(tls_context& ctx) +load_separate(corosio::tls_context& ctx) { // tag::load_separate[] // Load certificate chain (leaf + intermediates) @@ -116,24 +116,27 @@ load_separate(tls_context& ctx) return; // Load the matching private key - if (auto ec = ctx.use_private_key_file("privkey.key", tls_file_format::pem)) + if (auto ec = ctx.use_private_key_file( + "privkey.key", corosio::tls_file_format::pem)) return; // end::load_separate[] } void -load_single(tls_context& ctx) +load_single(corosio::tls_context& ctx) { // tag::load_single[] - if (auto ec = ctx.use_certificate_file("server.crt", tls_file_format::pem)) + if (auto ec = ctx.use_certificate_file( + "server.crt", corosio::tls_file_format::pem)) return; - if (auto ec = ctx.use_private_key_file("server.key", tls_file_format::pem)) + if (auto ec = ctx.use_private_key_file( + "server.key", corosio::tls_file_format::pem)) return; // end::load_single[] } void -pkcs12_bundle(tls_context& ctx) +pkcs12_bundle(corosio::tls_context& ctx) { // tag::pkcs12_file[] if (auto ec = ctx.use_pkcs12_file("credentials.pfx", "bundle-password")) @@ -154,7 +157,7 @@ fetch_key_from_vault() } void -load_memory(tls_context& ctx) +load_memory(corosio::tls_context& ctx) { // tag::load_memory[] std::string cert_pem = fetch_certificate_from_vault(); @@ -162,25 +165,26 @@ load_memory(tls_context& ctx) if (auto ec = ctx.use_certificate_chain(cert_pem)) return; - if (auto ec = ctx.use_private_key(key_pem, tls_file_format::pem)) + if (auto ec = ctx.use_private_key(key_pem, corosio::tls_file_format::pem)) return; // end::load_memory[] } void -der_files(tls_context& ctx) +der_files(corosio::tls_context& ctx) { // tag::der_files[] - if (auto ec = ctx.use_certificate_file("server.der", tls_file_format::der)) + if (auto ec = ctx.use_certificate_file( + "server.der", corosio::tls_file_format::der)) return; - if (auto ec = - ctx.use_private_key_file("server.key.der", tls_file_format::der)) + if (auto ec = ctx.use_private_key_file( + "server.key.der", corosio::tls_file_format::der)) return; // end::der_files[] } void -system_trust(tls_context& ctx) +system_trust(corosio::tls_context& ctx) { // tag::system_trust[] if (auto ec = ctx.set_default_verify_paths()) @@ -189,7 +193,7 @@ system_trust(tls_context& ctx) } void -ca_bundle(tls_context& ctx) +ca_bundle(corosio::tls_context& ctx) { // tag::ca_bundle[] // Load CA bundle file (may contain multiple CAs) @@ -199,7 +203,7 @@ ca_bundle(tls_context& ctx) } void -ca_directory(tls_context& ctx) +ca_directory(corosio::tls_context& ctx) { // tag::ca_directory[] if (auto ec = ctx.add_verify_path("/etc/ssl/certs")) @@ -215,7 +219,7 @@ load_ca_from_config() void ca_individual( - tls_context& ctx, + corosio::tls_context& ctx, std::string const& root_ca_pem, std::string const& intermediate_ca_pem) { @@ -234,7 +238,7 @@ ca_individual( } void -combine_trust(tls_context& ctx, std::string const& corporate_ca_pem) +combine_trust(corosio::tls_context& ctx, std::string const& corporate_ca_pem) { // tag::combine_trust[] // Start with system trust store @@ -248,23 +252,23 @@ combine_trust(tls_context& ctx, std::string const& corporate_ca_pem) } void -version_bounds(tls_context& ctx) +version_bounds(corosio::tls_context& ctx) { // tag::version_bounds[] // Require TLS 1.2 or newer (default) - if (auto ec = ctx.set_min_protocol_version(tls_version::tls_1_2)) + if (auto ec = ctx.set_min_protocol_version(corosio::tls_version::tls_1_2)) return; // Require TLS 1.3 only - if (auto ec = ctx.set_min_protocol_version(tls_version::tls_1_3)) + if (auto ec = ctx.set_min_protocol_version(corosio::tls_version::tls_1_3)) return; - if (auto ec = ctx.set_max_protocol_version(tls_version::tls_1_3)) + if (auto ec = ctx.set_max_protocol_version(corosio::tls_version::tls_1_3)) return; // end::version_bounds[] } void -cipher_suites(tls_context& ctx) +cipher_suites(corosio::tls_context& ctx) { // tag::cipher_suites[] // TLS 1.2 and below @@ -278,7 +282,7 @@ cipher_suites(tls_context& ctx) } void -alpn_offer(tls_context& ctx) +alpn_offer(corosio::tls_context& ctx) { // tag::alpn_offer[] // HTTP/2 with HTTP/1.1 fallback @@ -302,19 +306,19 @@ alpn_read(corosio::tls_stream& stream) } void -verify_modes(tls_context& ctx) +verify_modes(corosio::tls_context& ctx) { // tag::verify_modes[] // Don't verify peer (not recommended for production) - if (auto ec = ctx.set_verify_mode(tls_verify_mode::none)) + if (auto ec = ctx.set_verify_mode(corosio::tls_verify_mode::none)) return; // Verify peer if certificate is presented - if (auto ec = ctx.set_verify_mode(tls_verify_mode::peer)) + if (auto ec = ctx.set_verify_mode(corosio::tls_verify_mode::peer)) return; // Require and verify peer certificate (mTLS server-side) - if (auto ec = ctx.set_verify_mode(tls_verify_mode::require_peer)) + if (auto ec = ctx.set_verify_mode(corosio::tls_verify_mode::require_peer)) return; // end::verify_modes[] } @@ -330,7 +334,7 @@ hostname_setup(corosio::tls_stream& secure) } void -verify_depth(tls_context& ctx) +verify_depth(corosio::tls_context& ctx) { // tag::verify_depth[] // Allow up to 3 intermediates (leaf -> 3 intermediates -> root) @@ -342,7 +346,7 @@ verify_depth(tls_context& ctx) std::vector const expected_pin; void -verify_callback(tls_context& ctx) +verify_callback(corosio::tls_context& ctx) { // tag::verify_callback[] ctx.set_verify_callback( @@ -358,17 +362,17 @@ verify_callback(tls_context& ctx) } void -revocation_policy(tls_context& ctx) +revocation_policy(corosio::tls_context& ctx) { // tag::revocation_policy[] // Don't check revocation (default) - ctx.set_revocation_policy(tls_revocation_policy::disabled); + ctx.set_revocation_policy(corosio::tls_revocation_policy::disabled); // Accept unknown status, reject a listed (revoked) certificate - ctx.set_revocation_policy(tls_revocation_policy::soft_fail); + ctx.set_revocation_policy(corosio::tls_revocation_policy::soft_fail); // Also reject when status can't be determined (strict) - ctx.set_revocation_policy(tls_revocation_policy::hard_fail); + ctx.set_revocation_policy(corosio::tls_revocation_policy::hard_fail); // end::revocation_policy[] } @@ -379,7 +383,7 @@ fetch_crl_from_url(std::string_view) } void -crl_load(tls_context& ctx, std::string_view crl_url) +crl_load(corosio::tls_context& ctx, std::string_view crl_url) { // tag::crl_load[] // From file @@ -391,7 +395,7 @@ crl_load(tls_context& ctx, std::string_view crl_url) if (auto ec = ctx.add_crl(crl_data)) return; - ctx.set_revocation_policy(tls_revocation_policy::hard_fail); + ctx.set_revocation_policy(corosio::tls_revocation_policy::hard_fail); // end::crl_load[] } @@ -400,34 +404,36 @@ bootstrap_hardened() { // tag::bootstrap_hardened[] // Bootstrap context: for fetching revocation data - tls_context bootstrap_ctx; + corosio::tls_context bootstrap_ctx; bootstrap_ctx.set_default_verify_paths(); - bootstrap_ctx.set_verify_mode(tls_verify_mode::peer); - bootstrap_ctx.set_revocation_policy(tls_revocation_policy::disabled); + bootstrap_ctx.set_verify_mode(corosio::tls_verify_mode::peer); + bootstrap_ctx.set_revocation_policy( + corosio::tls_revocation_policy::disabled); // Hardened context: for sensitive connections - tls_context hardened_ctx; + corosio::tls_context hardened_ctx; hardened_ctx.set_default_verify_paths(); - hardened_ctx.set_verify_mode(tls_verify_mode::peer); + hardened_ctx.set_verify_mode(corosio::tls_verify_mode::peer); hardened_ctx.add_crl_file("cached.crl"); - hardened_ctx.set_revocation_policy(tls_revocation_policy::hard_fail); + hardened_ctx.set_revocation_policy( + corosio::tls_revocation_policy::hard_fail); // end::bootstrap_hardened[] } void -password_callback(tls_context& ctx) +password_callback(corosio::tls_context& ctx) { // tag::password_callback[] // Set callback before loading encrypted key ctx.set_password_callback( - [](std::size_t max_length, tls_password_purpose purpose) { + [](std::size_t max_length, corosio::tls_password_purpose purpose) { // purpose: for_reading (decrypt) or for_writing (encrypt) return std::string("my-secret-password"); }); // Now load encrypted private key - if (auto ec = - ctx.use_private_key_file("encrypted.key", tls_file_format::pem)) + if (auto ec = ctx.use_private_key_file( + "encrypted.key", corosio::tls_file_format::pem)) return; // end::password_callback[] } @@ -439,11 +445,11 @@ prompt_user_for_password() } void -password_env(tls_context& ctx) +password_env(corosio::tls_context& ctx) { // tag::password_env[] ctx.set_password_callback( - [](std::size_t max_length, tls_password_purpose purpose) { + [](std::size_t max_length, corosio::tls_password_purpose purpose) { // Read from environment if (auto* pw = std::getenv("TLS_KEY_PASSWORD")) return std::string(pw); @@ -455,7 +461,7 @@ password_env(tls_context& ctx) } void -pkcs12_memory(tls_context& ctx, std::string_view pkcs12_data) +pkcs12_memory(corosio::tls_context& ctx, std::string_view pkcs12_data) { // tag::pkcs12_memory[] if (auto ec = ctx.use_pkcs12(pkcs12_data, "bundle-password")) @@ -466,11 +472,12 @@ pkcs12_memory(tls_context& ctx, std::string_view pkcs12_data) // Throws std::system_error when the credential files are absent, as // they are under the test runner; compiled but never executed. [[maybe_unused]] void -error_handling(tls_context& ctx) +error_handling(corosio::tls_context& ctx) { // tag::error_handling[] // Throw on error - if (auto ec = ctx.use_certificate_file("cert.pem", tls_file_format::pem)) + if (auto ec = + ctx.use_certificate_file("cert.pem", corosio::tls_file_format::pem)) throw std::system_error(ec); // Check error explicitly @@ -489,7 +496,7 @@ struct tls_context_3d_test // Configuration calls record settings and report failures as // error codes, so every context-only fragment executes safely // without credential files or a TLS peer. - tls_context ctx; + corosio::tls_context ctx; construction(); typical_setup(); load_separate(ctx); diff --git a/test/doc/snippets/4g_composed_operations.cpp b/test/doc/snippets/4g_composed_operations.cpp index 1eab940e5..09d39623e 100644 --- a/test/doc/snippets/4g_composed_operations.cpp +++ b/test/doc/snippets/4g_composed_operations.cpp @@ -71,23 +71,23 @@ namespace { // to compile. namespace synopsis { -using namespace boost::capy; - // tag::read_signature[] -auto read(ReadStream auto& stream, MutableBufferSequence auto buffers) +auto +read(capy::ReadStream auto& stream, capy::MutableBufferSequence auto buffers) -> capy::io_task; // end::read_signature[] // tag::write_signature[] -auto write(WriteStream auto& stream, ConstBufferSequence auto buffers) +auto +write(capy::WriteStream auto& stream, capy::ConstBufferSequence auto buffers) -> capy::io_task; // end::write_signature[] // tag::slice_interface[] template - requires MutableBufferSequence || - ConstBufferSequence -slice_type buffer_slice( + requires capy::MutableBufferSequence || + capy::ConstBufferSequence +capy::slice_type buffer_slice( BufferSequence const& seq, std::size_t offset = 0, std::size_t length = (std::numeric_limits::max)()); diff --git a/test/doc/snippets/4l_tls.cpp b/test/doc/snippets/4l_tls.cpp index 6f55e365b..17cb634a4 100644 --- a/test/doc/snippets/4l_tls.cpp +++ b/test/doc/snippets/4l_tls.cpp @@ -50,7 +50,6 @@ namespace corosio = boost::corosio; namespace capy = boost::capy; -using namespace boost::corosio; // end::assume[] #include @@ -77,9 +76,10 @@ namespace { verified_client(corosio::io_context& ioc) { // tag::verified_client[] - tls_context ctx; - ctx.set_default_verify_paths(); // trust the system CAs - ctx.set_verify_mode(tls_verify_mode::peer); // require + verify the peer + corosio::tls_context ctx; + ctx.set_default_verify_paths(); // trust the system CAs + ctx.set_verify_mode( + corosio::tls_verify_mode::peer); // require + verify the peer corosio::tcp_socket sock(ioc); corosio::wolfssl_stream secure(&sock, ctx); @@ -96,10 +96,10 @@ typical_flow( { // tag::typical_flow[] // 1. Configure a context - tls_context ctx; + corosio::tls_context ctx; if (auto ec = ctx.set_default_verify_paths(); ec) throw std::system_error(ec); - if (auto ec = ctx.set_verify_mode(tls_verify_mode::peer); ec) + if (auto ec = ctx.set_verify_mode(corosio::tls_verify_mode::peer); ec) throw std::system_error(ec); // 2. Connect a socket (connect() opens it automatically) @@ -110,7 +110,7 @@ typical_flow( // 3. Wrap the connected socket (pointer form; does not take ownership) corosio::wolfssl_stream secure(&sock, ctx); secure.set_hostname("api.example.com"); - if (auto [ec] = co_await secure.handshake(tls_role::client); ec) + if (auto [ec] = co_await secure.handshake(corosio::tls_role::client); ec) throw std::system_error(ec); // 4. Use encrypted I/O @@ -164,7 +164,7 @@ wolfssl_construction(corosio::io_context& ioc) corosio::tcp_socket sock(ioc); // ... connect sock ... - tls_context ctx; + corosio::tls_context ctx; // ... configure ctx ... // Reference form: sock must outlive secure @@ -177,7 +177,7 @@ wolfssl_construction(corosio::io_context& ioc) #endif [[maybe_unused]] void -alpn_read_back(tls_context& ctx, corosio::tls_stream& stream) +alpn_read_back(corosio::tls_context& ctx, corosio::tls_stream& stream) { // tag::alpn_config[] // Prefer HTTP/2, fall back to HTTP/1.1 @@ -200,7 +200,7 @@ hostname_verification(corosio::tls_stream& secure) client_handshake(corosio::tls_stream& secure) { // tag::client_handshake[] - auto [ec] = co_await secure.handshake(tls_role::client); + auto [ec] = co_await secure.handshake(corosio::tls_role::client); if (ec) { std::cerr << "Handshake failed: " << ec.message() << "\n"; @@ -213,7 +213,7 @@ client_handshake(corosio::tls_stream& secure) server_handshake(corosio::tls_stream& secure) { // tag::server_handshake[] - auto [ec] = co_await secure.handshake(tls_role::server); + auto [ec] = co_await secure.handshake(corosio::tls_role::server); // end::server_handshake[] } @@ -293,7 +293,7 @@ send_request(corosio::tls_stream& stream) // at column zero even though they live inside a scaffolding coroutine // (Asciidoctor concatenates same-name tag regions). [[maybe_unused]] capy::task<> -overload_selection(corosio::io_context& ioc, tls_context& ctx) +overload_selection(corosio::io_context& ioc, corosio::tls_context& ctx) { // tag::stream_overloads[] @@ -332,16 +332,16 @@ https_get( } // Configure TLS - tls_context ctx; + corosio::tls_context ctx; if (auto ec = ctx.set_default_verify_paths(); ec) throw std::system_error(ec); - if (auto ec = ctx.set_verify_mode(tls_verify_mode::peer); ec) + if (auto ec = ctx.set_verify_mode(corosio::tls_verify_mode::peer); ec) throw std::system_error(ec); // Wrap the connected socket (pointer form) and handshake corosio::wolfssl_stream secure(&sock, ctx); secure.set_hostname(hostname); - if (auto [ec] = co_await secure.handshake(tls_role::client); ec) + if (auto [ec] = co_await secure.handshake(corosio::tls_role::client); ec) throw std::system_error(ec); // Send HTTP request @@ -381,7 +381,8 @@ https_get( } // end::https_get[] -capy::task handle_tls_client(corosio::tcp_socket sock, tls_context ctx); +capy::task +handle_tls_client(corosio::tcp_socket sock, corosio::tls_context ctx); // Binds a port and accepts forever; compiled but never executed. // tag::tls_server[] @@ -389,9 +390,9 @@ capy::task tls_server(corosio::io_context& ioc, std::uint16_t port) { // Configure server TLS context - tls_context ctx; + corosio::tls_context ctx; ctx.use_certificate_chain_file("server-fullchain.pem"); - ctx.use_private_key_file("server.key", tls_file_format::pem); + ctx.use_private_key_file("server.key", corosio::tls_file_format::pem); // Set up acceptor corosio::tcp_acceptor acc(ioc, corosio::endpoint(port)); @@ -410,12 +411,12 @@ tls_server(corosio::io_context& ioc, std::uint16_t port) } capy::task -handle_tls_client(corosio::tcp_socket sock, tls_context ctx) +handle_tls_client(corosio::tcp_socket sock, corosio::tls_context ctx) { // Owning form: the handler owns the socket, so move it in corosio::wolfssl_stream secure(std::move(sock), ctx); - auto [ec] = co_await secure.handshake(tls_role::server); + auto [ec] = co_await secure.handshake(corosio::tls_role::server); if (ec) co_return; @@ -440,21 +441,21 @@ struct tls_test { // tag::default_context[] // Default context (TLS 1.2+ enabled) - tls_context ctx; + corosio::tls_context ctx; // end::default_context[] BOOST_TEST(true); } void testLoadCertificates() { - tls_context ctx; + corosio::tls_context ctx; // tag::load_certificates[] // From file - ctx.use_certificate_file("server.crt", tls_file_format::pem); + ctx.use_certificate_file("server.crt", corosio::tls_file_format::pem); // From memory std::string cert_data = load_cert_pem(); - ctx.use_certificate(cert_data, tls_file_format::pem); + ctx.use_certificate(cert_data, corosio::tls_file_format::pem); // Certificate chain (cert + intermediates) ctx.use_certificate_chain_file("fullchain.pem"); @@ -464,34 +465,35 @@ struct tls_test void testLoadPrivateKeys() { - tls_context ctx; + corosio::tls_context ctx; std::string key_data = load_cert_pem(); // tag::load_private_keys[] // From file - ctx.use_private_key_file("server.key", tls_file_format::pem); + ctx.use_private_key_file("server.key", corosio::tls_file_format::pem); // From memory - ctx.use_private_key(key_data, tls_file_format::pem); + ctx.use_private_key(key_data, corosio::tls_file_format::pem); // end::load_private_keys[] BOOST_TEST(true); } void testPasswordCallback() { - tls_context ctx; + corosio::tls_context ctx; // tag::password_callback[] ctx.set_password_callback( - [](std::size_t max_len, tls_password_purpose purpose) { + [](std::size_t max_len, corosio::tls_password_purpose purpose) { return std::string("my-key-password"); }); - ctx.use_private_key_file("encrypted.key", tls_file_format::pem); + ctx.use_private_key_file( + "encrypted.key", corosio::tls_file_format::pem); // end::password_callback[] BOOST_TEST(true); } void testPkcs12() { - tls_context ctx; + corosio::tls_context ctx; // tag::pkcs12[] ctx.use_pkcs12_file("credentials.pfx", "bundle-password"); // end::pkcs12[] @@ -500,7 +502,7 @@ struct tls_test void testSystemTrust() { - tls_context ctx; + corosio::tls_context ctx; // tag::system_trust[] ctx.set_default_verify_paths(); // end::system_trust[] @@ -509,7 +511,7 @@ struct tls_test void testCustomCas() { - tls_context ctx; + corosio::tls_context ctx; std::string ca_pem = load_cert_pem(); // tag::custom_cas[] // Single CA from memory @@ -528,20 +530,20 @@ struct tls_test void testProtocolVersions() { - tls_context ctx; + corosio::tls_context ctx; // tag::protocol_versions[] // Require TLS 1.3 minimum - ctx.set_min_protocol_version(tls_version::tls_1_3); + ctx.set_min_protocol_version(corosio::tls_version::tls_1_3); // Cap at TLS 1.2 (unusual, but possible) - ctx.set_max_protocol_version(tls_version::tls_1_2); + ctx.set_max_protocol_version(corosio::tls_version::tls_1_2); // end::protocol_versions[] BOOST_TEST(true); } void testCipherSuites() { - tls_context ctx; + corosio::tls_context ctx; // tag::cipher_suites[] // TLS 1.2-and-below cipher list (OpenSSL syntax) ctx.set_ciphersuites("ECDHE+AESGCM:ECDHE+CHACHA20"); @@ -553,23 +555,23 @@ struct tls_test void testVerifyModes() { - tls_context ctx; + corosio::tls_context ctx; // tag::verify_modes[] // Don't verify peer (not recommended for clients) - ctx.set_verify_mode(tls_verify_mode::none); + ctx.set_verify_mode(corosio::tls_verify_mode::none); // Verify if peer presents certificate - ctx.set_verify_mode(tls_verify_mode::peer); + ctx.set_verify_mode(corosio::tls_verify_mode::peer); // Require peer certificate (fail if not presented) - ctx.set_verify_mode(tls_verify_mode::require_peer); + ctx.set_verify_mode(corosio::tls_verify_mode::require_peer); // end::verify_modes[] - BOOST_TEST(!ctx.set_verify_mode(tls_verify_mode::peer)); + BOOST_TEST(!ctx.set_verify_mode(corosio::tls_verify_mode::peer)); } void testVerifyDepth() { - tls_context ctx; + corosio::tls_context ctx; // tag::verify_depth[] ctx.set_verify_depth(10); // Max 10 intermediate certs // end::verify_depth[] @@ -578,7 +580,7 @@ struct tls_test void testVerifyCallback() { - tls_context ctx; + corosio::tls_context ctx; // tag::verify_callback[] ctx.set_verify_callback( [](bool preverified, corosio::verify_context& ctx) { @@ -595,7 +597,7 @@ struct tls_test void testRevocation() { - tls_context ctx; + corosio::tls_context ctx; std::string crl_data = load_cert_pem(); // tag::revocation[] // Load a CRL (PEM or DER), from file or memory @@ -603,7 +605,7 @@ struct tls_test ctx.add_crl(crl_data); // Choose how strict to be - ctx.set_revocation_policy(tls_revocation_policy::hard_fail); + ctx.set_revocation_policy(corosio::tls_revocation_policy::hard_fail); // end::revocation[] BOOST_TEST(true); } @@ -611,29 +613,33 @@ struct tls_test void testMutualTlsServer() { // tag::mtls_server[] - tls_context server_ctx; + corosio::tls_context server_ctx; server_ctx.use_certificate_chain_file("server.pem"); - server_ctx.use_private_key_file("server.key", tls_file_format::pem); + server_ctx.use_private_key_file( + "server.key", corosio::tls_file_format::pem); // Require client certificate - server_ctx.set_verify_mode(tls_verify_mode::require_peer); + server_ctx.set_verify_mode(corosio::tls_verify_mode::require_peer); server_ctx.load_verify_file("client-ca.pem"); // end::mtls_server[] - BOOST_TEST(!server_ctx.set_verify_mode(tls_verify_mode::require_peer)); + BOOST_TEST(!server_ctx.set_verify_mode( + corosio::tls_verify_mode::require_peer)); } void testMutualTlsClient() { // tag::mtls_client[] - tls_context client_ctx; + corosio::tls_context client_ctx; client_ctx.set_default_verify_paths(); - client_ctx.set_verify_mode(tls_verify_mode::peer); + client_ctx.set_verify_mode(corosio::tls_verify_mode::peer); // Provide client certificate - client_ctx.use_certificate_file("client.crt", tls_file_format::pem); - client_ctx.use_private_key_file("client.key", tls_file_format::pem); + client_ctx.use_certificate_file( + "client.crt", corosio::tls_file_format::pem); + client_ctx.use_private_key_file( + "client.key", corosio::tls_file_format::pem); // end::mtls_client[] - BOOST_TEST(!client_ctx.set_verify_mode(tls_verify_mode::peer)); + BOOST_TEST(!client_ctx.set_verify_mode(corosio::tls_verify_mode::peer)); } void run() diff --git a/test/doc/snippets/4r_wait.cpp b/test/doc/snippets/4r_wait.cpp index 574eac455..135bc9723 100644 --- a/test/doc/snippets/4r_wait.cpp +++ b/test/doc/snippets/4r_wait.cpp @@ -65,19 +65,6 @@ using namespace std::chrono_literals; namespace { -// The page shows the enum's shape; the real one lives in -// . -namespace api_sketch { -// tag::wait_type_enum[] -enum class wait_type -{ - read, - write, - error -}; -// end::wait_type_enum[] -} // namespace api_sketch - capy::task<> wait_readable(corosio::tcp_socket& sock, std::error_code& ec_out) {