diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f371f11..d1dd16e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -259,6 +259,41 @@ jobs: - name: Cada --filter documentado casa con ≥1 test run: bash .github/scripts/check-test-filters.sh + # ── License gate: third-party notices for the redistributed native binaries ─────────────── + # BLOCKING. The NuGet packages ship the cdylibs, which statically link Rust crates — so their + # licenses are redistributed too. This job regenerates THIRD-PARTY-NOTICES.md with cargo-about + # and fails if (a) a dependency introduces a license not in `accepted` (native/about.toml), or + # (b) the committed notice is stale. Keeps the attribution honest as the dependency tree moves + # (e.g. a yrs/Loro bump, FU-015). cargo-about is pinned for reproducible output. + third-party-notices: + name: third-party-notices (license gate) + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v5 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + workspaces: native + - name: Install cargo-about (pinned) + run: | + VER=0.9.1 + curl -sL "https://github.com/EmbarkStudios/cargo-about/releases/download/${VER}/cargo-about-${VER}-x86_64-unknown-linux-musl.tar.gz" | tar xz -C /tmp + find /tmp -name cargo-about -type f -executable | head -1 | xargs -I{} sudo mv {} /usr/local/bin/cargo-about + cargo-about --version + # Fails here if a dependency's license is not in `accepted` (native/about.toml). + - name: Regenerate THIRD-PARTY-NOTICES.md + run: cargo about generate --manifest-path native/Cargo.toml native/about.hbs -o THIRD-PARTY-NOTICES.md + # Fails here if the committed notice does not match what was just regenerated. + - name: Verify committed notice is up to date + run: | + if ! git diff --quiet -- THIRD-PARTY-NOTICES.md; then + echo "::error::THIRD-PARTY-NOTICES.md is stale. Regenerate with the command in native/about.toml and commit the result." + git diff --stat -- THIRD-PARTY-NOTICES.md + exit 1 + fi + echo "THIRD-PARTY-NOTICES.md is up to date." + # ── Gate M1 (P-V, SC-006): prueba de carga de concurrencia ──────────────────────────────── # Nightly (schedule) + manual (workflow_dispatch): NO corre en PR (no lo bloquea), pero es # BLOQUEANTE para el cierre de M1 — el nightly debe estar verde. El harness sale con código ≠ 0 diff --git a/.markdownlintignore b/.markdownlintignore new file mode 100644 index 0000000..209827f --- /dev/null +++ b/.markdownlintignore @@ -0,0 +1,3 @@ +# Generated file — verbatim third-party license texts, not authored Markdown. +# Regenerated and gated by the `third-party-notices` CI job (see native/about.toml). +THIRD-PARTY-NOTICES.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..919b146 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,49 @@ + + +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +Because a document's version identity is the `SHA-256` of its deterministic export, an engine +encoding change is **breaking** (it invalidates the citability of prior versions) and bumps the +major. + +## [Unreleased] + +First release candidate. Everything below is built and verified in CI, pending the operator-gated +publish to NuGet.org. + +### Added + +- **Core document & engine (`Weft.Core`).** A CRDT engine abstraction (`ICrdtEngine` / `ICrdtDoc`) + over the Rust `yrs` core via an in-house C-ABI shim, with a deterministic native resource + lifecycle (`SafeHandle`, explicit disposal, no GC touching native memory) and native panics + translated to typed `WeftException`s. +- **Content-addressed versioning (`Weft.Versioning`).** Publish immutable versions identified by + the `SHA-256` of the deterministic export; content-addressed blob store (in-memory, filesystem); + word-level LCS text diff; branch/merge with automatic convergence; compaction that preserves all + published versions. Engine-agnostic (no dependency on a concrete engine). +- **Concurrency & lifecycle at scale.** A document broker serializing all access per document + (actor/channel, single-reader), with registration, reuse, and inactivity eviction. +- **Sync relay server (`Weft.Server`).** A WebSocket relay speaking the standard Yjs `y-sync` + protocol — existing editor clients (Tiptap + `y-prosemirror`) connect without adaptation. + Ephemeral awareness/presence; incremental reconnect via state vectors; a pluggable authorization + extension point (`IWeftAuthorizer`); pluggable persistence adapters (in-memory, filesystem, + EF Core relational, Redis) validated by a shared contract suite; persist-before-broadcast + durability by default, with directory-fsync. +- **Replaceable engine (`Weft.Loro`).** A Loro adapter implementing the engine abstraction and its + optional native versioning surface (`INativeVersioning`), kept compilable and exercised in CI as + a continuous portability proof. Cross-engine deterministic seeding (`IDeterministicSeeding`). +- **Distribution.** NuGet packaging with native binaries for `linux-x64`, `linux-arm64`, `win-x64`, + and `osx-arm64`, resolved automatically per platform; a release pipeline (symbols + SourceLink). +- **Quality gates in CI.** Multi-platform build/tests; ASan/LSan memory verification; `cargo-fuzz` + fuzzing of the native boundary and convergence; an encoding-determinism gate cross-checked against + the Yjs JS implementation. +- **Samples & docs.** `Weft.Sample.Versioning`, `Weft.Sample.Server`, and a `tiptap-client` + (real editor + headless wire-compat check); architecture doc, per-package API overview, quickstart, + and governance/contribution guides. + +[Unreleased]: https://github.com/StrangeDaysTech/weft/commits/main diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..07de989 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,83 @@ + + +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our community a +harassment-free experience for everyone, regardless of age, body size, visible or invisible +disability, ethnicity, sex characteristics, gender identity and expression, level of experience, +education, socio-economic status, nationality, personal appearance, race, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, +and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the + experience +- Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or advances of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, without their + explicit permission +- Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior +and will take appropriate and fair corrective action in response to any behavior that they deem +inappropriate, threatening, offensive, or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, +code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and +will communicate reasons for moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is +officially representing the community in public spaces. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community +leaders responsible for enforcement at [Strange Days Tech](https://strangedays.tech/en). All +complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the reporter of any +incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining the consequences for +any action they deem in violation of this Code of Conduct: + +1. **Correction** — Community Impact: use of inappropriate language or other behavior deemed + unprofessional or unwelcome. Consequence: a private, written warning, providing clarity around + the nature of the violation and an explanation of why the behavior was inappropriate. +2. **Warning** — Community Impact: a violation through a single incident or series of actions. + Consequence: a warning with consequences for continued behavior. +3. **Temporary Ban** — Community Impact: a serious violation of community standards. Consequence: a + temporary ban from any sort of interaction or public communication with the community. +4. **Permanent Ban** — Community Impact: demonstrating a pattern of violation of community + standards. Consequence: a permanent ban from any sort of public interaction within the + community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), +version 2.1, available at +. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fc313de..7651b3b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,82 +1,82 @@ -# Contribuir a Weft +# Contributing to Weft -Gracias por tu interés. Weft es una librería .NET (Apache-2.0) de colaboración CRDT en tiempo real y -versionado content-addressed sobre el core Rust `yrs`, vía un shim C-ABI propio. +Thanks for your interest. Weft is a .NET library (Apache-2.0) for real-time CRDT collaboration and +content-addressed versioning over the Rust `yrs` core, via an in-house C-ABI shim. ## CLA -Toda contribución requiere firmar el **CLA** (Contributor License Agreement) — un bot lo pide -automáticamente en tu primer PR. Ver [`CLA.md`](./CLA.md). +Every contribution requires signing the **CLA** (Contributor License Agreement) — a bot requests it +automatically on your first PR. See [`CLA.md`](./CLA.md). ## Toolchain - **.NET SDK 10** (`net10.0`, C# 13). -- **Rust stable** (fijado en `native/rust-toolchain.toml`); `nightly` solo para ASan/LSan (lo instala CI). -- Para cross-compilar los binarios nativos localmente (RIDs de Linux): `cargo-zigbuild` + `zig` 0.15.x. -- Opcional: **Node.js** para el gate de determinismo cross-implementación (`tests/determinism-yjs/`) y el - cliente de ejemplo Tiptap. +- **Rust stable** (pinned in `native/rust-toolchain.toml`); `nightly` only for ASan/LSan (CI installs it). +- To cross-compile the native binaries locally (Linux RIDs): `cargo-zigbuild` + `zig` 0.15.x. +- Optional: **Node.js** for the cross-implementation determinism gate (`tests/determinism-yjs/`) and the + Tiptap sample client. -## Construir y probar +## Build and test ```bash -# Shim nativo (con test-hooks para la suite de panic-safety, SC-009) +# Native shim (with test-hooks for the panic-safety suite, SC-009) cd native && cargo build --release --features test-hooks && cargo test --features test-hooks && cd .. -# Solución .NET completa +# Full .NET solution dotnet build Weft.sln -c Release -dotnet test Weft.sln -c Release # el test de Redis se salta sin WEFT_TEST_REDIS (Valkey/Redis local) +dotnet test Weft.sln -c Release # the Redis test is skipped without WEFT_TEST_REDIS (local Valkey/Redis) ``` -> **No empaquetes (`dotnet pack`) desde un árbol compilado con `--features test-hooks`.** El pack lee -> el cdylib de `native/target//release/`; si lo construiste con la feature (p. ej. tras -> `cargo build --release --target --features test-hooks`), el `.nupkg` incluiría el símbolo -> `weft_test_panic`/`weft_loro_test_panic`. El gate SC-009 que verifica su ausencia solo corre en el -> pipeline de `release.yml`, **no** en un pack local. Para publicar, compila el nativo **sin** la -> feature (FU-019). - -## Gates (constitución) - -La constitución del proyecto (`.specify/memory/constitution.md`) fija 6 principios **vinculantes**, cada uno -con su gate de CI. Un PR no se mergea sin ellos en verde: - -| Principio | Gate | -|---|---| -| **P-I** FFI segura | ningún panic cruza la frontera C (`catch_unwind` en cada entrada) | -| **P-II** Memoria verificada | ASan/LSan sobre los tests Rust de ambos shims — 0 fugas / 0 double-free | -| **P-III** Determinismo | encoding reproducible cross-RID + paridad byte-idéntica cross-impl vs Yjs (**bloqueante** desde CHARTER-09) | -| **P-IV** Motor reemplazable | la suite de versionado corre idéntica sobre `yrs` **y** Loro (dual-engine) | -| **P-V** Concurrencia por doc | acceso a `ICrdtDoc` serializado; el broker usa actor/canal single-reader | -| **P-VI** Portabilidad por RID | *pack-smoke* del paquete en cada RID soportado — "soportado" = ejercitado | - -Reglas duras al escribir código: buffers del shim liberados solo con `weft_buf_free` (el GC jamás toca memoria -nativa); nunca `skip_gc`; `Weft.Versioning` no referencia tipos de `yrs`/Loro (solo las abstracciones); API -pública con índices `int` validados y errores nativos → jerarquía `WeftException`. - -## Protocolo de bump del motor (yrs / Loro) — research R16 - -Las versiones de los motores están **pinneadas exactas** (`yrs = "=0.27.2"`, `loro = "=1.13.6"`) con -`Cargo.lock` versionado — los nombres y firmas de `yrs` cambian entre minors, y el gate de determinismo -(P-III) exige reproducibilidad. Para subir un motor: - -1. **Rama dedicada**; actualizar el pin exacto en `native//Cargo.toml` + `Cargo.lock`. -2. **Ajustar el shim** (`native//src/lib.rs`) a los cambios de API del motor. El shim aísla el bump: la - **C-ABI propia y el C# no cambian** (esa es su razón de ser). -3. **Correr los gates completos**: sanitizers (P-II), determinismo cross-RID **y cross-implementación** - (P-III — un bump puede cambiar el encoding y romper la citabilidad de versiones previas), convergencia y +> **Do not pack (`dotnet pack`) from a tree built with `--features test-hooks`.** The pack reads the +> cdylib from `native/target//release/`; if you built it with the feature (e.g. after +> `cargo build --release --target --features test-hooks`), the `.nupkg` would include the +> `weft_test_panic`/`weft_loro_test_panic` symbol. The SC-009 gate that verifies its absence only runs +> in the `release.yml` pipeline, **not** in a local pack. To publish, build the native binary +> **without** the feature (FU-019). + +## Gates (constitution) + +The project constitution (`.specify/memory/constitution.md`) fixes 6 **binding** principles, each with +its CI gate. A PR is not merged without them green: + +| Principle | Gate | +| --- | --- | +| **P-I** Safe FFI | no panic crosses the C boundary (`catch_unwind` at every entry point) | +| **P-II** Verified memory | ASan/LSan over the Rust tests of both shims — 0 leaks / 0 double-free | +| **P-III** Determinism | reproducible encoding cross-RID + byte-identical cross-impl parity vs Yjs (**blocking** since CHARTER-09) | +| **P-IV** Replaceable engine | the versioning suite runs identically over `yrs` **and** Loro (dual-engine) | +| **P-V** Per-doc concurrency | serialized access to `ICrdtDoc`; the broker uses a single-reader actor/channel | +| **P-VI** Portability by RID | *pack-smoke* of the package on every supported RID — "supported" = exercised | + +Hard rules when writing code: shim buffers freed only with `weft_buf_free` (the GC never touches native +memory); never `skip_gc`; `Weft.Versioning` does not reference `yrs`/Loro types (only the abstractions); +public API with validated `int` indices and native errors → `WeftException` hierarchy. + +## Engine bump protocol (yrs / Loro) — research R16 + +Engine versions are **pinned exactly** (`yrs = "=0.27.2"`, `loro = "=1.13.6"`) with a versioned +`Cargo.lock` — `yrs`'s names and signatures change between minors, and the determinism gate (P-III) +requires reproducibility. To bump an engine: + +1. **Dedicated branch**; update the exact pin in `native//Cargo.toml` + `Cargo.lock`. +2. **Adjust the shim** (`native//src/lib.rs`) to the engine's API changes. The shim isolates the + bump: the **in-house C-ABI and the C# do not change** (that's its whole point). +3. **Run the full gates**: sanitizers (P-II), determinism cross-RID **and cross-implementation** (P-III + — a bump can change the encoding and break the citability of prior versions), convergence, and dual-engine (P-IV). -4. **Merge solo en verde.** Un cambio de encoding es *breaking* para el content-addressing → se trata como - tal en el versionado SemVer del paquete. +4. **Merge only when green.** An encoding change is *breaking* for content-addressing → it's treated as + such in the package's SemVer versioning. -## Flujo de trabajo +## Workflow -Spec-driven con [GitHub Spec Kit](https://github.com/github/spec-kit) (spec → plan → tasks → implement) y -gobernanza documental con [StrayMark](https://github.com/StrangeDaysTech/straymark) (Charters + AILOG/AIDEC). -Ver [`GOVERNANCE.md`](./GOVERNANCE.md). Las decisiones ✅ CERRADO del brief (`weft-design-brief.md`) no se -re-litigan. +Spec-driven with [GitHub Spec Kit](https://github.com/github/spec-kit) (spec → plan → tasks → implement) +and documentation governance with [StrayMark](https://github.com/StrangeDaysTech/straymark) (Charters + +AILOG/AIDEC). See [`GOVERNANCE.md`](./GOVERNANCE.md). The ✅ CLOSED decisions of the brief +(`weft-design-brief.md`) are not re-litigated. -## Reportar bugs / proponer cambios +## Reporting bugs / proposing changes -Abre un issue con repro mínimo. Para cambios sustantivos, comenta el diseño en un issue antes del PR — los -cambios de contrato (FFI, `IDocumentStore`, protocolo de sync) requieren acuerdo previo. +Open an issue with a minimal repro. For substantive changes, discuss the design in an issue before the +PR — contract changes (FFI, `IDocumentStore`, sync protocol) require prior agreement. diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 96255d5..e16d4c6 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -1,59 +1,69 @@ -# Gobernanza de Weft +# Weft governance -Weft es un proyecto open-source (Apache-2.0) de [Strange Days Tech](https://strangedays.tech/es). +Weft is an open-source (Apache-2.0) project by [Strange Days Tech](https://strangedays.tech/en). -## Modelo +## Model -- **Mantenedor(es)**: el equipo de Strange Days Tech administra el repositorio, revisa PRs y corta releases. -- **Decisiones técnicas**: se toman por el mantenedor con base en la **constitución** del proyecto - (`.specify/memory/constitution.md`, vinculante) y quedan registradas como **Charters** y documentos - **AILOG/AIDEC/ADR** de [StrayMark](https://github.com/StrangeDaysTech/straymark) — el porqué de cada - decisión es parte del repositorio, no folclore oral. -- **Alcance**: Weft es un *building block* reutilizable, no una aplicación. Las peticiones que empujen lógica - de dominio hacia la librería se declinan por diseño (ver [`README.md`](./README.md) §"Lo que no es Weft"). +- **Maintainer(s)**: the Strange Days Tech team administers the repository, reviews PRs, and cuts releases. +- **Technical decisions**: made by the maintainer based on the project **constitution** + (`.specify/memory/constitution.md`, binding) and recorded as **Charters** and + **AILOG/AIDEC/ADR** documents of [StrayMark](https://github.com/StrangeDaysTech/straymark) — the why + of each decision is part of the repository, not oral folklore. +- **Scope**: Weft is a reusable *building block*, not an application. Requests that push domain logic + into the library are declined by design (see [`README.md`](./README.md) §"What Weft is not"). -## Cómo se decide un cambio +## How a change is decided -1. Discusión en un issue (para cambios sustantivos o de contrato: FFI, `IDocumentStore`, protocolo de sync). -2. Un **Charter** de StrayMark declara el alcance ex-ante (qué entra, qué no, riesgos) antes de implementar. -3. Implementación en un PR con los **6 gates** de la constitución en verde (ver [`CONTRIBUTING.md`](./CONTRIBUTING.md)). -4. Cierres de hito requieren **auditoría externa multi-modelo** (StrayMark) antes de mergear. +1. Discussion in an issue (for substantive or contract changes: FFI, `IDocumentStore`, sync protocol). +2. A StrayMark **Charter** declares the scope ex-ante (what's in, what's out, risks) before implementing. +3. Implementation in a PR with the **6 constitutional gates** green (see [`CONTRIBUTING.md`](./CONTRIBUTING.md)). +4. Milestone closes require a **multi-model external audit** (StrayMark) before merging. -## Versionado y releases +## Versioning and releases -- **SemVer**. La identidad de versión de un documento es el `SHA-256` de su export determinista: **un cambio - de encoding del motor es breaking** (rompe la citabilidad de versiones previas) y sube el *major*. Ver el - protocolo de bump del motor en [`CONTRIBUTING.md`](./CONTRIBUTING.md#protocolo-de-bump-del-motor-yrs--loro--research-r16). -- Los paquetes se publican a NuGet.org con símbolos + SourceLink desde el pipeline de release - (`.github/workflows/release.yml`), tras verde en cross-compile + *pack-smoke* multi-RID. -- **RIDs soportados** v1: `linux-x64`, `linux-arm64`, `win-x64`, `osx-arm64`. "Soportado" = ejercitado en CI - (P-VI). +- **SemVer**. A document's version identity is the `SHA-256` of its deterministic export: **an engine + encoding change is breaking** (it invalidates the citability of prior versions) and bumps the + *major*. See the engine bump protocol in + [`CONTRIBUTING.md`](./CONTRIBUTING.md#engine-bump-protocol-yrs--loro--research-r16). +- Packages are published to NuGet.org with symbols + SourceLink from the release pipeline + (`.github/workflows/release.yml`), after green cross-compile + *pack-smoke* multi-RID. +- **Supported RIDs** v1: `linux-x64`, `linux-arm64`, `win-x64`, `osx-arm64`. "Supported" = exercised in + CI (P-VI). -## Seguridad +## Security -Reporta vulnerabilidades de forma privada (no en un issue público) al contacto de seguridad de -[Strange Days Tech](https://strangedays.tech/es). La memoria nativa se verifica con ASan/LSan en CI (P-II) y la -frontera FFI se fuzzea (`cargo-fuzz`); el input de red no confiable del relay tiene límites de tamaño/recursos. +Report vulnerabilities privately (not in a public issue) to the security contact of +[Strange Days Tech](https://strangedays.tech/en). See [`SECURITY.md`](./SECURITY.md) for the reporting +process. Native memory is verified with ASan/LSan in CI (P-II) and the FFI boundary is fuzzed +(`cargo-fuzz`); the relay's untrusted network input has size/resource limits. -### Ingesta directa de bytes CRDT no confiables (caveat R6) +### Direct ingestion of untrusted CRDT bytes (R6 caveat) -El **relay** (`Weft.Server`) ya protege la ingesta de red: cap configurable de tamaño de mensaje y límites de -recursos por conexión antes de decodificar (ver `WeftServerOptions`). Si en cambio alimentas bytes CRDT **no -confiables directamente** a la API pública fuera del relay — `weft_doc_load` / `apply_update` / `export_since`, -o sus envoltorios en `Weft.Core` — replica esa defensa: **impón un cap de tamaño de entrada y un límite de -memoria del proceso** (p. ej. cgroup/contenedor). +The **relay** (`Weft.Server`) already protects network ingestion: a configurable message-size cap and +per-connection resource limits before decoding (see `WeftServerOptions`). If instead you feed +**untrusted** CRDT bytes **directly** to the public API outside the relay — `weft_doc_load` / +`apply_update` / `export_since`, or their wrappers in `Weft.Core` — replicate that defense: **impose an +input-size cap and a process memory limit** (e.g. cgroup/container). -Motivo: el decoder de `yrs` puede amplificar memoria (allocation-bomb) — pocos bytes que declaran una longitud -gigante disparan una reserva grande. `Update::decode` ya usa asignación falible (`try_reserve` → error -recuperable, no abort), por lo que `apply_update` está endurecido upstream; quedan dos sitios residuales con -`with_capacity` sin acotar (decode de *delete sets* y de *state vectors*, este último alcanzable vía -`export_since`). En `glibc` (overcommit) el efecto práctico es una reserva virtual y un **error de decode limpio**, -no un crash; el `abort` no capturable solo aparece en hosts memory-constrained duros o allocators eager. El fix -canónico vive upstream (PR de `try_reserve` a `y-crdt`); un target de fuzz de regresión rastrea el residual. +Rationale: the `yrs` decoder can amplify memory (allocation-bomb) — a few bytes declaring a huge length +trigger a large reservation. `Update::decode` already uses fallible allocation (`try_reserve` → +recoverable error, not abort), so `apply_update` is hardened upstream; two residual sites remain with +unbounded `with_capacity` (decode of *delete sets* and of *state vectors*, the latter reachable via +`export_since`). On `glibc` (overcommit) the practical effect is a virtual reservation and a **clean +decode error**, not a crash; the non-catchable `abort` only appears on hard memory-constrained hosts or +eager allocators. The canonical fix lives upstream (the `try_reserve` PR to `y-crdt`); a regression fuzz +target tracks the residual. -## Licencia +## License -[Apache-2.0](./LICENSE) — permisiva, con concesión explícita de patentes. Recíproca con los motores MIT sobre -los que se apoya (`yrs`, Loro). +[Apache-2.0](./LICENSE) — permissive, with an explicit patent grant, fit for open-source and proprietary +use alike. Reciprocal with the MIT engines it builds on (`yrs`, Loro). + +The distributed native binaries statically link third-party Rust crates; their licenses are reproduced in +[`THIRD-PARTY-NOTICES.md`](./THIRD-PARTY-NOTICES.md), regenerated and license-gated in CI (the +`third-party-notices` job fails if a dependency introduces a non-permissive license or the notice drifts). +Everything is permissive except three **MPL-2.0** components reached only through the optional Loro engine — +the default `yrs` path is fully permissive. MPL-2.0 is compatible with proprietary use; only attribution is +required, and the notice file provides it. diff --git a/README.md b/README.md index 49ba53b..ea2a29c 100644 --- a/README.md +++ b/README.md @@ -2,48 +2,48 @@ # Weft -**Colaboración en tiempo real (CRDT) y versionado content-addressed de documentos, para .NET.** +**Real-time collaboration (CRDT) and content-addressed document versioning, for .NET.** -Weft es una librería .NET que envuelve el core CRDT [`yrs`](https://github.com/y-crdt/y-crdt) (el port oficial en Rust de [Yjs](https://github.com/yjs/yjs)) mediante un binding propio, y construye encima una capa de **versionado inmutable** (identificado por contenido) más un **servidor de sincronización** — diseñada a nuestros patrones, no heredando los de un binding de terceros. +Weft is a .NET library that wraps the [`yrs`](https://github.com/y-crdt/y-crdt) CRDT core (the official Rust port of [Yjs](https://github.com/yjs/yjs)) through an in-house binding, and builds on top an **immutable versioning** layer (content-identified) plus a **synchronization server** — designed to our own patterns, not inheriting those of a third-party binding. -> **El nombre.** `yrs` se pronuncia *"wires"* (hilos). El **weft** son los hilos que se tejen a través de la urdimbre en un telar: el motor aporta los hilos, Weft los teje en colaboración. Un guiño a la familia de proyectos de [Strange Days Tech](https://strangedays.tech/es) — Arborist, StrayMark, Weft. +> **The name.** `yrs` is pronounced *"wires"*. The **weft** is the set of threads woven across the warp on a loom: the engine provides the threads, Weft weaves them into collaboration. A nod to the [Strange Days Tech](https://strangedays.tech/en) family of projects — Arborist, StrayMark, Weft. -## Estado +## Status -✅ **Release-ready (hito M3).** Los hitos M0 (versionado + dual-engine), M1 (concurrencia a escala) y M2 -(servidor relay) están cerrados y verdes en CI. El empaquetado NuGet nativo multi-RID (`linux-x64`, -`linux-arm64`, `win-x64`, `osx-arm64`) está construido y verificado por *pack-smoke*. Los paquetes se -**publican a NuGet.org con el corte de release M3** (el pipeline queda a un `workflow_dispatch` de distancia). +✅ **Release-ready (milestone M3).** Milestones M0 (versioning + dual-engine), M1 (concurrency at scale) and M2 +(relay server) are closed and green in CI. The multi-RID native NuGet packaging (`linux-x64`, +`linux-arm64`, `win-x64`, `osx-arm64`) is built and verified by *pack-smoke*. Packages are +**published to NuGet.org with the M3 release cut** (the pipeline is one `workflow_dispatch` away). -## Qué ofrece (frontera del componente) +## What it offers (the component boundary) -Weft es un **building block reutilizable**, no una aplicación. Provee: +Weft is a **reusable building block**, not an application. It provides: -- **Binding .NET a `yrs`** vía un shim C-ABI propio (`ICrdtEngine` / `ICrdtDoc`), con ciclo de vida seguro (`SafeHandle`), contrato de ownership explícito y seguridad de memoria verificada. -- **Versionado de documento content-addressed:** publicar versiones (`SHA-256` del export byte-determinista), diff, ramas/merge y compactación — **engine-agnóstico** (probado corriendo idéntico sobre `yrs` y Loro). -- **Sincronización:** sync incremental (state-vector + delta), servidor relay WebSocket (protocolo Yjs), presencia/awareness y adaptadores de persistencia. -- **Capacidad opcional `INativeVersioning`** para motores que la ofrezcan (p. ej. Loro), manteniendo el motor reemplazable. +- **.NET binding to `yrs`** via an in-house C-ABI shim (`ICrdtEngine` / `ICrdtDoc`), with a safe lifecycle (`SafeHandle`), an explicit ownership contract, and verified memory safety. +- **Content-addressed document versioning:** publish versions (`SHA-256` of the byte-deterministic export), diff, branches/merge and compaction — **engine-agnostic** (proven running identically over `yrs` and Loro). +- **Synchronization:** incremental sync (state-vector + delta), a WebSocket relay server (Yjs protocol), presence/awareness and persistence adapters. +- **Optional `INativeVersioning` capability** for engines that offer it (e.g. Loro), keeping the engine replaceable. -Lo que **no** es Weft: modelo de contenido de dominio de una app concreta, editor de frontend, ni lógica de negocio. Esas viven en el consumidor (p. ej. un LMS), que **depende de Weft, nunca al revés**. +What Weft is **not**: the domain content model of a concrete app, a frontend editor, or business logic. Those live in the consumer (e.g. a collaborative content editor for an LMS), which **depends on Weft, never the other way around**. -## Instalación +## Installation -Weft se distribuye como paquetes NuGet con los binarios nativos incluidos por RID — **sin pasos manuales**: -el binario correcto (`linux-x64`/`linux-arm64`/`win-x64`/`osx-arm64`) se resuelve solo desde +Weft ships as NuGet packages with the native binaries included per RID — **no manual steps**: +the correct binary (`linux-x64`/`linux-arm64`/`win-x64`/`osx-arm64`) resolves itself from `runtimes//native/`. ```bash -dotnet add package Weft.Core # binding + versionado base (motor yrs incluido) -dotnet add package Weft.Versioning # publish / diff / branch / merge content-addressed -dotnet add package Weft.Server # relay WebSocket y-sync para ASP.NET Core (opcional) +dotnet add package Weft.Core # binding + base versioning (yrs engine included) +dotnet add package Weft.Versioning # content-addressed publish / diff / branch / merge +dotnet add package Weft.Server # y-sync WebSocket relay for ASP.NET Core (optional) ``` -Paquetes: `Weft.Core`, `Weft.Versioning`, `Weft.Server`, `Weft.Loro` (motor alternativo) y los adaptadores -de persistencia `Weft.Server.Persistence.EFCore` / `.Redis`. +Packages: `Weft.Core`, `Weft.Versioning`, `Weft.Server`, `Weft.Loro` (alternative engine) and the +persistence adapters `Weft.Server.Persistence.EFCore` / `.Redis`. ## Quickstart -**Editar y versionar** un documento (content-addressed, `SHA-256` del export determinista): +**Edit and version** a document (content-addressed, `SHA-256` of the deterministic export): ```csharp using Weft; @@ -55,81 +55,109 @@ ICrdtEngine engine = YrsEngine.Instance; var store = new VersionStore(engine, new InMemoryBlobStore()); using ICrdtDoc doc = engine.CreateDoc(); -doc.InsertText("titulo", 0, "hola weft"); -VersionId v1 = await store.PublishAsync(doc); // v1 = hash citable del contenido +doc.InsertText("title", 0, "hello weft"); +VersionId v1 = await store.PublishAsync(doc); // v1 = citable hash of the content -doc.InsertText("titulo", 9, " en tiempo real"); +doc.InsertText("title", 10, " in real time"); VersionId v2 = await store.PublishAsync(doc); -TextDiff diff = await store.DiffAsync(v1, v2, "titulo"); // segmentos Equal/Insert/Delete -using ICrdtDoc restored = await store.CheckoutAsync(v1); // reconstruye cualquier versión +TextDiff diff = await store.DiffAsync(v1, v2, "title"); // Equal/Insert/Delete segments +using ICrdtDoc restored = await store.CheckoutAsync(v1); // reconstruct any version ``` -**Servir colaboración en vivo** — relay WebSocket compatible con clientes Yjs estándar -(`y-websocket`/`y-prosemirror`/Tiptap), en ASP.NET Core: +**Serve live collaboration** — a WebSocket relay compatible with standard Yjs clients +(`y-websocket`/`y-prosemirror`/Tiptap), in ASP.NET Core: ```csharp builder.Services.AddWeftServer(options => { /* Engine, Broker, ... */ }); -builder.Services.AddSingleton(); // decisión de acceso del consumidor -builder.Services.AddWeftRedisDocumentStore("localhost:6379"); // o EFCore / FileSystem / InMemory +builder.Services.AddSingleton(); // consumer's access decision +builder.Services.AddWeftRedisDocumentStore("localhost:6379"); // or EFCore / FileSystem / InMemory -app.MapWeft("/ws"); // endpoint WebSocket: /ws/{docId} +app.MapWeft("/ws"); // WebSocket endpoint: /ws/{docId} ``` -Recorrido end-to-end (editar → publicar → servir → cliente Tiptap) en -[`samples/`](./samples) y en `specs/001-weft-crdt-versioning/quickstart.md`. +End-to-end walkthrough (edit → publish → serve → Tiptap client) in +[`samples/`](./samples) and in `specs/001-weft-crdt-versioning/quickstart.md`. -## Motor +## Engine -- **Core: `yrs`** (adoptado, no reimplementado). Elegido por continuidad (el formato Yjs tiene múltiples implementaciones independientes → fork = elegir entre implementaciones), madurez del ecosistema de editores (Tiptap/ProseMirror + `y-prosemirror`) y fork-safety. -- **Cliente de editor recomendado:** [Tiptap](https://tiptap.dev) (sobre ProseMirror) + `y-prosemirror`, conectado al servidor relay de Weft. -- **Dual-path:** [Loro](https://github.com/loro-dev/loro) queda como alternativa viva tras la abstracción; cambiar de motor = cambiar el adaptador, no la capa de versionado. +- **Core: `yrs`** (adopted, not reimplemented). Chosen for continuity (the Yjs format has multiple independent implementations → a fork = choosing among implementations), the maturity of the editor ecosystem (Tiptap/ProseMirror + `y-prosemirror`) and fork-safety. +- **Recommended editor client:** [Tiptap](https://tiptap.dev) (on ProseMirror) + `y-prosemirror`, connected to Weft's relay server. +- **Dual-path:** [Loro](https://github.com/loro-dev/loro) stays as a living alternative behind the abstraction; switching engines = switching the adapter, not the versioning layer. -## Estructura del repo +## Repository structure -``` +```text weft/ ├── LICENSE # Apache-2.0 +├── THIRD-PARTY-NOTICES.md # licenses of the bundled Rust crates (CI-gated) ├── README.md -├── native/ # workspace cargo (un solo build cubre ambos shims) -│ ├── weft-yrs-ffi/ # cdylib: shim C-ABI sobre yrs (motor por defecto) -│ │ ├── src/lib.rs # Cargo.toml pinnea yrs = "=X.Y.Z" -│ │ ├── include/weft_ffi.h # header C (contrato de ownership; fuente de verdad) -│ │ └── tests/mem_asan.rs # harness de memoria (ASan/LSan) -│ └── weft-loro-ffi/ # cdylib: shim C-ABI sobre Loro (dual-path) +├── native/ # cargo workspace (one build covers both shims) +│ ├── about.toml # cargo-about config (accepted licenses) +│ ├── weft-yrs-ffi/ # cdylib: C-ABI shim over yrs (default engine) +│ │ ├── src/lib.rs # Cargo.toml pins yrs = "=X.Y.Z" +│ │ ├── include/weft_ffi.h # C header (ownership contract) +│ │ └── tests/mem_asan.rs # memory harness (ASan/LSan) +│ └── weft-loro-ffi/ # cdylib: C-ABI shim over Loro (dual-path) ├── src/ -│ ├── Weft.Core/ # ICrdtEngine/ICrdtDoc, P/Invoke [LibraryImport], SafeHandle, broker -│ ├── Weft.Versioning/ # publish/diff/branch/merge/compact (content-addressed) -│ ├── Weft.Server/ # relay WebSocket, awareness, persistencia -│ ├── Weft.Server.Persistence.EFCore/ # adaptador IDocumentStore sobre EF Core -│ ├── Weft.Server.Persistence.Redis/ # adaptador IDocumentStore sobre Redis/Valkey -│ └── Weft.Loro/ # adaptador opcional (INativeVersioning) — dual-path +│ ├── Weft.Core/ # binding, abstractions, SafeHandle, broker +│ ├── Weft.Versioning/ # publish/diff/branch/merge (content-addressed) +│ ├── Weft.Server/ # WebSocket relay, awareness, persistence +│ ├── Weft.Server.Persistence.EFCore/ # IDocumentStore adapter (EF Core) +│ ├── Weft.Server.Persistence.Redis/ # IDocumentStore adapter (Redis) +│ └── Weft.Loro/ # optional adapter (INativeVersioning) ├── tests/ +├── samples/ # runnable examples (versioning, relay, Tiptap) ├── docs/ # architecture.md, api/, spikes/ -├── .specify/ # GitHub Spec Kit (spec/plan/tasks) -└── .github/workflows/ # CI: build multi-RID, tests, ASan, fuzzing, determinismo +├── .specify/ # GitHub Spec Kit (constitution, spec/plan/tasks) +├── .straymark/ # StrayMark: Charters, decision records, audits +└── .github/workflows/ # CI: build, tests, ASan, fuzz, determinism ``` -## Arquitectura +## Architecture + +[**docs/architecture.md**](docs/architecture.md) explains how everything fits together: the module +map, the FFI boundary and its **memory ownership contract**, the sync flow, the content-addressed +versioning model and the known limits. It's the recommended read before integrating Weft or touching +the shim. The per-package reference is in [docs/api/](docs/api/README.md). + +## Development + +Weft is built **spec-driven and governed by Charters** — the pairing of two complementary frameworks: -[**docs/architecture.md**](docs/architecture.md) explica cómo encaja todo: mapa de módulos, la -frontera FFI y su **contrato de ownership de memoria**, el flujo de sync, el modelo de versionado -content-addressed y los límites conocidos. Es la lectura recomendada antes de integrar Weft o de -tocar el shim. La referencia por paquete está en [docs/api/](docs/api/README.md). +- **[GitHub Spec Kit](https://github.com/github/spec-kit) defines *what* to build.** The flow is + **Spec → Plan → Tasks → Implement**: design (`/specify`, `/plan`) and implementation runs + (`/tasks`, `/implement`) are carried out in Claude Code. The active feature lives in + `specs/001-weft-crdt-versioning/`, and the project **constitution** + (`.specify/memory/constitution.md`, binding — 6 principles) sits at the root of that flow. See the + **design brief** in `weft-design-brief.md`. +- **[StrayMark](https://github.com/StrangeDaysTech/straymark) governs *how* the work is executed and + recorded.** It matters because it keeps the *why* of every decision inside the repository, not in + oral folklore. Its unit of work is the **Charter**: a bounded block that pairs declarative *ex-ante* + scope (what's in, what's out, risks) with *ex-post* telemetry (real effort vs. estimate). Technical + decisions are recorded as **AILOG / AIDEC / ADR** documents under `.straymark/`, and milestone + closes require a **multi-model external audit** before merging. See + [`STRAYMARK.md`](./STRAYMARK.md) for the documentation governance rules. -## Desarrollo +**How the two integrate.** Spec Kit produces the task list (`tasks.md`, T001…T063); StrayMark groups +those tasks into **Charters** for execution. Each Charter declares its scope up front, the +implementation lands in a PR with the **6 constitutional gates** green (see +[`CONTRIBUTING.md`](./CONTRIBUTING.md) and the [Gates](docs/architecture.md#gates) section), decisions +are logged as AILOG/AIDEC/ADR, and the Charter closes with telemetry. The constitution lives on the +Spec Kit side but is **enforced** as executable CI gates on the StrayMark side — the bridge between +the two. Governance model: [`GOVERNANCE.md`](./GOVERNANCE.md). -Spec-driven, con [GitHub Spec Kit](https://github.com/github/spec-kit): **Spec → Plan → Tasks → Implement**. El diseño (`/specify`, `/plan`) y las tandas de implementación (`/tasks`, `/implement`) se realizan en Claude Code. Ver el **brief de diseño** en `weft-design-brief.md`. +Toolchain: Rust (with `yrs` pinned) · .NET SDK 10 (LTS) · native packaging per RID (Linux/Windows/macOS, x64/arm64). -Toolchain: Rust (con `yrs` pinneado) · .NET SDK 10 (LTS) · empaquetado nativo por RID (Linux/Windows/macOS, x64/arm64). +## Security -## Seguridad +The relay (`Weft.Server`) caps untrusted network input. If you ingest **untrusted** CRDT bytes +**directly** through the API (`weft_doc_load` / `apply_update` / `export_since`) outside the relay, apply +a size cap and a process memory limit — the `yrs` decoder can amplify memory. Details and vulnerability +reporting: [`SECURITY.md`](./SECURITY.md) and [GOVERNANCE.md § Security](./GOVERNANCE.md#security). -El relay (`Weft.Server`) capea el input de red no confiable. Si ingieres bytes CRDT **no confiables directamente** -por la API (`weft_doc_load` / `apply_update` / `export_since`) fuera del relay, aplica un cap de tamaño y un límite -de memoria del proceso — el decoder de `yrs` puede amplificar memoria. Detalle y reporte de vulnerabilidades: -[GOVERNANCE.md § Seguridad](./GOVERNANCE.md#seguridad). +## License -## Licencia +[Apache-2.0](./LICENSE) © 2026 [Strange Days Tech](https://strangedays.tech/en). A permissive library with an explicit patent grant, comfortable for both open-source and proprietary use. -[Apache-2.0](./LICENSE) © 2026 [Strange Days Tech](https://strangedays.tech/es). Librería permisiva con concesión explícita de patentes; reciproca a los motores MIT sobre los que se apoya (`yrs`, Loro). +**Third-party components.** The NuGet packages redistribute native binaries that statically link third-party Rust crates, so their licenses travel too — reproduced in [`THIRD-PARTY-NOTICES.md`](./THIRD-PARTY-NOTICES.md) (regenerated and license-gated in CI). All are permissive **except three weak-copyleft MPL-2.0 components** (`im`, `bitmaps`, `sized-chunks`) that arrive **only via the optional Loro engine** (`Weft.Loro`) — the default `yrs` engine path is fully permissive (MIT/Apache/BSD). MPL-2.0 is file-level copyleft and compatible with proprietary use: you owe only attribution, which the notice file provides. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..a06b1a5 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,49 @@ + + +# Security Policy + +Weft is an open-source (Apache-2.0) project by +[Strange Days Tech](https://strangedays.tech/en). We take the security of the native FFI boundary +and the sync relay seriously. + +## Reporting a vulnerability + +**Please report vulnerabilities privately — do not open a public issue.** + +- Preferred: use GitHub's [private vulnerability reporting](https://github.com/StrangeDaysTech/weft/security/advisories/new) + ("Report a vulnerability" under the repository's **Security** tab). +- Alternatively, contact the Strange Days Tech security contact via + [strangedays.tech](https://strangedays.tech/en). + +Please include a description, affected version/RID, and a minimal reproduction if possible. We aim +to acknowledge reports promptly and will keep you informed as we investigate and remediate. + +## Supported versions + +Weft follows SemVer. Because a document's version identity is the `SHA-256` of its deterministic +export, an engine encoding change is **breaking** (it invalidates the citability of prior versions) +and bumps the major. Security fixes target the latest released major. + +## How the boundary is hardened + +- **Native memory** is verified with ASan/LSan in CI (0 leaks / 0 double-frees required on every + accepted change). +- **The FFI boundary** is fuzzed with `cargo-fuzz`; no panic is allowed to cross the C boundary + (`catch_unwind` at every shim entry point). +- **Untrusted network input** to the relay (`Weft.Server`) is bounded: a configurable message-size + cap and per-connection resource limits before decoding (see `WeftServerOptions`). + +## Caveat: feeding untrusted CRDT bytes directly + +The relay already protects network ingestion. If instead you feed **untrusted** CRDT bytes +**directly** to the public API outside the relay — `weft_doc_load` / `apply_update` / +`export_since`, or their `Weft.Core` wrappers — replicate that defense: **impose an input-size cap +and a process memory limit** (e.g. cgroup/container). + +Rationale: the `yrs` decoder can amplify memory (allocation-bomb) — a few bytes declaring a huge +length trigger a large reservation. `Update::decode` already uses fallible allocation +(`try_reserve` → recoverable error, not abort), so `apply_update` is hardened. On `glibc` +(overcommit) the practical effect is a virtual reservation and a **clean decode error**, not a +crash; a non-catchable `abort` only appears on hard memory-constrained hosts or eager allocators. +The canonical fix lives upstream (a `try_reserve` PR to `y-crdt`); a regression fuzz target tracks +the residual. See `GOVERNANCE.md` for the full note. diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md new file mode 100644 index 0000000..c038805 --- /dev/null +++ b/THIRD-PARTY-NOTICES.md @@ -0,0 +1,2271 @@ + +# Third-Party Notices + +Weft itself is licensed under **Apache-2.0** (see [`LICENSE`](./LICENSE)). + +Its NuGet packages redistribute native binaries (`weft-yrs-ffi` and `weft-loro-ffi`, +the cdylibs under `runtimes//native/`) that statically link the Rust crates listed +below. This file reproduces their licenses as required by those licenses and by +Apache-2.0 §4(d). It is generated with [`cargo-about`](https://github.com/EmbarkStudios/cargo-about); +regenerate with the command in `native/about.toml`. + +All redistributed components are under permissive or weak-copyleft (MPL-2.0) licenses, +all compatible with proprietary and open-source use. The default engine (`yrs`) path is +fully permissive (MIT/Apache/BSD); the **MPL-2.0** components (`im`, `bitmaps`, +`sized-chunks`) arrive only via the optional **Loro** engine (`Weft.Loro`). + +## Summary + +- **MIT License** (MIT) — 124 crate(s) +- **Mozilla Public License 2.0** (MPL-2.0) — 3 crate(s) +- **Boost Software License 1.0** (BSL-1.0) — 1 crate(s) +- **Unicode License v3** (Unicode-3.0) — 1 crate(s) + +## Boost Software License 1.0 + +- xxhash-rust 0.8.16 — + +``` +Boost Software License - Version 1.0 - August 17th, 2003 + +Permission is hereby granted, free of charge, to any person or organization +obtaining a copy of the software and accompanying documentation covered by +this license (the "Software") to use, reproduce, display, distribute, +execute, and transmit the Software, and to prepare derivative works of the +Software, and to permit third-parties to whom the Software is furnished to +do so, all subject to the following: + +The copyright notices in the Software and this entire statement, including +the above license grant, this restriction and the following disclaimer, +must be included in all copies of the Software, in whole or in part, and +all derivative works of the Software, unless such copies or derivative +works are solely in the form of machine-executable object code generated by +a source language processor. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT +SHALL THE COPYRIGHT HOLDERS OR ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE +FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +``` + +## MIT License + +- cfg-if 1.0.4 — + +``` +Copyright (c) 2014 Alex Crichton + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +``` + +## MIT License + +- num-bigint 0.4.8 — +- num-complex 0.4.6 — +- num-integer 0.1.46 — +- num-iter 0.1.46 — +- num-rational 0.4.2 — +- num-traits 0.2.19 — +- num 0.4.3 — + +``` +Copyright (c) 2014 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +``` + +## MIT License + +- either 1.16.0 — +- itertools 0.11.0 — +- itertools 0.12.1 — + +``` +Copyright (c) 2015 + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +``` + +## MIT License + +- heck 0.4.1 — +- heck 0.5.0 — +- leb128 0.2.7 — + +``` +Copyright (c) 2015 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +``` + +## MIT License + +- cobs 0.3.0 — + +``` +Copyright (c) 2015 The cobs.rs Developers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +``` + +## MIT License + +- enum-as-inner 0.5.1 — +- enum-as-inner 0.6.1 — + +``` +Copyright (c) 2015 The trust-dns Developers +Copyright (c) 2017 Google LLC. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +``` + +## MIT License + +- hashbrown 0.14.5 — +- hashbrown 0.16.1 — + +``` +Copyright (c) 2016 Amanieu d'Antras + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +``` + +## MIT License + +- lock_api 0.4.14 — +- parking_lot 0.12.5 — +- parking_lot_core 0.9.12 — +- rustc_version 0.4.1 — +- thread_local 1.1.10 — + +``` +Copyright (c) 2016 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +``` + +## MIT License + +- equivalent 1.0.2 — + +``` +Copyright (c) 2016--2023 + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +``` + +## MIT License + +- scopeguard 1.2.0 — + +``` +Copyright (c) 2016-2019 Ulrik Sverdrup "bluss" and scopeguard developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +``` + +## MIT License + +- fnv 1.0.7 — + +``` +Copyright (c) 2017 Contributors + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +``` + +## MIT License + +- heapless 0.7.17 — +- heapless 0.8.0 — +- heapless 0.9.3 — + +``` +Copyright (c) 2017 Jorge Aparicio + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +``` + +## MIT License + +- stable_deref_trait 1.2.1 — + +``` +Copyright (c) 2017 Robert Grosse + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +``` + +## MIT License + +- arc-swap 1.9.2 — + +``` +Copyright (c) 2017 arc-swap developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +``` + +## MIT License + +- bytes 1.12.1 — + +``` +Copyright (c) 2018 Carl Lerche + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +``` + +## MIT License + +- rand_xoshiro 0.6.0 — + +``` +Copyright (c) 2018 Developers of the Rand project + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +``` + +## MIT License + +- hash32 0.2.1 — +- hash32 0.3.1 — + +``` +Copyright (c) 2018 Jorge Aparicio + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +``` + +## MIT License + +- autocfg 1.5.1 — + +``` +Copyright (c) 2018 Josh Stone + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +``` + +## MIT License + +- smallvec 1.15.2 — + +``` +Copyright (c) 2018 The Servo Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +``` + +## MIT License + +- ahash 0.8.12 — + +``` +Copyright (c) 2018 Tom Kaitchuck + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +``` + +## MIT License + +- smallstr 0.3.1 — + +``` +Copyright (c) 2018-2020 Murarth + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +``` + +## MIT License + +- getrandom 0.2.17 — + +``` +Copyright (c) 2018-2024 The rust-random Project Developers +Copyright (c) 2014 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +``` + +## MIT License + +- getrandom 0.3.4 — + +``` +Copyright (c) 2018-2025 The rust-random Project Developers +Copyright (c) 2014 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +``` + +## MIT License + +- postcard 1.1.3 — + +``` +Copyright (c) 2019 Anthony James Munns + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +``` + +## MIT License + +- ppv-lite86 0.2.21 — + +``` +Copyright (c) 2019 The CryptoCorrosion Contributors + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +``` + +## MIT License + +- tracing-attributes 0.1.31 — +- tracing-core 0.1.36 — +- tracing 0.1.44 — + +``` +Copyright (c) 2019 Tokio Contributors + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +``` + +## MIT License + +- loro-thunderdome 0.6.2 — +- nonmax 0.5.5 — + +``` +Copyright (c) 2020 Lucien Greathouse + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +## MIT License + +- libc 0.2.186 — + +``` +Copyright (c) The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +``` + +## MIT License + +- arrayvec 0.7.8 — + +``` +Copyright (c) Ulrik Sverdrup "bluss" 2015-2023 + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +``` + +## MIT License + +- rand 0.8.6 — +- rand_chacha 0.3.1 — +- rand_core 0.6.4 — + +``` +Copyright 2018 Developers of the Rand project +Copyright (c) 2014 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +``` + +## MIT License + +- zerocopy 0.8.54 — + +``` +Copyright 2023 The Fuchsia Authors + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + + +``` + +## MIT License + +- diff 0.1.13 — + +``` +MIT License + +Copyright (c) 2015 Utkarsh Kukreti + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +``` + +## MIT License + +- darling 0.20.11 — +- darling_core 0.20.11 — +- darling_macro 0.20.11 — + +``` +MIT License + +Copyright (c) 2017 Ted Driggs + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +``` + +## MIT License + +- dashmap 6.2.1 — + +``` +MIT License + +Copyright (c) 2019 Acrimon + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +``` + +## MIT License + +- enum_dispatch 0.3.13 — + +``` +MIT License + +Copyright (c) 2019 Anton Lazarev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +``` + +## MIT License + +- arbitrary 1.4.2 — +- derive_arbitrary 1.4.2 — + +``` +MIT License + +Copyright (c) 2019 Manish Goregaokar + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +``` + +## MIT License + +- quick_cache 0.6.24 — + +``` +MIT License + +Copyright (c) 2022 Arthur Silva + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +``` + +## MIT License + +- loro-delta 1.13.0 — + +``` +MIT License + +Copyright (c) 2024 Loro + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +``` + +## MIT License + +- ensure-cov 0.1.0 — + +``` +MIT License + +Copyright (c) 2024 Zixuan Chen + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +``` + +## MIT License + +- append-only-bytes 0.1.12 — +- arref 0.1.0 — +- generic-btree 0.10.7 — +- loro-internal 1.13.6 — +- md5 0.7.0 — +- serde_columnar 0.3.14 — +- serde_columnar_derive 0.3.7 +- windows-link 0.2.1 — +- yrs 0.27.2 — + +``` +MIT License + +Copyright (c) <year> <copyright holders> + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. + +``` + +## MIT License + +- ident_case 1.0.1 — + +``` +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +``` + +## MIT License + +- loro-common 1.13.1 — +- loro-rle 1.6.0 — +- loro 1.13.6 — + +``` +MIT License Copyright (c) 2023 Zixuan Chen + +Permission is hereby granted, free of +charge, to any person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice +(including the next paragraph) shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +``` + +## MIT License + +- loro_fractional_index 1.13.0 — + +``` +MIT License Copyright (c) 2024 Loro + +Permission is hereby granted, free of +charge, to any person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice +(including the next paragraph) shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +``` + +## MIT License + +- loro-kv-store 1.13.1 — + +``` +MIT License Copyright (c) 2024 Zixuan Chen + +Permission is hereby granted, free of +charge, to any person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice +(including the next paragraph) shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +``` + +## MIT License + +- rustc-hash 2.1.3 — + +``` +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +``` + +## MIT License + +- async-lock 3.4.2 — +- async-trait 0.1.89 — +- concurrent-queue 2.5.0 — +- event-listener-strategy 0.5.4 — +- event-listener 5.4.1 — +- fastrand 2.4.1 — +- itoa 1.0.18 — +- once_cell 1.21.4 — +- parking 2.2.1 — +- pest 2.8.7 — +- pest_derive 2.8.7 — +- pest_generator 2.8.7 — +- pest_meta 2.8.7 — +- pin-project-lite 0.2.17 — +- proc-macro2 1.0.106 — +- quote 1.0.46 — +- rustversion 1.0.23 — +- semver 1.0.28 — +- serde 1.0.228 — +- serde_core 1.0.228 — +- serde_derive 1.0.228 — +- serde_json 1.0.150 — +- syn 1.0.109 — +- syn 2.0.118 — +- thiserror-impl 1.0.69 — +- thiserror-impl 2.0.18 — +- thiserror 1.0.69 — +- thiserror 2.0.18 — +- unicode-ident 1.0.24 — +- zmij 1.0.21 — + +``` +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +``` + +## MIT License + +- spin 0.9.8 — + +``` +The MIT License (MIT) + +Copyright (c) 2014 Mathijs van de Nes + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +## MIT License + +- typenum 1.20.1 — + +``` +The MIT License (MIT) + +Copyright (c) 2014 Paho Lurie-Gregg + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +``` + +## MIT License + +- byteorder 1.5.0 — +- memchr 2.8.3 — +- ucd-trie 0.1.7 — + +``` +The MIT License (MIT) + +Copyright (c) 2015 Andrew Gallant + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +``` + +## MIT License + +- strsim 0.11.1 — + +``` +The MIT License (MIT) + +Copyright (c) 2015 Danny Guo +Copyright (c) 2016 Titus Wormer <tituswormer@gmail.com> +Copyright (c) 2018 Akash Kurdekar + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +``` + +## MIT License + +- twox-hash 2.1.2 — + +``` +The MIT License (MIT) + +Copyright (c) 2015 Jake Goulding + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +``` + +## MIT License + +- pretty_assertions 1.4.1 — + +``` +The MIT License (MIT) + +Copyright (c) 2016 rust-derive-builder contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +``` + +## MIT License + +- crossbeam-utils 0.8.22 — + +``` +The MIT License (MIT) + +Copyright (c) 2019 The Crossbeam Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +``` + +## MIT License + +- lz4_flex 0.11.6 — + +``` +The MIT License (MIT) + +Copyright (c) 2020 Pascal Seitz + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +``` + +## MIT License + +- yansi 1.0.1 — + +``` +The MIT License (MIT) +Copyright (c) 2017 Sergio Benitez + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +``` + +## MIT License + +- version_check 0.9.5 — + +``` +The MIT License (MIT) +Copyright (c) 2017-2018 Sergio Benitez + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +``` + +## Mozilla Public License 2.0 + +- bitmaps 2.1.0 — +- im 15.1.0 — +- sized-chunks 0.6.5 — + +``` +Mozilla Public License Version 2.0 +================================== + +### 1. Definitions + +**1.1. “Contributor”** + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +**1.2. “Contributor Version”** + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +**1.3. “Contribution”** + means Covered Software of a particular Contributor. + +**1.4. “Covered Software”** + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +**1.5. “Incompatible With Secondary Licenses”** + means + +* **(a)** that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or +* **(b)** that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +**1.6. “Executable Form”** + means any form of the work other than Source Code Form. + +**1.7. “Larger Work”** + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +**1.8. “License”** + means this document. + +**1.9. “Licensable”** + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +**1.10. “Modifications”** + means any of the following: + +* **(a)** any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or +* **(b)** any new file in Source Code Form that contains any Covered + Software. + +**1.11. “Patent Claims” of a Contributor** + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +**1.12. “Secondary License”** + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +**1.13. “Source Code Form”** + means the form of the work preferred for making modifications. + +**1.14. “You” (or “Your”)** + means an individual or a legal entity exercising rights under this + License. For legal entities, “You” includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, “control” means **(a)** the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or **(b)** ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + + +### 2. License Grants and Conditions + +#### 2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +* **(a)** under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and +* **(b)** under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +#### 2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +#### 2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +* **(a)** for any code that a Contributor has removed from Covered Software; + or +* **(b)** for infringements caused by: **(i)** Your and any other third party's + modifications of Covered Software, or **(ii)** the combination of its + Contributions with other software (except as part of its Contributor + Version); or +* **(c)** under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +#### 2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +#### 2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +#### 2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +#### 2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + + +### 3. Responsibilities + +#### 3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +#### 3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +* **(a)** such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +* **(b)** You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +#### 3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +#### 3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +#### 3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + + +### 4. Inability to Comply Due to Statute or Regulation + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: **(a)** comply with +the terms of this License to the maximum extent possible; and **(b)** +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + + +### 5. Termination + +**5.1.** The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated **(a)** provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and **(b)** on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +**5.2.** If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +**5.3.** In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + + +### 6. Disclaimer of Warranty + +> Covered Software is provided under this License on an “as is” +> basis, without warranty of any kind, either expressed, implied, or +> statutory, including, without limitation, warranties that the +> Covered Software is free of defects, merchantable, fit for a +> particular purpose or non-infringing. The entire risk as to the +> quality and performance of the Covered Software is with You. +> Should any Covered Software prove defective in any respect, You +> (not any Contributor) assume the cost of any necessary servicing, +> repair, or correction. This disclaimer of warranty constitutes an +> essential part of this License. No use of any Covered Software is +> authorized under this License except under this disclaimer. + +### 7. Limitation of Liability + +> Under no circumstances and under no legal theory, whether tort +> (including negligence), contract, or otherwise, shall any +> Contributor, or anyone who distributes Covered Software as +> permitted above, be liable to You for any direct, indirect, +> special, incidental, or consequential damages of any character +> including, without limitation, damages for lost profits, loss of +> goodwill, work stoppage, computer failure or malfunction, or any +> and all other commercial damages or losses, even if such party +> shall have been informed of the possibility of such damages. This +> limitation of liability shall not apply to liability for death or +> personal injury resulting from such party's negligence to the +> extent applicable law prohibits such limitation. Some +> jurisdictions do not allow the exclusion or limitation of +> incidental or consequential damages, so this exclusion and +> limitation may not apply to You. + + +### 8. Litigation + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + + +### 9. Miscellaneous + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + + +### 10. Versions of the License + +#### 10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +#### 10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +#### 10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +#### 10.4. Distributing Source Code Form that is Incompatible With Secondary Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +## Exhibit A - Source Code Form License Notice + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +## Exhibit B - “Incompatible With Secondary Licenses” Notice + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. + +``` + +## Unicode License v3 + +- unicode-ident 1.0.24 — + +``` +UNICODE LICENSE V3 + +COPYRIGHT AND PERMISSION NOTICE + +Copyright © 1991-2023 Unicode, Inc. + +NOTICE TO USER: Carefully read the following legal agreement. BY +DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR +SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE +TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT +DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of data files and any associated documentation (the "Data Files") or +software and any associated documentation (the "Software") to deal in the +Data Files or Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, and/or sell +copies of the Data Files or Software, and to permit persons to whom the +Data Files or Software are furnished to do so, provided that either (a) +this copyright and permission notice appear with all copies of the Data +Files or Software, or (b) this copyright and permission notice appear in +associated Documentation. + +THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +THIRD PARTY RIGHTS. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE +BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, +OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA +FILES OR SOFTWARE. + +Except as contained in this notice, the name of a copyright holder shall +not be used in advertising or otherwise to promote the sale, use or other +dealings in these Data Files or Software without prior written +authorization of the copyright holder. + +``` + diff --git a/docs/api/README.md b/docs/api/README.md index 0d4fa32..aca1b40 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -1,67 +1,68 @@ -# Weft — overview de API por paquete +# Weft — per-package API overview -Weft se distribuye como varios paquetes NuGet en capas. Referencia solo los que necesites; las dependencias -van **hacia el core**, nunca al revés. +Weft ships as several layered NuGet packages. Reference only the ones you need; dependencies point +**toward the core**, never the other way. -> Esta página es la referencia **por paquete**: qué instalar y qué tipos usar. Para cómo encajan entre -> sí —frontera FFI, contrato de ownership de memoria, flujo de sync, modelo de versionado— ver -> [**Arquitectura de Weft**](../architecture.md). +> This page is the **per-package** reference: what to install and which types to use. For how they fit +> together — FFI boundary, memory ownership contract, sync flow, versioning model — see +> [**Weft architecture**](../architecture.md). -| Paquete | Depende de | Para qué | -|---|---|---| -| **Weft.Core** | — (trae el motor nativo `yrs`) | Binding seguro + abstracciones + broker de concurrencia | -| **Weft.Versioning** | Weft.Core | Versionado content-addressed (publish/diff/branch/merge) | -| **Weft.Server** | Weft.Core, Weft.Versioning | Relay WebSocket y-sync para ASP.NET Core | -| **Weft.Server.Persistence.EFCore** | Weft.Server | Adaptador `IDocumentStore` sobre EF Core | -| **Weft.Server.Persistence.Redis** | Weft.Server | Adaptador `IDocumentStore` sobre Redis/Valkey | -| **Weft.Loro** | Weft.Core | Motor CRDT alternativo (Loro) tras la misma abstracción | +| Package | Depends on | For | +| --- | --- | --- | +| **Weft.Core** | — (brings the native `yrs` engine) | Safe binding + abstractions + concurrency broker | +| **Weft.Versioning** | Weft.Core | Content-addressed versioning (publish/diff/branch/merge) | +| **Weft.Server** | Weft.Core, Weft.Versioning | y-sync WebSocket relay for ASP.NET Core | +| **Weft.Server.Persistence.EFCore** | Weft.Server | `IDocumentStore` adapter over EF Core | +| **Weft.Server.Persistence.Redis** | Weft.Server | `IDocumentStore` adapter over Redis/Valkey | +| **Weft.Loro** | Weft.Core | Alternative CRDT engine (Loro) behind the same abstraction | ## Weft.Core -El binding y las abstracciones. Trae el shim nativo `weft_yrs_ffi` (motor `yrs`) empaquetado por RID. +The binding and the abstractions. Brings the native `weft_yrs_ffi` shim (`yrs` engine) packaged per RID. -- **`ICrdtEngine`** (`YrsEngine.Instance`) — fábrica de documentos: `CreateDoc()`, `LoadDoc(blob)`. -- **`ICrdtDoc`** — documento CRDT: `InsertText`/`DeleteText`/`GetText`, `ExportState`/`ExportStateVector`/ - `ExportUpdateSince`/`ApplyUpdate`. **No es thread-safe**: acceso serializado (o vía el broker). -- **Concurrencia** (`Weft.Concurrency`): `DocumentBroker`/`DocumentSession` — actor por documento - (single-reader) para acceso concurrente seguro a escala. -- **Errores**: jerarquía `WeftException` (`CorruptUpdateException`, `WeftEngineException`) + `WeftErrorCode`. -- Ciclo de vida nativo: `SafeHandle` + contrato de ownership; el GC jamás toca memoria nativa. +- **`ICrdtEngine`** (`YrsEngine.Instance`) — document factory: `CreateDoc()`, `LoadDoc(blob)`. +- **`ICrdtDoc`** — CRDT document: `InsertText`/`DeleteText`/`GetText`, `ExportState`/`ExportStateVector`/ + `ExportUpdateSince`/`ApplyUpdate`. **Not thread-safe**: serialized access (or via the broker). +- **Concurrency** (`Weft.Concurrency`): `DocumentBroker`/`DocumentSession` — one actor per document + (single-reader) for safe concurrent access at scale. +- **Errors**: `WeftException` hierarchy (`CorruptUpdateException`, `WeftEngineException`) + `WeftErrorCode`. +- Native lifecycle: `SafeHandle` + ownership contract; the GC never touches native memory. ## Weft.Versioning -Versionado inmutable content-addressed, **engine-agnóstico** (no referencia tipos de `yrs`/Loro). +Immutable content-addressed versioning, **engine-agnostic** (does not reference `yrs`/Loro types). -- **`VersionStore(engine, IBlobStore)`** — `PublishAsync(doc)` → `VersionId` (`SHA-256` del export - determinista), `CheckoutAsync`/`BranchAsync`/`DiffAsync`, `Merge`. -- **`IBlobStore`** — `InMemoryBlobStore`, `FileSystemBlobStore` (blobs por versión, citables). -- **`TextDiff`** — diff por palabras (segmentos `Equal`/`Insert`/`Delete`). +- **`VersionStore(engine, IBlobStore)`** — `PublishAsync(doc)` → `VersionId` (`SHA-256` of the + deterministic export), `CheckoutAsync`/`BranchAsync`/`DiffAsync`, `Merge`. +- **`IBlobStore`** — `InMemoryBlobStore`, `FileSystemBlobStore` (per-version blobs, citable). +- **`TextDiff`** — word-level diff (`Equal`/`Insert`/`Delete` segments). ## Weft.Server -Relay WebSocket compatible con clientes Yjs estándar (`y-websocket`/`y-prosemirror`/Tiptap), sin adaptación. +WebSocket relay compatible with standard Yjs clients (`y-websocket`/`y-prosemirror`/Tiptap), with no adaptation. -- **DI**: `AddWeftServer(options)` (falla al arrancar sin `IWeftAuthorizer`) + `MapWeft(path)` → `path/{docId}`. -- **`IWeftAuthorizer`** — hook de acceso del consumidor (`Deny`/`ReadOnly`/`ReadWrite`); Weft nunca decide identidad. -- **`IDocumentStore`** — estado durable de blobs **opacos** (`LoadAsync`/`AppendUpdateAsync`/`SaveSnapshotAsync`); - implementaciones `InMemory`/`FileSystem` incluidas, `EFCore`/`Redis` en paquetes aparte. -- **`IWeftServer`** — `PublishAsync` (paridad de `VersionId` server↔local), `GetConnectionCountAsync`, +- **DI**: `AddWeftServer(options)` (fails at startup without an `IWeftAuthorizer`) + `MapWeft(path)` → `path/{docId}`. +- **`IWeftAuthorizer`** — the consumer's access hook (`Deny`/`ReadOnly`/`ReadWrite`); Weft never decides identity. +- **`IDocumentStore`** — durable state of **opaque** blobs (`LoadAsync`/`AppendUpdateAsync`/`SaveSnapshotAsync`); + `InMemory`/`FileSystem` implementations included, `EFCore`/`Redis` in separate packages. +- **`IWeftServer`** — `PublishAsync` (server↔local `VersionId` parity), `GetConnectionCountAsync`, `DisconnectAllAsync`. ## Weft.Server.Persistence.EFCore / .Redis -Adaptadores `IDocumentStore` externos, intercambiables (pasan la misma contract suite). EF Core es -provider-agnóstico (SQLite/PostgreSQL/SQL Server); Redis es compatible con Valkey. Registro DI: +External, interchangeable `IDocumentStore` adapters (they pass the same contract suite). EF Core is +provider-agnostic (SQLite/PostgreSQL/SQL Server); Redis is Valkey-compatible. DI registration: `AddWeftEFCoreDocumentStore(...)` / `AddWeftRedisDocumentStore(...)`. ## Weft.Loro -Motor CRDT alternativo (Loro) tras la misma `ICrdtEngine`/`ICrdtDoc` — prueba viva de que la abstracción es -reemplazable (P-IV). Ofrece además la capacidad opcional `INativeVersioning` para versionado nativo. +An alternative CRDT engine (Loro) behind the same `ICrdtEngine`/`ICrdtDoc` — living proof that the +abstraction is replaceable (P-IV). It also offers the optional `INativeVersioning` capability for native +versioning. --- -> Documentación de referencia por símbolo: los paquetes incluyen sus comentarios XML (IntelliSense) y -> SourceLink (navegación al fuente). Contratos formales en `specs/001-weft-crdt-versioning/contracts/`. +> Per-symbol reference documentation: the packages include their XML comments (IntelliSense) and +> SourceLink (navigation to source). Formal contracts in `specs/001-weft-crdt-versioning/contracts/`. diff --git a/docs/architecture.md b/docs/architecture.md index 80a9ddd..b75a26b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,146 +1,139 @@ -# Arquitectura de Weft +# Weft architecture -> Cómo está construido Weft y por qué. Documento de orientación para quien va a consumir la -> librería, integrarla o contribuir a ella. +> How Weft is built and why. An orientation document for anyone about to consume the library, +> integrate it, or contribute to it. > -> **Alcance**: este doc explica la **forma** del sistema y el **contrato de memoria** de la -> frontera nativa. No duplica lo que ya vive en otro sitio: la API por paquete está en -> [`docs/api/README.md`](api/README.md), los contratos formales en -> [`specs/001-weft-crdt-versioning/contracts/`](../specs/001-weft-crdt-versioning/contracts/), y el -> *porqué* de cada decisión técnica en -> [`research.md`](../specs/001-weft-crdt-versioning/research.md) (R1–R17), al que se enlaza en vez -> de reexplicarlo. - -## Qué es Weft - -Weft es un **building block**, no una aplicación: da colaboración CRDT en tiempo real y versionado -content-addressed a aplicaciones .NET. El trabajo CRDT real lo hace [`yrs`](https://github.com/y-crdt/y-crdt) -(el core Rust de Yjs); Weft aporta un binding seguro, un modelo de versionado que `yrs` no tiene, un -relay compatible con el ecosistema Yjs, y la disciplina de memoria/determinismo que hace que todo eso -sea sostenible desde .NET. - -Dos consecuencias de diseño que conviene tener claras desde el principio: - -- **Weft no decide tu autenticación, tu almacenamiento ni tu retención.** El relay delega la - autorización en un `IWeftAuthorizer` tuyo (R17), la persistencia en un `IDocumentStore` tuyo (R8), y - las versiones publicadas son inmutables sin `delete` en v1 — la política de retención es dominio - del consumidor. -- **La interoperabilidad con Yjs es un requisito, no un accidente.** El wire es el protocolo y-sync - sobre lib0 (R7) y el encoding es byte-idéntico al de Yjs JS, verificado por un gate bloqueante. - Un cliente Tiptap/y-websocket existente habla con Weft sin adaptadores. - -## Mapa de módulos - -Seis paquetes. Todas las dependencias apuntan **hacia el core**; ninguna al revés. - -```text - ┌───────────────┐ - │ Weft.Core │ binding yrs + abstracciones + broker - └───────┬───────┘ - ┌───────────────┼───────────────┐ - │ │ │ - ┌───────▼──────┐ ┌──────▼──────┐ ┌──────▼─────┐ - │Weft.Versioning│ │ Weft.Loro │ │ │ - └───────┬───────┘ └─────────────┘ │ │ - │ │ │ - └──────────┬───────────────┘ │ - ┌──────▼──────┐ │ - │ Weft.Server │─────────────────────┘ - └──────┬──────┘ - ┌──────────┴──────────┐ - ┌───────▼────────┐ ┌────────▼───────┐ - │ …Persistence │ │ …Persistence │ - │ .Redis │ │ .EFCore │ - └────────────────┘ └────────────────┘ +> **Scope**: this doc explains the **shape** of the system and the **memory contract** of the native +> boundary. It does not duplicate what already lives elsewhere: the per-package API is in +> [`docs/api/README.md`](api/README.md), the formal contracts in +> [`specs/001-weft-crdt-versioning/contracts/`](../specs/001-weft-crdt-versioning/contracts/), and the +> *why* of each technical decision in +> [`research.md`](../specs/001-weft-crdt-versioning/research.md) (R1–R17), which is linked to rather +> than re-explained. + +## What Weft is + +Weft is a **building block**, not an application: it gives .NET applications real-time CRDT +collaboration and content-addressed versioning. The actual CRDT work is done by +[`yrs`](https://github.com/y-crdt/y-crdt) (the Rust core of Yjs); Weft adds a safe binding, a +versioning model that `yrs` doesn't have, a relay compatible with the Yjs ecosystem, and the +memory/determinism discipline that makes all of that sustainable from .NET. + +Two design consequences worth having clear from the start: + +- **Weft does not decide your authentication, your storage, or your retention.** The relay delegates + authorization to an `IWeftAuthorizer` of yours (R17), persistence to an `IDocumentStore` of yours + (R8), and published versions are immutable with no `delete` in v1 — retention policy is the + consumer's domain. +- **Interoperability with Yjs is a requirement, not an accident.** The wire is the y-sync protocol + over lib0 (R7) and the encoding is byte-identical to Yjs JS, verified by a blocking gate. An + existing Tiptap/y-websocket client talks to Weft with no adapters. + +## Module map + +Six packages. All dependencies point **toward the core**; none the other way. Each arrow reads +*depends on*. + +```mermaid +graph BT + Versioning["Weft.Versioning"] --> Core["Weft.Core"] + Loro["Weft.Loro"] --> Core + Server["Weft.Server"] --> Core + Server --> Versioning + Redis["…Persistence.Redis"] --> Server + EFCore["…Persistence.EFCore"] --> Server + + style Core stroke-width:3px ``` -| Paquete | Responsabilidad | Depende de | -|---|---|---| -| **Weft.Core** | Binding seguro a `yrs` vía shim C-ABI propio; abstracciones (`ICrdtEngine`, `ICrdtDoc`); concurrencia serializada (`DocumentBroker`) | — | -| **Weft.Versioning** | Versionado content-addressed **engine-agnóstico**: `VersionStore`, `VersionId`, `IBlobStore`, diff por palabras | Weft.Core | -| **Weft.Server** | Relay WebSocket y-sync para ASP.NET Core: `AddWeftServer`/`MapWeft`, awareness, backpressure | Weft.Core, Weft.Versioning | -| **Weft.Loro** | Adaptador dual-path sobre Loro. Existe para mantener honesta la abstracción (P-IV) | Weft.Core | -| **…Persistence.Redis** / **…Persistence.EFCore** | Implementaciones de `IDocumentStore` | Weft.Server | +| Package | Responsibility | Depends on | +| --- | --- | --- | +| **Weft.Core** | Safe binding to `yrs` via an in-house C-ABI shim; abstractions (`ICrdtEngine`, `ICrdtDoc`); serialized concurrency (`DocumentBroker`) | — | +| **Weft.Versioning** | **Engine-agnostic** content-addressed versioning: `VersionStore`, `VersionId`, `IBlobStore`, word-level diff | Weft.Core | +| **Weft.Server** | y-sync WebSocket relay for ASP.NET Core: `AddWeftServer`/`MapWeft`, awareness, backpressure | Weft.Core, Weft.Versioning | +| **Weft.Loro** | Dual-path adapter over Loro. Exists to keep the abstraction honest (P-IV) | Weft.Core | +| **…Persistence.Redis** / **…Persistence.EFCore** | `IDocumentStore` implementations | Weft.Server | -La regla que sostiene el grafo: **`Weft.Versioning` no puede referenciar tipos de `yrs` ni de Loro.** -Sólo habla con las abstracciones. Es lo que hace que el versionado funcione igual sobre ambos motores, -y lo verifica un gate (`dual-engine`) que corre la misma suite sobre los dos. +The rule that holds the graph together: **`Weft.Versioning` may not reference `yrs` or Loro types.** +It only talks to the abstractions. That is what makes versioning work identically over both engines, +and a gate (`dual-engine`) verifies it by running the same suite over the two. -## La frontera FFI +## The FFI boundary -Es la parte del sistema donde un error no da una excepción sino corrupción silenciosa, así que es la -que más disciplina lleva. +This is the part of the system where a mistake yields not an exception but silent corruption, so it's +the part that carries the most discipline. -### Por qué un shim propio +### Why an in-house shim -Weft no llama a `yrs` directamente ni usa un binding de terceros: mantiene su propio shim C-ABI en -Rust (`native/weft-yrs-ffi`), y .NET habla con él vía `[LibraryImport]` (R1). El shim es la única -superficie que cruza; `yrs` nunca se expone. Eso permite fijar la semántica que .NET necesita -—índices en UTF-16, errores tipados, un contrato de memoria explícito— en vez de heredar la de Rust. +Weft does not call `yrs` directly, nor use a third-party binding: it maintains its own C-ABI shim in +Rust (`native/weft-yrs-ffi`), and .NET talks to it via `[LibraryImport]` (R1). The shim is the only +surface that crosses; `yrs` is never exposed. That lets us fix the semantics .NET needs — UTF-16 +indices, typed errors, an explicit memory contract — instead of inheriting Rust's. -Un detalle no obvio: **los índices son unidades de código UTF-16**, no bytes UTF-8 (el default de -`yrs`). Es lo que hace que `doc.InsertText("t", 5, …)` signifique lo mismo desde C# que desde Yjs JS, -y coincida con `string.Length` de .NET. +A non-obvious detail: **indices are UTF-16 code units**, not UTF-8 bytes (`yrs`'s default). That's +what makes `doc.InsertText("t", 5, …)` mean the same thing from C# as from Yjs JS, and match .NET's +`string.Length`. -Las versiones de los motores están **fijadas exactamente** (`yrs = "=0.27.2"`, `loro = "=1.13.6"`): un -bump es un acto deliberado con protocolo propio (R16), porque puede cambiar el encoding y por tanto -la identidad de las versiones ya publicadas. +Engine versions are **pinned exactly** (`yrs = "=0.27.2"`, `loro = "=1.13.6"`): a bump is a +deliberate act with its own protocol (R16), because it can change the encoding and therefore the +identity of already-published versions. -### Contrato de ownership de memoria +### Memory ownership contract -**Ésta es la parte que hay que leer si vas a tocar el shim.** La regla de oro: +**This is the part to read if you're going to touch the shim.** The golden rule: -> El GC de .NET **nunca** toca memoria nativa. Todo buffer que el shim entrega se libera **sólo** con -> `weft_buf_free`, exactamente una vez, con el mismo `(ptr, len)` que se recibió. +> The .NET GC **never** touches native memory. Every buffer the shim hands over is freed **only** with +> `weft_buf_free`, exactly once, with the same `(ptr, len)` that was received. -Tres clases de memoria cruzan la frontera, con reglas distintas: +Three classes of memory cross the boundary, with different rules: -| Qué | Quién lo asigna | Quién lo libera | Regla | -|---|---|---|---| -| **Handle de documento** (`WeftDoc*`) | El shim (`weft_doc_new` / `weft_doc_load`) | El llamador, con `weft_doc_free`, **exactamente una vez** | La idempotencia **no** está garantizada: liberar dos veces es UB. Del lado C# lo envuelve un `SafeHandle`, así que no lo haces a mano | -| **Buffers de salida** (`out_ptr` + `out_len`) | El shim (`Box<[u8]>` + `mem::forget`) | El llamador, con `weft_buf_free(ptr, len)` | `len` debe ser el que el shim devolvió: reconstruye el `Box` desde `(ptr, len)`. Un `len` distinto corrompe el allocator | -| **Buffers de entrada** | El llamador | El llamador | Están **prestados**: el shim no toma posesión ni retiene el puntero más allá de la llamada | +| What | Who allocates it | Who frees it | Rule | +| --- | --- | --- | --- | +| **Document handle** (`WeftDoc*`) | The shim (`weft_doc_new` / `weft_doc_load`) | The caller, with `weft_doc_free`, **exactly once** | Idempotency is **not** guaranteed: freeing twice is UB. On the C# side a `SafeHandle` wraps it, so you don't do it by hand | +| **Output buffers** (`out_ptr` + `out_len`) | The shim (`Box<[u8]>` + `mem::forget`) | The caller, with `weft_buf_free(ptr, len)` | `len` must be the one the shim returned: it reconstructs the `Box` from `(ptr, len)`. A different `len` corrupts the allocator | +| **Input buffers** | The caller | The caller | They are **borrowed**: the shim takes no ownership and does not retain the pointer beyond the call | -Dos postcondiciones que ahorran depuración: +Two postconditions that save debugging: -- **En error, los out-params no se escriben.** Si el código de retorno no es `WEFT_OK`, no hay nada - que liberar. -- **En éxito, un resultado vacío puede tener `out_ptr` válido con `out_len == 0`.** Hay que liberarlo - igual: «vacío» no es «nulo». +- **On error, the out-params are not written.** If the return code is not `WEFT_OK`, there is nothing + to free. +- **On success, an empty result may have a valid `out_ptr` with `out_len == 0`.** It must be freed + anyway: "empty" is not "null". -Del lado .NET esto se concentra en **un punto por motor**: `YrsDoc.TakeOwnedBuffer` y -`LoroDoc.TakeOwnedBuffer` (que llama a `weft_loro_buf_free` — cada shim libera con el suyo, nunca -cruzados). Ambos copian a memoria gestionada y liberan en un `finally`. Si auditas fugas, son los dos -sitios por los que empezar; el resto del código gestionado nunca ve un puntero nativo. +On the .NET side this concentrates in **one point per engine**: `YrsDoc.TakeOwnedBuffer` and +`LoroDoc.TakeOwnedBuffer` (which calls `weft_loro_buf_free` — each shim frees with its own, never +crossed). Both copy to managed memory and free in a `finally`. If you audit leaks, those are the two +sites to start from; the rest of the managed code never sees a native pointer. -Los handles usan `SafeHandleZeroOrMinusOneIsInvalid`, que resuelve de una vez fuga, double-free y -use-after-free (R2). Hay una fricción conocida: `[LibraryImport]` no marshala `SafeHandle` -(SYSLIB1051), así que los P/Invoke declaran `nint` crudo y las llamadas prestan el puntero con un -`HandleLease` (`DangerousAddRef`/`DangerousRelease`). Es deliberado y está documentado, no un descuido. +Handles use `SafeHandleZeroOrMinusOneIsInvalid`, which resolves leak, double-free, and use-after-free +in one go (R2). There is a known friction: `[LibraryImport]` does not marshal `SafeHandle` +(SYSLIB1051), so the P/Invokes declare raw `nint` and calls lend the pointer with a `HandleLease` +(`DangerousAddRef`/`DangerousRelease`). It's deliberate and documented, not an oversight. -### Ningún panic cruza la frontera +### No panic crosses the boundary -Un panic de Rust desenrollando a través de una frontera C es UB. Por eso **toda** entrada del shim que -ejecuta código del motor envuelve su cuerpo en un helper `guard()` con `catch_unwind`: un panic se -convierte en `WEFT_ERR_PANIC` (-127), nunca en un desenrollado que cruza. `weft_doc_free` y -`weft_buf_free` usan `catch_unwind` directo por la misma razón. (La única excepción es -`weft_abi_version`, que devuelve una constante y no puede entrar en pánico.) +A Rust panic unwinding across a C boundary is UB. That's why **every** shim entry point that runs +engine code wraps its body in a `guard()` helper with `catch_unwind`: a panic becomes `WEFT_ERR_PANIC` +(-127), never an unwind that crosses. `weft_doc_free` and `weft_buf_free` use `catch_unwind` directly +for the same reason. (The only exception is `weft_abi_version`, which returns a constant and cannot +panic.) -Esta garantía se verifica end-to-end, no se asume: el shim exporta `weft_test_panic` bajo la feature -`test-hooks`, y la suite comprueba que un panic real se contiene y el proceso sigue vivo (SC-009). El -símbolo **nunca viaja en release**: el pipeline compila sin la feature, y el job `native` de -`release.yml` verifica con `nm` que no está exportado en los cdylibs antes de que lleguen al pack. +This guarantee is verified end-to-end, not assumed: the shim exports `weft_test_panic` under the +`test-hooks` feature, and the suite checks that a real panic is contained and the process stays alive +(SC-009). The symbol **never ships in release**: the pipeline builds without the feature, and the +`native` job in `release.yml` verifies with `nm` that it is not exported in the cdylibs before they +reach the pack. -**Lo que `catch_unwind` no puede contener**: un fallo de asignación aborta el proceso vía -`handle_alloc_error`, que no es un panic. Es la raíz de R6 — ver [Límites conocidos](#límites-conocidos). +**What `catch_unwind` cannot contain**: an allocation failure aborts the process via +`handle_alloc_error`, which is not a panic. It's the root of R6 — see [Known limits](#known-limits). -### Errores +### Errors -Códigos `i32` en la frontera, mapeados a la jerarquía `WeftException` del lado gestionado. La -traducción es total: ningún código se filtra a la API pública como número. +`i32` codes at the boundary, mapped to the managed-side `WeftException` hierarchy. The translation is +total: no code leaks to the public API as a number. -| Código | Valor | Excepción .NET | -|---|---|---| +| Code | Value | .NET exception | +| --- | --- | --- | | `WEFT_OK` | 0 | — | | `WEFT_ERR_NULL_ARG` | -1 | `WeftException` | | `WEFT_ERR_DECODE` | -2 | `CorruptUpdateException` | @@ -149,191 +142,194 @@ traducción es total: ningún código se filtra a la API pública como número. | `WEFT_ERR_OUT_OF_BOUNDS` | -5 | `ArgumentOutOfRangeException` | | `WEFT_ERR_PANIC` | -127 | `WeftEngineException(Panic)` | -### Carga del binario y verificación de ABI +### Binary loading and ABI verification -El resolver se registra solo (`[ModuleInitializer]`) y busca el binario en -`runtimes//native/` → `runtimes//native/` → directorio base, con fallback a -`NATIVE_DLL_SEARCH_DIRECTORIES`. Es el layout estándar de NuGet multi-RID (patrón SkiaSharp, R11), -así que `dotnet add package Weft.Core` resuelve el nativo sin que hagas nada. +The resolver registers itself (`[ModuleInitializer]`) and looks for the binary in +`runtimes//native/` → `runtimes//native/` → base directory, with a fallback to +`NATIVE_DLL_SEARCH_DIRECTORIES`. It's the standard NuGet multi-RID layout (SkiaSharp pattern, R11), so +`dotnet add package Weft.Core` resolves the native binary without you doing anything. -Al cargar, **verifica la ABI antes de usar la librería**: llama a `weft_abi_version` y, si el símbolo -falta o la versión no es la esperada (hoy **2**), libera la librería y lanza una excepción explícita. -Esto convierte un desajuste binario —que si no sería corrupción silenciosa o un crash sin contexto— -en un error legible en el primer uso. La ABI subió a 2 al añadirse `weft_doc_new_with_client_id` -(client-ids deterministas, necesarios para el gate de paridad con Yjs). +On load, it **verifies the ABI before using the library**: it calls `weft_abi_version` and, if the +symbol is missing or the version is not the expected one (currently **2**), it frees the library and +throws an explicit exception. This turns a binary mismatch — which would otherwise be silent +corruption or a context-free crash — into a readable error on first use. The ABI went to 2 when +`weft_doc_new_with_client_id` was added (deterministic client-ids, needed for the Yjs parity gate). -La superficie son **12 funciones** de datos (crear doc, crear doc con client-id fijo, cargar, liberar; -insertar/borrar/leer texto; exportar estado/state-vector/delta; aplicar update; liberar buffer), más -`weft_abi_version`. El contrato formal está en +The surface is **12 data functions** (create doc, create doc with fixed client-id, load, free; +insert/delete/read text; export state/state-vector/delta; apply update; free buffer), plus +`weft_abi_version`. The formal contract is in [`contracts/ffi-abi.md`](../specs/001-weft-crdt-versioning/contracts/ffi-abi.md). -### El shim de Loro +### The Loro shim -`native/weft-loro-ffi` es simétrico, con prefijo `weft_loro_*`, su propio `weft_loro_buf_free` y su -propio `weft_loro_abi_version`. Mismas reglas de ownership y de `catch_unwind`. +`native/weft-loro-ffi` is symmetric, with a `weft_loro_*` prefix, its own `weft_loro_buf_free` and its +own `weft_loro_abi_version`. Same ownership and `catch_unwind` rules. -Expone además tres *probes* que `yrs` no tiene (`shallow_snapshot`, `native_diff_probe`, -`native_branch_merge_probe`), superficie de `INativeVersioning`. **Son demostrativos**: su salida no -es byte-determinista entre réplicas, **no** alimentan `VersionId` y ningún gate depende de ellos. -Existen para probar que la abstracción admite capacidades específicas de motor sin que el dominio se -entere; el content-addressing sigue viniendo de `ExportState()` en ambos motores. +It also exposes three *probes* that `yrs` doesn't have (`shallow_snapshot`, `native_diff_probe`, +`native_branch_merge_probe`), the `INativeVersioning` surface. **They are demonstrative**: their +output is not byte-deterministic across replicas, they do **not** feed `VersionId`, and no gate +depends on them. They exist to prove the abstraction admits engine-specific capabilities without the +domain noticing; content-addressing still comes from `ExportState()` on both engines. -## Flujo de sync +## Sync flow ```text -cliente ──WebSocket──> MapWeft ──> IWeftAuthorizer ──> WeftServer ──> DocumentHub +client ──WebSocket──> MapWeft ──> IWeftAuthorizer ──> WeftServer ──> DocumentHub │ │ Deny → 403 DocumentSession - (antes del upgrade) │ + (before the upgrade) │ DocumentActor - (canal 1-reader) + (1-reader channel) │ ICrdtDoc ``` -El recorrido de un update: - -1. **Endpoint**. `MapWeft` expone `{pattern}/{docId}`. Si falta un `IWeftAuthorizer` o un - `IDocumentStore` registrado, **falla al arrancar**, no en la primera petición. Un `Deny` responde - **403 antes del upgrade** a WebSocket: cero bytes de contenido para quien no tiene acceso. -2. **Handshake**. El **servidor** manda su `SyncStep1` primero; el cliente responde con el suyo y el - servidor contesta con `SyncStep2(delta)`. Incremental en ambas direcciones desde el primer byte: - por eso una reconexión transfiere un delta y no el estado completo (SC-004; medido: 479 B → 26 B, - 94,6 % menos). -3. **Aplicar y persistir**. El update se aplica dentro del turno del actor y se persiste (`AppendUpdateAsync`). -4. **Difundir**. El delta se difunde a **todas** las conexiones del documento, incluido el emisor. El - eco es un no-op CRDT idempotente, y es deliberado: rastrear el origen dentro del turno del actor - costaría más que dejar que el CRDT haga su trabajo. -5. **Cerrar**. Cada desconexión emite la retirada de awareness de ese cliente, para que los demás - dejen de verlo al instante (FR-015). Cuando además era el **último**, se consolida un snapshot - (compaction) y se libera la sesión. - -Cosas del protocolo que conviene saber: - -- **Awareness (presencia) nunca se persiste.** Es efímera por definición; se difunde a los pares y ya. -- **Un cliente `ReadOnly` que manda `SyncStep2` se ignora sin cerrar la conexión** — cerrarla rompería - el handshake y-websocket estándar. Pero si manda un `Update` se cierra con **1008** (PolicyViolation). - La distinción es intencional. -- **Backpressure en dos ejes** (FU-002): tamaño de mensaje (16 MiB por defecto → cierre **1009**) y - cola de envío por conexión (256 → se cierra el consumidor lento). Un cliente lento no puede hacer - crecer la memoria del servidor sin límite. -- Mensaje malformado → cierre **1002** (ProtocolError). El cap de tamaño se aplica **antes** de parsear. - -### Concurrencia - -`ICrdtDoc` **no es thread-safe**, y no se pretende que lo sea. La serialización vive un nivel arriba: - -- Un **`DocumentActor` por documento**, con un canal de un solo lector (R6). Todas las operaciones de - un documento pasan por su turno; no hay locks en el camino caliente. -- El **broker** gestiona el ciclo de vida: desalojo por inactividad, LRU bajo presión de memoria, y - recarga desde lo persistido al reabrir. `MaxActiveDocuments` es un límite **suave**: nunca desaloja - un documento con sesiones vivas. -- La carrera interesante —desalojo en vuelo mientras alguien reabre el mismo documento— se resuelve - esperando a que la persistencia termine antes de recargar (SC-006). Está cubierta por la prueba de - carga, que fuerza cientos de miles de desalojos. - -Fuera del broker, serializar es responsabilidad del dueño del documento (P-V). - -## Modelo de versionado - -Ortogonal al sync: puedes versionar sin servidor, y servir sin versionar. - -- **`VersionId` = SHA-256 del export determinista.** No hay contador ni reloj: la identidad *es* el - contenido (R10). Dos réplicas convergidas publican el mismo id, byte a byte. Esto sólo funciona - porque el export es determinista — de ahí que el determinismo sea un principio constitucional con - gate propio, y no un detalle de implementación. -- **`IBlobStore`** guarda blobs por hash: `Put` es idempotente y la deduplicación sale gratis. **No hay - `delete` en v1**: las versiones publicadas son inmutables y la retención la decide el consumidor. -- **`VersionStore`** publica, hace checkout, diff (LCS por palabras, R9), branch y merge. Al leer, - **re-hashea y compara** antes de devolver: un blob corrupto da `BlobIntegrityException`, no un - documento silenciosamente equivocado. -- **Los merges cross-engine se rechazan** comparando `EngineName`: el formato de update de yrs y el de - Loro no son intercambiables, y fallar temprano y claro es mejor que fallar dentro del FFI. - -Una nota sobre citabilidad: **nunca se usa `skip_gc`** en el motor. La capacidad de citar una versión -antigua no viene de retener basura en el documento vivo, sino de que cada versión publicada es un blob -inmutable direccionado por contenido. - -### Paridad servidor ↔ local - -`IWeftServer.PublishAsync` ejecuta el export **dentro del turno del actor**, lo que garantiza que -publicar desde el servidor da el mismo `VersionId` que publicar en local sobre el mismo estado. Sin -esa garantía, «la versión v1 del documento» significaría cosas distintas según quién la publicó. - -## Persistencia (eje distinto) - -`IDocumentStore` trata el estado como **blobs opacos** (R8): snapshot consolidado + updates -acumulados. La capa de persistencia no sabe de CRDTs, y eso es a propósito — es lo que permite que -haya adaptadores de Redis, EF Core, filesystem y memoria intercambiables, todos validados por la -**misma** suite de contrato. - -La recuperación tolera solapamiento entre snapshot y updates porque aplicar un update es idempotente: -en el peor caso se aplica dos veces lo mismo y converge igual. +The path of an update: + +1. **Endpoint**. `MapWeft` exposes `{pattern}/{docId}`. If a registered `IWeftAuthorizer` or + `IDocumentStore` is missing, it **fails at startup**, not on the first request. A `Deny` responds + **403 before the upgrade** to WebSocket: zero content bytes for whoever has no access. +2. **Handshake**. The **server** sends its `SyncStep1` first; the client replies with its own and the + server answers with `SyncStep2(delta)`. Incremental in both directions from the first byte: that's + why a reconnect transfers a delta and not the full state (SC-004; measured: 479 B → 26 B, 94.6% + less). +3. **Apply and persist**. The update is applied within the actor's turn and persisted + (`AppendUpdateAsync`). +4. **Broadcast**. The delta is broadcast to **all** the document's connections, including the sender. + The echo is an idempotent CRDT no-op, and it's deliberate: tracking the origin within the actor's + turn would cost more than letting the CRDT do its job. +5. **Close**. Each disconnect emits that client's awareness retirement, so the others stop seeing it + instantly (FR-015). When it was also the **last** one, a snapshot is consolidated (compaction) and + the session is released. + +Protocol things worth knowing: + +- **Awareness (presence) is never persisted.** It's ephemeral by definition; it's broadcast to peers + and that's it. +- **A `ReadOnly` client that sends `SyncStep2` is ignored without closing the connection** — closing + it would break the standard y-websocket handshake. But if it sends an `Update` it's closed with + **1008** (PolicyViolation). The distinction is intentional. +- **Backpressure on two axes** (FU-002): message size (16 MiB by default → **1009** close) and + per-connection send queue (256 → the slow consumer is closed). A slow client cannot grow the + server's memory without bound. +- Malformed message → **1002** (ProtocolError) close. The size cap is applied **before** parsing. + +### Concurrency + +`ICrdtDoc` is **not thread-safe**, and is not meant to be. Serialization lives one level up: + +- One **`DocumentActor` per document**, with a single-reader channel (R6). All of a document's + operations go through its turn; there are no locks on the hot path. +- The **broker** manages the lifecycle: inactivity eviction, LRU under memory pressure, and reload + from persistence on reopen. `MaxActiveDocuments` is a **soft** limit: it never evicts a document + with live sessions. +- The interesting race — eviction in flight while someone reopens the same document — is resolved by + waiting for persistence to finish before reloading (SC-006). It's covered by the load test, which + forces hundreds of thousands of evictions. + +Outside the broker, serializing is the responsibility of the document's owner (P-V). + +## Versioning model + +Orthogonal to sync: you can version without a server, and serve without versioning. + +- **`VersionId` = SHA-256 of the deterministic export.** No counter, no clock: identity *is* the + content (R10). Two converged replicas publish the same id, byte for byte. This only works because + the export is deterministic — hence determinism being a constitutional principle with its own gate, + and not an implementation detail. +- **`IBlobStore`** stores blobs by hash: `Put` is idempotent and deduplication comes for free. **There + is no `delete` in v1**: published versions are immutable and retention is the consumer's call. +- **`VersionStore`** publishes, checks out, diffs (word-level LCS, R9), branches, and merges. On read, + it **re-hashes and compares** before returning: a corrupt blob yields `BlobIntegrityException`, not + a silently wrong document. +- **Cross-engine merges are rejected** by comparing `EngineName`: the update format of yrs and that of + Loro are not interchangeable, and failing early and clearly is better than failing inside the FFI. + +A note on citability: **`skip_gc` is never used** in the engine. The ability to cite an old version +doesn't come from retaining garbage in the live document, but from each published version being an +immutable, content-addressed blob. + +### Server ↔ local parity + +`IWeftServer.PublishAsync` runs the export **within the actor's turn**, which guarantees that +publishing from the server yields the same `VersionId` as publishing locally over the same state. +Without that guarantee, "version v1 of the document" would mean different things depending on who +published it. + +## Persistence (a separate axis) + +`IDocumentStore` treats state as **opaque blobs** (R8): a consolidated snapshot + accumulated updates. +The persistence layer knows nothing about CRDTs, and that's on purpose — it's what allows +interchangeable Redis, EF Core, filesystem, and in-memory adapters, all validated by the **same** +contract suite. + +Recovery tolerates overlap between snapshot and updates because applying an update is idempotent: in +the worst case the same thing is applied twice and converges anyway. ## Gates -Los gates no son CI decorativo: son la constitución hecha ejecutable. Corren por PR en -`ci.yml` salvo donde se indique. - -| Gate | Qué protege | Bloquea | Principio | -|---|---|---|---| -| `test-{linux,win,mac}` | Build + suite en los 3 SO | Sí | P-VI | -| `asan` | 0 fugas / 0 double-free en ambos shims (nightly + ASan/LSan) | Sí | P-II | -| `determinism` | Export byte-determinista cross-RID **y paridad byte-idéntica con Yjs JS** | Sí | P-III | -| `dual-engine` | La misma suite de versionado verde sobre yrs **y** Loro | Sí | P-IV | -| `fuzz` | La frontera FFI ante bytes arbitrarios | **Parcial** | P-I/P-II | -| `pack-smoke` | Instalar el paquete y correr hello-Weft por RID; símbolo de test ausente | **No** (ver abajo) | P-VI | - -Dos matices que importan si vas a fiarte de estos gates: - -- **`fuzz` bloquea a medias, y es deliberado.** Si los targets no *compilan*, el job se pone rojo. Si - un target *encuentra un crash*, sólo emite un `::warning`. La razón es R6 (ver abajo): hoy los - targets reproducen un fallo conocido de `yrs` aguas abajo del shim, y bloquear merges por él - paralizaría el repo sin arreglar nada. -- **El `pack-smoke` real no corre por PR.** El job de `ci.yml` es un marcador; la matriz - cross-compile de verdad —y la verificación de que `weft_test_panic` no está exportado— vive en - `release.yml`, que es `workflow_dispatch` únicamente, porque la matriz es cara. En la práctica el - empaquetado se valida en el dry-run del release, no en cada PR. - -## Límites conocidos - -Vale más decirlos que descubrirlos en producción: - -- **R6 — amplificación de memoria del decoder de `yrs`.** Un update malformado de pocos bytes puede - declarar una longitud enorme y hacer que `yrs` reserve sin cota; la asignación falla y el proceso - aborta (`handle_alloc_error`, **no** capturable por `catch_unwind`). El shim es correcto —contiene - panics, sin UB—; el fallo está aguas abajo. El fix está enviado upstream - ([y-crdt#639](https://github.com/y-crdt/y-crdt/pull/639), aprobado) y se adoptará vía bump. - **Mientras tanto**: el relay ya se protege con cap de tamaño y límite de memoria; si usas la **ruta - directa** del FFI (`LoadDoc`/`ApplyUpdate`) con bytes **no confiables**, protégela igual. Ver - [`GOVERNANCE.md`](../GOVERNANCE.md) §Seguridad. -- **Sólo texto por campo nombrado en v1.** Sin mapas, arrays ni tipos anidados todavía. -- **Los probes nativos de Loro no son content-addressing** (ver arriba). Si tu código los usa como si - lo fueran, converge a resultados no deterministas. - -## Decisiones - -El *porqué* de cada elección vive en -[`research.md`](../specs/001-weft-crdt-versioning/research.md), no aquí. Índice rápido: - -| | Decisión | | Decisión | -|---|---|---|---| -| **R1** | Shim propio + `[LibraryImport]` | **R10** | Content-addressing SHA-256 | -| **R2** | `SafeHandle` / `HandleLease` | **R11** | NuGet multi-RID (patrón SkiaSharp) | -| **R3** | Ownership de buffers | **R12** | Cross-compile con cargo-zigbuild | -| **R4** | Errores tipados | **R13** | Gate de determinismo vs Yjs | -| **R5** | Índices `int` validados | **R14** | Fuzzing de la frontera | -| **R6** | Actor + Channels | **R15** | Dual-engine como prueba viva | -| **R7** | Protocolo y-protocols | **R16** | Pinning y protocolo de bump | -| **R8** | `IDocumentStore` opaco | **R17** | Authz delegada al consumidor | -| **R9** | Diff LCS por palabras | | | - -Los principios que gobiernan todo esto están en la -[constitución](../.specify/memory/constitution.md) (P-I…P-VI); son vinculantes, no aspiracionales. - -## Por dónde seguir - -- **Consumir la librería**: [`README.md`](../README.md) (quickstart) → [`docs/api/README.md`](api/README.md) -- **Contribuir**: [`CONTRIBUTING.md`](../CONTRIBUTING.md), incluido el protocolo de bump del motor -- **Validar end-to-end**: [`quickstart.md`](../specs/001-weft-crdt-versioning/quickstart.md) -- **Evidencia experimental** que fundó estas decisiones: [`docs/spikes/`](spikes/) +The gates are not decorative CI: they are the constitution made executable. They run per PR in +`ci.yml` except where noted. + +| Gate | What it protects | Blocks | Principle | +| --- | --- | --- | --- | +| `test-{linux,win,mac}` | Build + suite on the 3 OSes | Yes | P-VI | +| `asan` | 0 leaks / 0 double-free in both shims (nightly + ASan/LSan) | Yes | P-II | +| `determinism` | Byte-deterministic export cross-RID **and byte-identical parity with Yjs JS** | Yes | P-III | +| `dual-engine` | The same versioning suite green over yrs **and** Loro | Yes | P-IV | +| `fuzz` | The FFI boundary against arbitrary bytes | **Partial** | P-I/P-II | +| `pack-smoke` | Install the package and run hello-Weft per RID; test symbol absent | **No** (see below) | P-VI | + +Two nuances that matter if you're going to trust these gates: + +- **`fuzz` blocks halfway, and it's deliberate.** If the targets don't *compile*, the job goes red. If + a target *finds a crash*, it only emits a `::warning`. The reason is R6 (see below): today the + targets reproduce a known `yrs` bug downstream of the shim, and blocking merges on it would + paralyze the repo without fixing anything. +- **The real `pack-smoke` does not run per PR.** The `ci.yml` job is a marker; the real cross-compile + matrix — and the verification that `weft_test_panic` is not exported — lives in `release.yml`, which + is `workflow_dispatch`-only, because the matrix is expensive. In practice packaging is validated in + the release dry-run, not on every PR. + +## Known limits + +Better to state them than to discover them in production: + +- **R6 — `yrs` decoder memory amplification.** A malformed update of a few bytes can declare an + enormous length and make `yrs` reserve without bound; the allocation fails and the process aborts + (`handle_alloc_error`, **not** catchable by `catch_unwind`). The shim is correct — it contains + panics, no UB; the failure is downstream. The fix is submitted upstream + ([y-crdt#639](https://github.com/y-crdt/y-crdt/pull/639), approved) and will be adopted via a bump. + **In the meantime**: the relay already protects itself with a size cap and a memory limit; if you + use the **direct** FFI path (`LoadDoc`/`ApplyUpdate`) with **untrusted** bytes, protect it the same + way. See [`GOVERNANCE.md`](../GOVERNANCE.md#security) (Security section). +- **Text-by-named-field only in v1.** No maps, arrays, or nested types yet. +- **Loro's native probes are not content-addressing** (see above). If your code uses them as if they + were, it converges to non-deterministic results. + +## Decisions + +The *why* of each choice lives in +[`research.md`](../specs/001-weft-crdt-versioning/research.md), not here. Quick index: + +| | Decision | | Decision | +| --- | --- | --- | --- | +| **R1** | In-house shim + `[LibraryImport]` | **R10** | SHA-256 content-addressing | +| **R2** | `SafeHandle` / `HandleLease` | **R11** | NuGet multi-RID (SkiaSharp pattern) | +| **R3** | Buffer ownership | **R12** | Cross-compile with cargo-zigbuild | +| **R4** | Typed errors | **R13** | Determinism gate vs Yjs | +| **R5** | Validated `int` indices | **R14** | Boundary fuzzing | +| **R6** | Actor + Channels | **R15** | Dual-engine as living proof | +| **R7** | y-protocols protocol | **R16** | Pinning and bump protocol | +| **R8** | Opaque `IDocumentStore` | **R17** | Authz delegated to the consumer | +| **R9** | Word-level LCS diff | | | + +The principles that govern all of this are in the +[constitution](../.specify/memory/constitution.md) (P-I…P-VI); they are binding, not aspirational. + +## Where to go next + +- **Consume the library**: [`README.md`](../README.md) (quickstart) → [`docs/api/README.md`](api/README.md) +- **Contribute**: [`CONTRIBUTING.md`](../CONTRIBUTING.md), including the engine bump protocol +- **Validate end-to-end**: [`quickstart.md`](../specs/001-weft-crdt-versioning/quickstart.md) +- **Experimental evidence** that founded these decisions: [`docs/spikes/`](spikes/) diff --git a/native/about.hbs b/native/about.hbs new file mode 100644 index 0000000..b2c91f6 --- /dev/null +++ b/native/about.hbs @@ -0,0 +1,34 @@ + +# Third-Party Notices + +Weft itself is licensed under **Apache-2.0** (see [`LICENSE`](./LICENSE)). + +Its NuGet packages redistribute native binaries (`weft-yrs-ffi` and `weft-loro-ffi`, +the cdylibs under `runtimes//native/`) that statically link the Rust crates listed +below. This file reproduces their licenses as required by those licenses and by +Apache-2.0 §4(d). It is generated with [`cargo-about`](https://github.com/EmbarkStudios/cargo-about); +regenerate with the command in `native/about.toml`. + +All redistributed components are under permissive or weak-copyleft (MPL-2.0) licenses, +all compatible with proprietary and open-source use. The default engine (`yrs`) path is +fully permissive (MIT/Apache/BSD); the **MPL-2.0** components (`im`, `bitmaps`, +`sized-chunks`) arrive only via the optional **Loro** engine (`Weft.Loro`). + +## Summary + +{{#each overview}} +- **{{name}}** ({{id}}) — {{count}} crate(s) +{{/each}} + +{{#each licenses}} +## {{name}} + +{{#each used_by}} +- {{crate.name}} {{crate.version}}{{#if crate.repository}} — <{{crate.repository}}>{{/if}} +{{/each}} + +``` +{{text}} +``` + +{{/each}} diff --git a/native/about.toml b/native/about.toml new file mode 100644 index 0000000..94fe24f --- /dev/null +++ b/native/about.toml @@ -0,0 +1,36 @@ +# Config for `cargo-about` — generates THIRD-PARTY-NOTICES.md for the Rust crates +# statically linked into the redistributed cdylibs (weft-yrs-ffi, weft-loro-ffi). +# +# Regenerate: cargo about generate --manifest-path native/Cargo.toml native/about.hbs -o THIRD-PARTY-NOTICES.md +# +# `accepted` lists the SPDX license IDs we allow in redistributed binaries. All are +# permissive or weak-copyleft (MPL-2.0, file-level, compatible with proprietary use). +# A dependency licensed under a disallowed license would make `cargo about generate` fail +# — that is the CI gate. + +# Our own (unpublished) workspace crates are not third parties — exclude them. +private = { ignore = true } + +# Evaluate every RID we ship, so target-specific deps (Windows/macOS/UEFI) are covered. +targets = [ + "x86_64-unknown-linux-gnu", + "aarch64-unknown-linux-gnu", + "x86_64-pc-windows-msvc", + "aarch64-apple-darwin", +] + +# Don't fail on crates that ship no license text of their own; cargo-about will fetch it. +accepted = [ + "MIT", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "MPL-2.0", + "BSL-1.0", + "Zlib", + "Unicode-3.0", + "Unlicense", + "CC0-1.0", +] diff --git a/native/weft-loro-ffi/fuzz/fuzz_targets/loro_apply_update.rs b/native/weft-loro-ffi/fuzz/fuzz_targets/loro_apply_update.rs index 259c413..21dfec8 100644 --- a/native/weft-loro-ffi/fuzz/fuzz_targets/loro_apply_update.rs +++ b/native/weft-loro-ffi/fuzz/fuzz_targets/loro_apply_update.rs @@ -1,4 +1,4 @@ -//! Fuzz target: `weft_loro_doc_apply_update` con bytes arbitrarios sobre un doc vivo (research R14). +//! Fuzz target: `weft_loro_doc_apply_update` with arbitrary bytes over a live doc (research R14). #![no_main] use std::ptr; diff --git a/native/weft-loro-ffi/fuzz/fuzz_targets/loro_doc_load.rs b/native/weft-loro-ffi/fuzz/fuzz_targets/loro_doc_load.rs index 92a565c..c396015 100644 --- a/native/weft-loro-ffi/fuzz/fuzz_targets/loro_doc_load.rs +++ b/native/weft-loro-ffi/fuzz/fuzz_targets/loro_doc_load.rs @@ -1,5 +1,5 @@ -//! Fuzz target: `weft_loro_doc_load` con bytes arbitrarios (research R14). El shim contiene panics -//! (`catch_unwind`) y rechaza corruptos (WEFT_ERR_DECODE); un SIGSEGV/UB real sigue detectándose. +//! Fuzz target: `weft_loro_doc_load` with arbitrary bytes (research R14). The shim contains panics +//! (`catch_unwind`) and rejects corrupt inputs (WEFT_ERR_DECODE); a real SIGSEGV/UB is still detected. #![no_main] use std::ptr; @@ -12,7 +12,7 @@ use weft_loro_ffi::*; static INIT: Once = Once::new(); fuzz_target!(|data: &[u8]| { - // Silencia el hook de libfuzzer-sys para ejercitar el catch_unwind del shim (ver weft-yrs-ffi). + // Silences libfuzzer-sys's hook to exercise the shim's catch_unwind (see weft-yrs-ffi). INIT.call_once(|| std::panic::set_hook(Box::new(|_| {}))); unsafe { diff --git a/native/weft-loro-ffi/include/weft_loro_ffi.h b/native/weft-loro-ffi/include/weft_loro_ffi.h index 3301063..c98a35f 100644 --- a/native/weft-loro-ffi/include/weft_loro_ffi.h +++ b/native/weft-loro-ffi/include/weft_loro_ffi.h @@ -1,29 +1,29 @@ /* - * weft_loro_ffi.h — contrato C-ABI del shim `weft-loro-ffi` (Weft, Apache-2.0). + * weft_loro_ffi.h — C-ABI contract of the `weft-loro-ffi` shim (Weft, Apache-2.0). * - * Réplica de la ABI de `weft-yrs-ffi` con prefijo `weft_loro_`, mapeada sobre `loro`. Fuente de - * verdad del contrato de ownership. `HeaderBindingParityTests` valida que las declaraciones - * `[LibraryImport]` de Weft.Loro coinciden con este header (paridad sintáctica: conjunto de - * funciones, aridad, orden y tipos). Se mantiene A MANO: no hay csbindgen en el repo. La ABI es - * propia y estable: un bump de `loro` cambia lib.rs, jamás este header sin incrementar + * Replica of the `weft-yrs-ffi` ABI with the `weft_loro_` prefix, mapped onto `loro`. Source of + * truth for the ownership contract. `HeaderBindingParityTests` validates that the + * `[LibraryImport]` declarations of Weft.Loro match this header (syntactic parity: function + * set, arity, order and types). Maintained BY HAND: there is no csbindgen in the repo. The ABI is + * owned and stable: a `loro` bump changes lib.rs, never this header without incrementing * weft_loro_abi_version(). * - * ── Reglas transversales (no negociables) ────────────────────────────────────────────── - * 1. Panics: cada función envuelve su cuerpo en catch_unwind; un panic retorna - * WEFT_ERR_PANIC, jamás cruza la frontera (sería UB). - * 2. Ownership de buffers: - * - Salida (out_ptr/out_len): asignados por el shim (Box<[u8]>); el llamador los libera - * SOLO con weft_loro_buf_free(ptr, len), exactamente una vez, nunca con el GC/Marshal. - * - Entrada (ptr+len): prestados; el shim no toma posesión ni retiene el puntero. - * - WeftLoroDoc*: se libera SOLO con weft_loro_doc_free, exactamente una vez. - * 3. Thread-safety: LoroDoc ES Send+Sync (locking interno); aun así, el contrato de Weft serializa - * por documento (el broker es single-reader). weft_loro_buf_free es thread-safe. - * 4. Strings: entradas de texto son UTF-8 (ptr+len, sin NUL); UTF-8 inválido -> WEFT_ERR_UTF8. - * 5. Índices: uint32_t en UTF-16 code units (consistente con .NET/Yjs); fuera de rango -> + * ── Cross-cutting rules (non-negotiable) ────────────────────────────────────────────── + * 1. Panics: each function wraps its body in catch_unwind; a panic returns + * WEFT_ERR_PANIC, never crosses the boundary (would be UB). + * 2. Buffer ownership: + * - Output (out_ptr/out_len): allocated by the shim (Box<[u8]>); the caller frees them + * ONLY with weft_loro_buf_free(ptr, len), exactly once, never with the GC/Marshal. + * - Input (ptr+len): borrowed; the shim takes no ownership nor retains the pointer. + * - WeftLoroDoc*: freed ONLY with weft_loro_doc_free, exactly once. + * 3. Thread-safety: LoroDoc IS Send+Sync (internal locking); even so, Weft's contract serializes + * per document (the broker is single-reader). weft_loro_buf_free is thread-safe. + * 4. Strings: text inputs are UTF-8 (ptr+len, no NUL); invalid UTF-8 -> WEFT_ERR_UTF8. + * 5. Indices: uint32_t in UTF-16 code units (consistent with .NET/Yjs); out of range -> * WEFT_ERR_OUT_OF_BOUNDS. * - * Postcondiciones: en error, los out-params quedan sin escribir; en éxito con contenido vacío, - * out_ptr puede ser válido con out_len == 0 (liberar igual). + * Postconditions: on error, the out-params are left unwritten; on success with empty content, + * out_ptr may be valid with out_len == 0 (free it anyway). */ #ifndef WEFT_LORO_FFI_H #define WEFT_LORO_FFI_H @@ -35,28 +35,28 @@ extern "C" { #endif -/* Puntero opaco al documento CRDT de Loro. Se libera SOLO con weft_loro_doc_free. */ +/* Opaque pointer to Loro's CRDT document. Freed ONLY with weft_loro_doc_free. */ typedef struct WeftLoroDoc WeftLoroDoc; -/* ── Códigos de estado (int32_t, idénticos a weft-yrs-ffi) ─────────────────────────────── */ +/* ── Status codes (int32_t, identical to weft-yrs-ffi) ─────────────────────────────── */ #define WEFT_OK 0 -#define WEFT_ERR_NULL_ARG -1 /* puntero requerido nulo */ -#define WEFT_ERR_DECODE -2 /* blob/update no decodificable */ -#define WEFT_ERR_APPLY -3 /* fallo aplicando update */ -#define WEFT_ERR_UTF8 -4 /* texto de entrada no UTF-8 */ -#define WEFT_ERR_OUT_OF_BOUNDS -5 /* índice/longitud fuera de rango */ -#define WEFT_ERR_PANIC -127 /* panic capturado en el shim */ +#define WEFT_ERR_NULL_ARG -1 /* required pointer null */ +#define WEFT_ERR_DECODE -2 /* blob/update not decodable */ +#define WEFT_ERR_APPLY -3 /* failure applying update */ +#define WEFT_ERR_UTF8 -4 /* input text not UTF-8 */ +#define WEFT_ERR_OUT_OF_BOUNDS -5 /* index/length out of range */ +#define WEFT_ERR_PANIC -127 /* panic caught in the shim */ -/* ── Ciclo de vida del documento ──────────────────────────────────────────────────────── */ +/* ── Document lifecycle ──────────────────────────────────────────────────────── */ int32_t weft_loro_doc_new(WeftLoroDoc** out_doc); -/* Doc nuevo con peer_id FIJO (siembra determinista, FU-016). peer_id == u64::MAX está reservado por - * Loro -> WEFT_ERR_OUT_OF_BOUNDS. Para uso determinista de test/corpus (reusar un peer_id entre - * escritores concurrentes corrompe el doc). ABI v3. */ +/* New doc with FIXED peer_id (deterministic seeding, FU-016). peer_id == u64::MAX is reserved by + * Loro -> WEFT_ERR_OUT_OF_BOUNDS. For deterministic test/corpus use (reusing a peer_id across + * concurrent writers corrupts the doc). ABI v3. */ int32_t weft_loro_doc_new_with_peer_id(uint64_t peer_id, WeftLoroDoc** out_doc); int32_t weft_loro_doc_load(const uint8_t* blob, size_t blob_len, WeftLoroDoc** out_doc); void weft_loro_doc_free(WeftLoroDoc* doc); -/* ── Texto por campo nombrado (índices UTF-16) ────────────────────────────────────────── */ +/* ── Text by named field (UTF-16 indices) ────────────────────────────────────────── */ int32_t weft_loro_text_insert(WeftLoroDoc* doc, const uint8_t* field, size_t field_len, uint32_t index, const uint8_t* text, size_t text_len); int32_t weft_loro_text_delete(WeftLoroDoc* doc, const uint8_t* field, size_t field_len, @@ -64,37 +64,37 @@ int32_t weft_loro_text_delete(WeftLoroDoc* doc, const uint8_t* field, size_t fie int32_t weft_loro_text_read(WeftLoroDoc* doc, const uint8_t* field, size_t field_len, uint8_t** out_ptr, size_t* out_len); -/* ── Estado y sincronización ──────────────────────────────────────────────────────────── */ +/* ── State and synchronization ──────────────────────────────────────────────────────────── */ int32_t weft_loro_doc_export_state(WeftLoroDoc* doc, uint8_t** out_ptr, size_t* out_len); int32_t weft_loro_doc_state_vector(WeftLoroDoc* doc, uint8_t** out_ptr, size_t* out_len); int32_t weft_loro_doc_export_since(WeftLoroDoc* doc, const uint8_t* sv, size_t sv_len, uint8_t** out_ptr, size_t* out_len); int32_t weft_loro_doc_apply_update(WeftLoroDoc* doc, const uint8_t* update, size_t update_len); -/* ── Versionado nativo (INativeVersioning, capacidad opcional — CHARTER-10/FU-006) ────── - * Probes DEMOSTRATIVOS de la capacidad nativa de Loro (diff/fork/shallow). Su salida NO es - * byte-determinista y NO alimenta VersionId (usar weft_loro_doc_export_state para eso). */ +/* ── Native versioning (INativeVersioning, optional capability — CHARTER-10/FU-006) ────── + * DEMONSTRATIVE probes of Loro's native capability (diff/fork/shallow). Their output is NOT + * byte-deterministic and does NOT feed VersionId (use weft_loro_doc_export_state for that). */ int32_t weft_loro_shallow_snapshot(WeftLoroDoc* doc, uint8_t** out_ptr, size_t* out_len); int32_t weft_loro_native_diff_probe(WeftLoroDoc* doc, const uint8_t* field, size_t field_len, uint8_t** out_ptr, size_t* out_len); int32_t weft_loro_native_branch_merge_probe(WeftLoroDoc* doc, const uint8_t* field, size_t field_len, uint8_t** out_ptr, size_t* out_len); -/* ── Memoria ──────────────────────────────────────────────────────────────────────────── */ +/* ── Memory ──────────────────────────────────────────────────────────────────────────── */ void weft_loro_buf_free(uint8_t* ptr, size_t len); -/* ── Diagnóstico ────────────────────────────────────────────────────────────────────────── - * Versión de ESTA ABI. Hoy: v3 (v2→v3 añadió weft_loro_doc_new_with_peer_id, CHARTER-13/FU-016; - * v1→v2 añadió los tres probes de versionado nativo, CHARTER-10). - * El resolver del binding la verifica al cargar y rechaza un shim con versión distinta. */ +/* ── Diagnostics ────────────────────────────────────────────────────────────────────────── + * Version of THIS ABI. Today: v3 (v2→v3 added weft_loro_doc_new_with_peer_id, CHARTER-13/FU-016; + * v1→v2 added the three native-versioning probes, CHARTER-10). + * The binding resolver checks it when loading and rejects a shim with a different version. */ uint32_t weft_loro_abi_version(void); -/* ── Test hooks (SOLO en builds con la feature de Cargo `test-hooks`) ───────────────────── - * Provoca un panic interno deliberado para verificar catch_unwind end-to-end (SC-009). - * NUNCA presente en binarios de release: el job `native` de release.yml verifica con `nm` que el - * símbolo no está exportado en los cdylibs antes de empaquetarlos. */ +/* ── Test hooks (ONLY in builds with the Cargo `test-hooks` feature) ───────────────────── + * Triggers a deliberate internal panic to verify catch_unwind end-to-end (SC-009). + * NEVER present in release binaries: the `native` job in release.yml verifies with `nm` that the + * symbol is not exported in the cdylibs before packaging them. */ #ifdef WEFT_TEST_HOOKS -int32_t weft_loro_test_panic(void); /* retorna WEFT_ERR_PANIC si el shim es correcto */ +int32_t weft_loro_test_panic(void); /* returns WEFT_ERR_PANIC if the shim is correct */ #endif #ifdef __cplusplus diff --git a/native/weft-loro-ffi/src/lib.rs b/native/weft-loro-ffi/src/lib.rs index fd41e9c..73b3455 100644 --- a/native/weft-loro-ffi/src/lib.rs +++ b/native/weft-loro-ffi/src/lib.rs @@ -1,16 +1,16 @@ -//! # weft-loro-ffi — shim C-ABI de Weft sobre `loro` (adaptador dual-path, P-IV) +//! # weft-loro-ffi — Weft's C-ABI shim over `loro` (dual-path adapter, P-IV) //! -//! Réplica de la ABI de `weft-yrs-ffi` con prefijo `weft_loro_`, mapeada sobre la API de Loro. -//! Diferencias con yrs: `LoroDoc` es Send+Sync (locking interno; el shim no añade locks), y el -//! versionado nativo (diff/branch/shallow) se expone aparte como capacidad opcional -//! (`INativeVersioning`). Índices en UTF-16 code units (consistente con .NET y Yjs). +//! Replica of the `weft-yrs-ffi` ABI with the `weft_loro_` prefix, mapped onto Loro's API. +//! Differences from yrs: `LoroDoc` is Send+Sync (internal locking; the shim adds no locks), and +//! native versioning (diff/branch/shallow) is exposed separately as an optional capability +//! (`INativeVersioning`). Indices in UTF-16 code units (consistent with .NET and Yjs). use std::os::raw::c_uchar; use std::panic::{catch_unwind, AssertUnwindSafe}; use loro::{ExportMode, Frontiers, LoroDoc, VersionVector}; -// ── Códigos de estado (idénticos a weft-yrs-ffi) ── +// ── Status codes (identical to weft-yrs-ffi) ── pub const WEFT_OK: i32 = 0; pub const WEFT_ERR_NULL_ARG: i32 = -1; pub const WEFT_ERR_DECODE: i32 = -2; @@ -21,8 +21,8 @@ pub const WEFT_ERR_PANIC: i32 = -127; const WEFT_ABI_VERSION: u32 = 3; -/// PeerID reservado por Loro (`loro-internal/src/loro.rs`: `set_peer_id(u64::MAX)` → -/// `LoroError::InvalidPeerID`). Se rechaza en la frontera para no depender del error interno. +/// PeerID reserved by Loro (`loro-internal/src/loro.rs`: `set_peer_id(u64::MAX)` → +/// `LoroError::InvalidPeerID`). Rejected at the boundary so as not to depend on the internal error. const PEER_ID_RESERVED: u64 = u64::MAX; fn guard i32>(f: F) -> i32 { @@ -33,7 +33,7 @@ fn guard i32>(f: F) -> i32 { } /// # Safety -/// `ptr` debe apuntar a `len` bytes válidos y vivos durante la llamada. +/// `ptr` must point to `len` valid bytes that stay alive during the call. unsafe fn borrow_str<'a>(ptr: *const c_uchar, len: usize) -> Option<&'a str> { if ptr.is_null() && len != 0 { return None; @@ -47,7 +47,7 @@ unsafe fn borrow_str<'a>(ptr: *const c_uchar, len: usize) -> Option<&'a str> { } /// # Safety -/// `ptr` debe apuntar a `len` bytes válidos y vivos durante la llamada. +/// `ptr` must point to `len` valid bytes that stay alive during the call. unsafe fn borrow_bytes<'a>(ptr: *const c_uchar, len: usize) -> Option<&'a [u8]> { if ptr.is_null() { return if len == 0 { Some(&[]) } else { None }; @@ -56,7 +56,7 @@ unsafe fn borrow_bytes<'a>(ptr: *const c_uchar, len: usize) -> Option<&'a [u8]> } /// # Safety -/// `out_ptr` y `out_len` deben ser punteros escribibles no nulos. +/// `out_ptr` and `out_len` must be non-null writable pointers. unsafe fn hand_out_buffer(data: Vec, out_ptr: *mut *mut c_uchar, out_len: *mut usize) -> i32 { if out_ptr.is_null() || out_len.is_null() { return WEFT_ERR_NULL_ARG; @@ -71,7 +71,7 @@ unsafe fn hand_out_buffer(data: Vec, out_ptr: *mut *mut c_uchar, out_len: *m } /// # Safety -/// `doc` debe provenir de `weft_loro_doc_new`/`weft_loro_doc_load` y no haber sido liberado. +/// `doc` must come from `weft_loro_doc_new`/`weft_loro_doc_load` and must not have been freed. unsafe fn doc_ref<'a>(doc: *mut LoroDoc) -> Option<&'a LoroDoc> { if doc.is_null() { None @@ -80,10 +80,10 @@ unsafe fn doc_ref<'a>(doc: *mut LoroDoc) -> Option<&'a LoroDoc> { } } -// ── Ciclo de vida ── +// ── Lifecycle ── /// # Safety -/// `out_doc` debe ser un puntero escribible no nulo. +/// `out_doc` must be a non-null writable pointer. #[no_mangle] pub unsafe extern "C" fn weft_loro_doc_new(out_doc: *mut *mut LoroDoc) -> i32 { guard(|| { @@ -95,15 +95,15 @@ pub unsafe extern "C" fn weft_loro_doc_new(out_doc: *mut *mut LoroDoc) -> i32 { }) } -/// Doc nuevo con `peer_id` FIJO (siembra determinista, FU-016; capacidad `IDeterministicSeeding`). -/// Habilita exports reproducibles cross-run/cross-RID (un `LoroDoc` normal recibe un `peer_id` -/// aleatorio). `peer_id == u64::MAX` está reservado por Loro → `WEFT_ERR_OUT_OF_BOUNDS`. ABI v3. +/// New doc with a FIXED `peer_id` (deterministic seeding, FU-016; `IDeterministicSeeding` capability). +/// Enables reproducible exports cross-run/cross-RID (a normal `LoroDoc` gets a random +/// `peer_id`). `peer_id == u64::MAX` is reserved by Loro → `WEFT_ERR_OUT_OF_BOUNDS`. ABI v3. /// -/// AVISO: reusar un `peer_id` entre escritores concurrentes corrompe el documento (Loro). Esta -/// función es para uso determinista de test/corpus, no para identidad por usuario/dispositivo. +/// WARNING: reusing a `peer_id` across concurrent writers corrupts the document (Loro). This +/// function is for deterministic test/corpus use, not for per-user/per-device identity. /// /// # Safety -/// `out_doc` debe ser un puntero escribible no nulo. +/// `out_doc` must be a non-null writable pointer. #[no_mangle] pub unsafe extern "C" fn weft_loro_doc_new_with_peer_id( peer_id: u64, @@ -118,8 +118,8 @@ pub unsafe extern "C" fn weft_loro_doc_new_with_peer_id( } let doc = LoroDoc::new(); if doc.set_peer_id(peer_id).is_err() { - // No debería ocurrir: el único fallo documentado de set_peer_id es el valor reservado, - // ya filtrado arriba. Se mapea defensivamente en vez de entrar en pánico. + // Should not happen: the only documented failure of set_peer_id is the reserved value, + // already filtered above. Mapped defensively instead of panicking. return WEFT_ERR_APPLY; } *out_doc = Box::into_raw(Box::new(doc)); @@ -128,7 +128,7 @@ pub unsafe extern "C" fn weft_loro_doc_new_with_peer_id( } /// # Safety -/// `blob` debe apuntar a `blob_len` bytes válidos; `out_doc` escribible no nulo. +/// `blob` must point to `blob_len` valid bytes; `out_doc` non-null writable. #[no_mangle] pub unsafe extern "C" fn weft_loro_doc_load( blob: *const c_uchar, @@ -152,7 +152,7 @@ pub unsafe extern "C" fn weft_loro_doc_load( } /// # Safety -/// `doc` debe ser null o un puntero válido no liberado antes. +/// `doc` must be null or a valid pointer not freed before. #[no_mangle] pub unsafe extern "C" fn weft_loro_doc_free(doc: *mut LoroDoc) { if doc.is_null() { @@ -161,10 +161,10 @@ pub unsafe extern "C" fn weft_loro_doc_free(doc: *mut LoroDoc) { let _ = catch_unwind(AssertUnwindSafe(|| drop(Box::from_raw(doc)))); } -// ── Texto por campo nombrado (índices UTF-16) ── +// ── Text by named field (UTF-16 indices) ── /// # Safety -/// Punteros válidos por sus longitudes; `doc` válido. +/// Pointers valid for their lengths; `doc` valid. #[no_mangle] pub unsafe extern "C" fn weft_loro_text_insert( doc: *mut LoroDoc, @@ -194,7 +194,7 @@ pub unsafe extern "C" fn weft_loro_text_insert( } /// # Safety -/// Punteros válidos por sus longitudes; `doc` válido. +/// Pointers valid for their lengths; `doc` valid. #[no_mangle] pub unsafe extern "C" fn weft_loro_text_delete( doc: *mut LoroDoc, @@ -223,7 +223,7 @@ pub unsafe extern "C" fn weft_loro_text_delete( } /// # Safety -/// Ver contrato del módulo. +/// See the module contract. #[no_mangle] pub unsafe extern "C" fn weft_loro_text_read( doc: *mut LoroDoc, @@ -244,15 +244,15 @@ pub unsafe extern "C" fn weft_loro_text_read( }) } -// ── Estado y sincronización ── +// ── State and synchronization ── -/// Export determinista del estado completo para content-addressing (P-III). Usa `all_updates` -/// (no `Snapshot`): el snapshot incluye metadata dependiente de la réplica (peer-ids, orden interno) -/// y NO es byte-determinista entre réplicas convergidas; `all_updates` serializa el oplog de forma -/// canónica → réplicas convergidas producen bytes idénticos (mismo VersionId, SC-002). +/// Deterministic export of the full state for content-addressing (P-III). Uses `all_updates` +/// (not `Snapshot`): the snapshot includes replica-dependent metadata (peer-ids, internal order) +/// and is NOT byte-deterministic across converged replicas; `all_updates` serializes the oplog +/// canonically → converged replicas produce identical bytes (same VersionId, SC-002). /// /// # Safety -/// Ver contrato del módulo. +/// See the module contract. #[no_mangle] pub unsafe extern "C" fn weft_loro_doc_export_state( doc: *mut LoroDoc, @@ -271,10 +271,10 @@ pub unsafe extern "C" fn weft_loro_doc_export_state( }) } -/// State vector del documento (VersionVector codificado). +/// State vector of the document (encoded VersionVector). /// /// # Safety -/// Ver contrato del módulo. +/// See the module contract. #[no_mangle] pub unsafe extern "C" fn weft_loro_doc_state_vector( doc: *mut LoroDoc, @@ -290,10 +290,10 @@ pub unsafe extern "C" fn weft_loro_doc_state_vector( }) } -/// Delta de cambios que el poseedor de `sv` no conoce. +/// Delta of changes the holder of `sv` does not know. /// /// # Safety -/// Ver contrato del módulo. +/// See the module contract. #[no_mangle] pub unsafe extern "C" fn weft_loro_doc_export_since( doc: *mut LoroDoc, @@ -321,10 +321,10 @@ pub unsafe extern "C" fn weft_loro_doc_export_since( }) } -/// Aplica un update/snapshot de otra réplica (convergente). +/// Applies an update/snapshot from another replica (convergent). /// /// # Safety -/// Ver contrato del módulo. +/// See the module contract. #[no_mangle] pub unsafe extern "C" fn weft_loro_doc_apply_update( doc: *mut LoroDoc, @@ -345,14 +345,14 @@ pub unsafe extern "C" fn weft_loro_doc_apply_update( }) } -// ── Versionado nativo (INativeVersioning, capacidad opcional — CHARTER-10/FU-006) ── +// ── Native versioning (INativeVersioning, optional capability — CHARTER-10/FU-006) ── // -// Probes DEMOSTRATIVOS de la capacidad de versionado nativo de Loro (diff/fork/shallow snapshot) que -// yrs no tiene. NO son content-addressing: su salida NO es byte-determinista entre réplicas (el shallow -// snapshot y los frontiers llevan metadata de réplica) y NO alimenta VersionId (que usa export_state / -// all_updates). Exhiben que Loro PUEDE versionar nativamente; el JSON se arma a mano (sin serde). +// DEMONSTRATIVE probes of Loro's native versioning capability (diff/fork/shallow snapshot) that +// yrs lacks. They are NOT content-addressing: their output is NOT byte-deterministic across replicas (the +// shallow snapshot and the frontiers carry replica metadata) and does NOT feed VersionId (which uses +// export_state / all_updates). They exhibit that Loro CAN version natively; the JSON is built by hand (no serde). -/// Escapa una cadena para incrustarla como valor JSON (comillas, backslash y controles). +/// Escapes a string to embed it as a JSON value (quotes, backslash and control chars). fn json_escape(s: &str) -> String { let mut out = String::with_capacity(s.len() + 2); for c in s.chars() { @@ -369,11 +369,11 @@ fn json_escape(s: &str) -> String { out } -/// Snapshot **shallow** (GC'd al estado actual) del documento. Bytes opacos, NO deterministas — -/// probe de la capacidad nativa, no un blob citable. Liberar con `weft_loro_buf_free`. +/// **Shallow** snapshot (GC'd to the current state) of the document. Opaque bytes, NOT deterministic — +/// a probe of the native capability, not a citable blob. Free with `weft_loro_buf_free`. /// /// # Safety -/// `out_ptr`/`out_len` escribibles no nulos; `doc` válido. +/// `out_ptr`/`out_len` non-null writable; `doc` valid. #[no_mangle] pub unsafe extern "C" fn weft_loro_shallow_snapshot( doc: *mut LoroDoc, @@ -392,11 +392,11 @@ pub unsafe extern "C" fn weft_loro_shallow_snapshot( }) } -/// Probe del **diff nativo**: describe (JSON) el diff de Loro entre frontiers vacíos y el estado -/// actual para el campo dado (nº de containers cambiados + longitud del texto). Demostrativo. +/// **Native diff** probe: describes (JSON) Loro's diff between empty frontiers and the current +/// state for the given field (number of changed containers + text length). Demonstrative. /// /// # Safety -/// Punteros válidos por sus longitudes; `doc` válido. +/// Pointers valid for their lengths; `doc` valid. #[no_mangle] pub unsafe extern "C" fn weft_loro_native_diff_probe( doc: *mut LoroDoc, @@ -429,11 +429,11 @@ pub unsafe extern "C" fn weft_loro_native_diff_probe( }) } -/// Probe de **fork/merge nativo**: forkea el doc, edita el fork, y lo mergea (import) en una copia -/// aparte — SIN mutar el `doc` del caller — reportando (JSON) si el merge convergió. Demostrativo. +/// **Native fork/merge** probe: forks the doc, edits the fork, and merges it (import) into a +/// separate copy — WITHOUT mutating the caller's `doc` — reporting (JSON) whether the merge converged. Demonstrative. /// /// # Safety -/// Punteros válidos por sus longitudes; `doc` válido. +/// Pointers valid for their lengths; `doc` valid. #[no_mangle] pub unsafe extern "C" fn weft_loro_native_branch_merge_probe( doc: *mut LoroDoc, @@ -451,7 +451,7 @@ pub unsafe extern "C" fn weft_loro_native_branch_merge_probe( }; doc.commit(); - // Branch: fork + edición local (marcador ① fuera del texto del caller). + // Branch: fork + local edit (marker ① outside the caller's text). const MARK: &str = "\u{2460}"; let branch = doc.fork(); if branch.get_text(field).insert_utf16(0, MARK).is_err() { @@ -459,7 +459,7 @@ pub unsafe extern "C" fn weft_loro_native_branch_merge_probe( } branch.commit(); - // Merge: importar la edición del branch en una copia independiente del doc (no toca al caller). + // Merge: import the branch's edit into an independent copy of the doc (does not touch the caller). let target = doc.fork(); let Ok(update) = branch.export(ExportMode::all_updates()) else { return WEFT_ERR_APPLY; @@ -479,10 +479,10 @@ pub unsafe extern "C" fn weft_loro_native_branch_merge_probe( }) } -// ── Memoria ── +// ── Memory ── /// # Safety -/// `ptr` debe ser null o provenir de una función de este shim, no liberado antes. +/// `ptr` must be null or come from a function of this shim, not freed before. #[no_mangle] pub unsafe extern "C" fn weft_loro_buf_free(ptr: *mut c_uchar, len: usize) { if ptr.is_null() { @@ -493,7 +493,7 @@ pub unsafe extern "C" fn weft_loro_buf_free(ptr: *mut c_uchar, len: usize) { })); } -// ── Diagnóstico ── +// ── Diagnostics ── #[no_mangle] pub extern "C" fn weft_loro_abi_version() -> u32 { @@ -502,9 +502,9 @@ pub extern "C" fn weft_loro_abi_version() -> u32 { // ── Test hooks ── -/// Provoca un panic deliberado para verificar catch_unwind (SC-009). +/// Triggers a deliberate panic to verify catch_unwind (SC-009). #[cfg(feature = "test-hooks")] #[no_mangle] pub extern "C" fn weft_loro_test_panic() -> i32 { - guard(|| panic!("weft_loro_test_panic: panic deliberado")) + guard(|| panic!("weft_loro_test_panic: deliberate panic")) } diff --git a/native/weft-loro-ffi/tests/mem_asan.rs b/native/weft-loro-ffi/tests/mem_asan.rs index 9a5ba4b..204c8e0 100644 --- a/native/weft-loro-ffi/tests/mem_asan.rs +++ b/native/weft-loro-ffi/tests/mem_asan.rs @@ -1,7 +1,7 @@ -//! Suite de integración del shim `weft-loro-ffi` (gate P-II, simétrica a weft-yrs-ffi): -//! round-trip, convergencia, rutas de error tipificadas y estrés de memoria (≥2000 iteraciones). +//! Integration suite for the `weft-loro-ffi` shim (P-II gate, symmetric to weft-yrs-ffi): +//! round-trip, convergence, typed error paths and memory stress (≥2000 iterations). //! -//! Gate de memoria: +//! Memory gate: //! ```bash //! RUSTFLAGS="-Zsanitizer=address" cargo +nightly test -p weft-loro-ffi --features test-hooks \ //! --target x86_64-unknown-linux-gnu @@ -178,11 +178,11 @@ fn stress_all_functions_2000_iterations() { } } -/// Siembra de peer_id (FU-016): reachability, guard del valor reservado y sin fugas (ASan/LSan). +/// peer_id seeding (FU-016): reachability, reserved-value guard and no leaks (ASan/LSan). #[test] fn seeded_peer_id_reachable_guarded_and_nonleaking() { unsafe { - // Camino feliz: peer_id fijo → doc válido, editable, liberable sin fugas. + // Happy path: fixed peer_id → valid doc, editable, freeable without leaks. let mut doc: *mut LoroDoc = ptr::null_mut(); assert_eq!(weft_loro_doc_new_with_peer_id(42, &mut doc), WEFT_OK); assert!(!doc.is_null()); @@ -193,7 +193,7 @@ fn seeded_peer_id_reachable_guarded_and_nonleaking() { ); weft_loro_doc_free(doc); - // Valor reservado (u64::MAX) → OUT_OF_BOUNDS, sin asignar doc (out queda intacto). + // Reserved value (u64::MAX) → OUT_OF_BOUNDS, without allocating a doc (out stays intact). let mut reserved: *mut LoroDoc = ptr::null_mut(); assert_eq!( weft_loro_doc_new_with_peer_id(u64::MAX, &mut reserved), @@ -201,7 +201,7 @@ fn seeded_peer_id_reachable_guarded_and_nonleaking() { ); assert!(reserved.is_null()); - // out_doc nulo → NULL_ARG. + // null out_doc → NULL_ARG. assert_eq!( weft_loro_doc_new_with_peer_id(1, ptr::null_mut()), WEFT_ERR_NULL_ARG @@ -209,7 +209,7 @@ fn seeded_peer_id_reachable_guarded_and_nonleaking() { } } -/// Probes nativos (INativeVersioning, FU-006): reachability + sin fugas (ASan) + sin mutar el caller. +/// Native probes (INativeVersioning, FU-006): reachability + no leaks (ASan) + no mutation of the caller. #[test] fn native_versioning_probes_reachable_and_nonleaking() { unsafe { @@ -220,7 +220,7 @@ fn native_versioning_probes_reachable_and_nonleaking() { assert_eq!(weft_loro_doc_new(&mut doc), WEFT_OK); weft_loro_text_insert(doc, field.as_ptr(), field.len(), 0, text.as_ptr(), text.len()); - // Shallow snapshot: no vacío, recargable. + // Shallow snapshot: non-empty, reloadable. let (mut sp, mut sl) = (ptr::null_mut(), 0usize); assert_eq!(weft_loro_shallow_snapshot(doc, &mut sp, &mut sl), WEFT_OK); assert!(sl > 0); @@ -228,12 +228,12 @@ fn native_versioning_probes_reachable_and_nonleaking() { assert_eq!( weft_loro_doc_load(sp, sl, &mut reloaded), WEFT_OK, - "el shallow snapshot debe ser recargable" + "the shallow snapshot must be reloadable" ); weft_loro_doc_free(reloaded); weft_loro_buf_free(sp, sl); - // Diff probe: JSON con el campo y el conteo. + // Diff probe: JSON with the field and the count. let (mut dp, mut dl) = (ptr::null_mut(), 0usize); assert_eq!( weft_loro_native_diff_probe(doc, field.as_ptr(), field.len(), &mut dp, &mut dl), @@ -243,21 +243,21 @@ fn native_versioning_probes_reachable_and_nonleaking() { assert!(diff_json.contains("\"containers_changed\"")); weft_loro_buf_free(dp, dl); - // Branch/merge probe: reporta convergencia y NO muta el doc del caller. + // Branch/merge probe: reports convergence and does NOT mutate the caller's doc. let (mut bp, mut bl) = (ptr::null_mut(), 0usize); assert_eq!( weft_loro_native_branch_merge_probe(doc, field.as_ptr(), field.len(), &mut bp, &mut bl), WEFT_OK ); let branch_json = std::str::from_utf8(std::slice::from_raw_parts(bp, bl)).unwrap(); - assert!(branch_json.contains("\"converged\":true"), "el merge nativo debe converger"); + assert!(branch_json.contains("\"converged\":true"), "the native merge must converge"); weft_loro_buf_free(bp, bl); - // El texto del caller sigue intacto (el probe forkea, no muta el original). + // The caller's text remains intact (the probe forks, does not mutate the original). let (mut rp, mut rl) = (ptr::null_mut(), 0usize); weft_loro_text_read(doc, field.as_ptr(), field.len(), &mut rp, &mut rl); let caller_text = std::str::from_utf8(std::slice::from_raw_parts(rp, rl)).unwrap(); - assert_eq!(caller_text, "hola", "el probe no debe mutar el doc del caller"); + assert_eq!(caller_text, "hola", "the probe must not mutate the caller's doc"); weft_loro_buf_free(rp, rl); weft_loro_doc_free(doc); diff --git a/native/weft-yrs-ffi/fuzz/fuzz_targets/apply_update.rs b/native/weft-yrs-ffi/fuzz/fuzz_targets/apply_update.rs index 5e956c4..ab02842 100644 --- a/native/weft-yrs-ffi/fuzz/fuzz_targets/apply_update.rs +++ b/native/weft-yrs-ffi/fuzz/fuzz_targets/apply_update.rs @@ -1,6 +1,6 @@ -//! Fuzz target: `weft_doc_apply_update` con bytes arbitrarios sobre un doc vivo (research R14). -//! Invariante: ningún input cruza la frontera como panic ni UB — el shim los contiene como -//! códigos de error. Un SIGSEGV/UB real sigue abortando y se detecta. +//! Fuzz target: `weft_doc_apply_update` with arbitrary bytes over a live doc (research R14). +//! Invariant: no input crosses the boundary as panic or UB — the shim contains them as +//! error codes. A real SIGSEGV/UB still aborts and is detected. #![no_main] use std::ptr; @@ -13,8 +13,8 @@ use yrs::Doc; static INIT: Once = Once::new(); fuzz_target!(|data: &[u8]| { - // Ver doc_load.rs: silenciamos el hook de libfuzzer-sys que aborta en panic, para ejercitar - // el catch_unwind del shim como en producción. Un SIGSEGV/UB real sigue detectándose. + // See doc_load.rs: we silence libfuzzer-sys's hook that aborts on panic, to exercise + // the shim's catch_unwind as in production. A real SIGSEGV/UB is still detected. INIT.call_once(|| std::panic::set_hook(Box::new(|_| {}))); unsafe { @@ -22,7 +22,7 @@ fuzz_target!(|data: &[u8]| { if weft_doc_new(&mut doc) != WEFT_OK || doc.is_null() { return; } - // El código de retorno es irrelevante para el fuzzer; lo que importa es que no haya UB. + // The return code is irrelevant to the fuzzer; what matters is that there is no UB. let _ = weft_doc_apply_update(doc, data.as_ptr(), data.len()); weft_doc_free(doc); } diff --git a/native/weft-yrs-ffi/fuzz/fuzz_targets/doc_load.rs b/native/weft-yrs-ffi/fuzz/fuzz_targets/doc_load.rs index d2f67b9..c5d9dc4 100644 --- a/native/weft-yrs-ffi/fuzz/fuzz_targets/doc_load.rs +++ b/native/weft-yrs-ffi/fuzz/fuzz_targets/doc_load.rs @@ -1,7 +1,7 @@ -//! Fuzz target: `weft_doc_load` con bytes arbitrarios (research R14). -//! Invariante: ningún input cruza la frontera como panic ni UB — el shim los contiene como -//! códigos de error (`catch_unwind` → WEFT_ERR_PANIC) o los rechaza (WEFT_ERR_DECODE). Un -//! SIGSEGV/UB real sigue abortando y se detecta. +//! Fuzz target: `weft_doc_load` with arbitrary bytes (research R14). +//! Invariant: no input crosses the boundary as panic or UB — the shim contains them as +//! error codes (`catch_unwind` → WEFT_ERR_PANIC) or rejects them (WEFT_ERR_DECODE). A +//! real SIGSEGV/UB still aborts and is detected. #![no_main] use std::ptr; @@ -14,16 +14,16 @@ use yrs::Doc; static INIT: Once = Once::new(); fuzz_target!(|data: &[u8]| { - // libfuzzer-sys instala un panic hook que aborta en el sitio del panic, ANTES de que el - // unwinding llegue al `catch_unwind` del shim. Lo silenciamos para ejercitar el mismo modo - // que producción (panic del motor → WEFT_ERR_PANIC contenido en la frontera). Un fallo de - // memoria real (SIGSEGV) no pasa por el hook y sigue detectándose. + // libfuzzer-sys installs a panic hook that aborts at the panic site, BEFORE the + // unwinding reaches the shim's `catch_unwind`. We silence it to exercise the same mode + // as production (engine panic → WEFT_ERR_PANIC contained at the boundary). A real + // memory fault (SIGSEGV) does not go through the hook and is still detected. INIT.call_once(|| std::panic::set_hook(Box::new(|_| {}))); unsafe { let mut doc: *mut Doc = ptr::null_mut(); let code = weft_doc_load(data.as_ptr(), data.len(), &mut doc); - // En éxito el shim entregó un doc: liberarlo (ASan detectaría fugas si no). + // On success the shim handed out a doc: free it (ASan would detect leaks otherwise). if code == WEFT_OK && !doc.is_null() { weft_doc_free(doc); } diff --git a/native/weft-yrs-ffi/fuzz/fuzz_targets/export_since.rs b/native/weft-yrs-ffi/fuzz/fuzz_targets/export_since.rs index 7f9613a..3b6985f 100644 --- a/native/weft-yrs-ffi/fuzz/fuzz_targets/export_since.rs +++ b/native/weft-yrs-ffi/fuzz/fuzz_targets/export_since.rs @@ -1,16 +1,16 @@ -//! Fuzz target: `weft_doc_export_since` con un state vector arbitrario (regresión R6). +//! Fuzz target: `weft_doc_export_since` with an arbitrary state vector (R6 regression). //! -//! Ejercita la ruta RESIDUAL `state_vector::decode` (`yrs/src/state_vector.rs:120`, -//! `HashMap::with_capacity(len)` sin acotar) — la única brecha de amplificación de memoria de -//! yrs que NO cubre el `try_reserve` ya presente en `Update::decode`. `weft_doc_export_since` -//! decodifica el SV crudo vía `StateVector::decode_v1` antes de calcular el delta, así que un SV -//! adversarial (`[255,255,255,122]`: 4 bytes que declaran una longitud gigante) llega directo al -//! sitio residual. +//! Exercises the RESIDUAL path `state_vector::decode` (`yrs/src/state_vector.rs:120`, +//! `HashMap::with_capacity(len)` unbounded) — the only memory-amplification gap in +//! yrs NOT covered by the `try_reserve` already present in `Update::decode`. `weft_doc_export_since` +//! decodes the raw SV via `StateVector::decode_v1` before computing the delta, so an adversarial +//! SV (`[255,255,255,122]`: 4 bytes declaring a gigantic length) reaches the residual site +//! directly. //! -//! Invariante: ningún input cruza la frontera como panic ni UB — el shim lo contiene como código -//! de error (`WEFT_ERR_DECODE` en glibc por overcommit; el `abort` de `handle_alloc_error` solo en -//! hosts memory-constrained duros / allocators eager). Informativo hasta que se adopte el fix -//! upstream (`try_reserve`, FU-015); prueba la regresión cuando el bump aterrice. +//! Invariant: no input crosses the boundary as panic or UB — the shim contains it as an error +//! code (`WEFT_ERR_DECODE` on glibc via overcommit; the `abort` of `handle_alloc_error` only on +//! hard memory-constrained hosts / eager allocators). Informative until the upstream fix +//! (`try_reserve`, FU-015) is adopted; it tests the regression once the bump lands. #![no_main] use std::ptr; @@ -23,8 +23,8 @@ use yrs::Doc; static INIT: Once = Once::new(); fuzz_target!(|data: &[u8]| { - // Ver doc_load.rs: silenciamos el hook de libfuzzer-sys que aborta en panic, para ejercitar - // el catch_unwind del shim como en producción. Un SIGSEGV/UB real sigue detectándose. + // See doc_load.rs: we silence libfuzzer-sys's hook that aborts on panic, to exercise + // the shim's catch_unwind as in production. A real SIGSEGV/UB is still detected. INIT.call_once(|| std::panic::set_hook(Box::new(|_| {}))); unsafe { @@ -34,11 +34,11 @@ fuzz_target!(|data: &[u8]| { } let mut out_ptr: *mut u8 = ptr::null_mut(); let mut out_len: usize = 0; - // `data` es el state vector crudo → `StateVector::decode_v1` (ruta residual R6). El código - // de retorno es irrelevante para el fuzzer; lo que importa es que no haya UB. + // `data` is the raw state vector → `StateVector::decode_v1` (residual path R6). The return + // code is irrelevant to the fuzzer; what matters is that there is no UB. let code = weft_doc_export_since(doc, data.as_ptr(), data.len(), &mut out_ptr, &mut out_len); - // En éxito el shim entregó un buffer nativo: liberarlo con weft_buf_free (ASan detectaría - // fugas si no; el GC jamás toca esta memoria). + // On success the shim handed out a native buffer: free it with weft_buf_free (ASan would + // detect leaks otherwise; the GC never touches this memory). if code == WEFT_OK && !out_ptr.is_null() { weft_buf_free(out_ptr, out_len); } diff --git a/native/weft-yrs-ffi/include/weft_ffi.h b/native/weft-yrs-ffi/include/weft_ffi.h index e05a2d5..25e32db 100644 --- a/native/weft-yrs-ffi/include/weft_ffi.h +++ b/native/weft-yrs-ffi/include/weft_ffi.h @@ -1,29 +1,29 @@ /* - * weft_ffi.h — contrato C-ABI del shim `weft-yrs-ffi` (Weft, Apache-2.0). + * weft_ffi.h — C-ABI contract of the `weft-yrs-ffi` shim (Weft, Apache-2.0). * - * Fuente de verdad del contrato de ownership. `HeaderBindingParityTests` valida que las - * declaraciones `[LibraryImport]` de Weft.Core coinciden con este header (paridad sintáctica: - * conjunto de funciones, aridad, orden y tipos). Este header se mantiene A MANO: no hay csbindgen - * en el repo — la mención en research R1 era una verificación cruzada opcional que nunca se - * implementó. La ABI es propia y estable: un bump de `yrs` cambia lib.rs, jamás este header sin - * incrementar weft_abi_version(). + * Source of truth for the ownership contract. `HeaderBindingParityTests` validates that the + * `[LibraryImport]` declarations of Weft.Core match this header (syntactic parity: + * function set, arity, order and types). This header is maintained BY HAND: there is no csbindgen + * in the repo — the mention in research R1 was an optional cross-check that was never + * implemented. The ABI is owned and stable: a `yrs` bump changes lib.rs, never this header without + * incrementing weft_abi_version(). * - * ── Reglas transversales (no negociables) ────────────────────────────────────────────── - * 1. Panics: cada función envuelve su cuerpo en catch_unwind; un panic retorna - * WEFT_ERR_PANIC, jamás cruza la frontera (sería UB). - * 2. Ownership de buffers: - * - Salida (out_ptr/out_len): asignados por el shim (Box<[u8]>); el llamador los libera - * SOLO con weft_buf_free(ptr, len), exactamente una vez, nunca con el GC/Marshal. - * - Entrada (ptr+len): prestados; el shim no toma posesión ni retiene el puntero. - * - WeftDoc*: se libera SOLO con weft_doc_free, exactamente una vez. - * 3. Thread-safety: NINGUNA función que reciba WeftDoc* es thread-safe respecto al mismo doc - * (yrs no es Send+Sync). El llamador serializa por documento. weft_buf_free es thread-safe. - * 4. Strings: entradas de texto son UTF-8 (ptr+len, sin NUL); UTF-8 inválido -> WEFT_ERR_UTF8. - * 5. Índices: uint32_t en UTF-16 code units (semántica de yrs); fuera de rango -> + * ── Cross-cutting rules (non-negotiable) ────────────────────────────────────────────── + * 1. Panics: each function wraps its body in catch_unwind; a panic returns + * WEFT_ERR_PANIC, never crosses the boundary (would be UB). + * 2. Buffer ownership: + * - Output (out_ptr/out_len): allocated by the shim (Box<[u8]>); the caller frees them + * ONLY with weft_buf_free(ptr, len), exactly once, never with the GC/Marshal. + * - Input (ptr+len): borrowed; the shim takes no ownership nor retains the pointer. + * - WeftDoc*: freed ONLY with weft_doc_free, exactly once. + * 3. Thread-safety: NO function taking WeftDoc* is thread-safe with respect to the same doc + * (yrs is not Send+Sync). The caller serializes per document. weft_buf_free is thread-safe. + * 4. Strings: text inputs are UTF-8 (ptr+len, no NUL); invalid UTF-8 -> WEFT_ERR_UTF8. + * 5. Indices: uint32_t in UTF-16 code units (yrs semantics); out of range -> * WEFT_ERR_OUT_OF_BOUNDS. * - * Postcondiciones: en error, los out-params quedan sin escribir (el llamador no libera nada); - * en éxito con contenido vacío, out_ptr puede ser válido con out_len == 0 (liberar igual). + * Postconditions: on error, the out-params are left unwritten (the caller frees nothing); + * on success with empty content, out_ptr may be valid with out_len == 0 (free it anyway). */ #ifndef WEFT_FFI_H #define WEFT_FFI_H @@ -35,27 +35,27 @@ extern "C" { #endif -/* Puntero opaco al documento CRDT. Se libera SOLO con weft_doc_free. */ +/* Opaque pointer to the CRDT document. Freed ONLY with weft_doc_free. */ typedef struct WeftDoc WeftDoc; -/* ── Códigos de estado (int32_t) ──────────────────────────────────────────────────────── */ +/* ── Status codes (int32_t) ──────────────────────────────────────────────────────── */ #define WEFT_OK 0 -#define WEFT_ERR_NULL_ARG -1 /* puntero requerido nulo */ -#define WEFT_ERR_DECODE -2 /* blob/update no decodificable */ -#define WEFT_ERR_APPLY -3 /* fallo aplicando update */ -#define WEFT_ERR_UTF8 -4 /* texto de entrada no UTF-8 */ -#define WEFT_ERR_OUT_OF_BOUNDS -5 /* índice/longitud fuera de rango */ -#define WEFT_ERR_PANIC -127 /* panic capturado en el shim */ +#define WEFT_ERR_NULL_ARG -1 /* required pointer null */ +#define WEFT_ERR_DECODE -2 /* blob/update not decodable */ +#define WEFT_ERR_APPLY -3 /* failure applying update */ +#define WEFT_ERR_UTF8 -4 /* input text not UTF-8 */ +#define WEFT_ERR_OUT_OF_BOUNDS -5 /* index/length out of range */ +#define WEFT_ERR_PANIC -127 /* panic caught in the shim */ -/* ── Ciclo de vida del documento ──────────────────────────────────────────────────────── */ +/* ── Document lifecycle ──────────────────────────────────────────────────────── */ int32_t weft_doc_new(WeftDoc** out_doc); -/* Doc nuevo con client_id FIJO (siembra determinista, FU-012). client_id debe caber en 53 bits - * (encoding de yrs 0.26+): client_id >= 2^53 -> WEFT_ERR_OUT_OF_BOUNDS. ABI v2. */ +/* New doc with FIXED client_id (deterministic seeding, FU-012). client_id must fit in 53 bits + * (yrs 0.26+ encoding): client_id >= 2^53 -> WEFT_ERR_OUT_OF_BOUNDS. ABI v2. */ int32_t weft_doc_new_with_client_id(uint64_t client_id, WeftDoc** out_doc); int32_t weft_doc_load(const uint8_t* blob, size_t blob_len, WeftDoc** out_doc); void weft_doc_free(WeftDoc* doc); -/* ── Texto por campo nombrado ─────────────────────────────────────────────────────────── */ +/* ── Text by named field ─────────────────────────────────────────────────────────── */ int32_t weft_text_insert(WeftDoc* doc, const uint8_t* field, size_t field_len, uint32_t index, const uint8_t* text, size_t text_len); int32_t weft_text_delete(WeftDoc* doc, const uint8_t* field, size_t field_len, @@ -63,25 +63,25 @@ int32_t weft_text_delete(WeftDoc* doc, const uint8_t* field, size_t field_len, int32_t weft_text_read(WeftDoc* doc, const uint8_t* field, size_t field_len, uint8_t** out_ptr, size_t* out_len); -/* ── Estado y sincronización ──────────────────────────────────────────────────────────── */ +/* ── State and synchronization ──────────────────────────────────────────────────────────── */ int32_t weft_doc_export_state(WeftDoc* doc, uint8_t** out_ptr, size_t* out_len); int32_t weft_doc_state_vector(WeftDoc* doc, uint8_t** out_ptr, size_t* out_len); int32_t weft_doc_export_since(WeftDoc* doc, const uint8_t* sv, size_t sv_len, uint8_t** out_ptr, size_t* out_len); int32_t weft_doc_apply_update(WeftDoc* doc, const uint8_t* update, size_t update_len); -/* ── Memoria ──────────────────────────────────────────────────────────────────────────── */ +/* ── Memory ──────────────────────────────────────────────────────────────────────────── */ void weft_buf_free(uint8_t* ptr, size_t len); -/* ── Diagnóstico ──────────────────────────────────────────────────────────────────────── */ +/* ── Diagnostics ──────────────────────────────────────────────────────────────────────── */ uint32_t weft_abi_version(void); -/* ── Test hooks (SOLO en builds con la feature de Cargo `test-hooks`) ───────────────────── - * Provoca un panic interno deliberado para verificar catch_unwind end-to-end (SC-009). - * NUNCA presente en binarios de release: el job `native` de release.yml verifica con `nm` que el - * símbolo no está exportado en los cdylibs antes de empaquetarlos. */ +/* ── Test hooks (ONLY in builds with the Cargo `test-hooks` feature) ───────────────────── + * Triggers a deliberate internal panic to verify catch_unwind end-to-end (SC-009). + * NEVER present in release binaries: the `native` job in release.yml verifies with `nm` that the + * symbol is not exported in the cdylibs before packaging them. */ #ifdef WEFT_TEST_HOOKS -int32_t weft_test_panic(void); /* retorna WEFT_ERR_PANIC si el shim es correcto */ +int32_t weft_test_panic(void); /* returns WEFT_ERR_PANIC if the shim is correct */ #endif #ifdef __cplusplus diff --git a/native/weft-yrs-ffi/src/lib.rs b/native/weft-yrs-ffi/src/lib.rs index d2f2444..15433ee 100644 --- a/native/weft-yrs-ffi/src/lib.rs +++ b/native/weft-yrs-ffi/src/lib.rs @@ -1,24 +1,24 @@ -//! # weft-yrs-ffi — shim C-ABI propio de Weft sobre `yrs` +//! # weft-yrs-ffi — Weft's own C-ABI shim over `yrs` //! -//! ABI estable y propia (constitución P-I): la superficie es nuestra; un bump de `yrs` cambia -//! este archivo, jamás la ABI. Consumido por `Weft.Core` vía P/Invoke. Contrato completo en -//! `include/weft_ffi.h` y `specs/001-weft-crdt-versioning/contracts/ffi-abi.md`. +//! Stable, owned ABI (constitution P-I): the surface is ours; a `yrs` bump changes +//! this file, never the ABI. Consumed by `Weft.Core` via P/Invoke. Full contract in +//! `include/weft_ffi.h` and `specs/001-weft-crdt-versioning/contracts/ffi-abi.md`. //! -//! ## Contrato de ownership (LEER ANTES DE CONSUMIR) +//! ## Ownership contract (READ BEFORE CONSUMING) //! -//! * **`WeftDoc*`** (aquí `*mut Doc`): lo crea `weft_doc_new`/`weft_doc_load` y SOLO se libera -//! con `weft_doc_free`, exactamente una vez. Nunca con el GC/`Marshal` de .NET (sería UB). -//! * **Buffers de salida** (`out_ptr`/`out_len`): los asigna el shim como `Box<[u8]>`; el -//! llamador los libera SOLO con `weft_buf_free(ptr, len)` con los mismos ptr/len. -//! * **Buffers de entrada** (`ptr`+`len`): prestados; el shim los lee durante la llamada y no -//! toma posesión ni retiene el puntero tras el retorno. +//! * **`WeftDoc*`** (here `*mut Doc`): created by `weft_doc_new`/`weft_doc_load` and freed ONLY +//! with `weft_doc_free`, exactly once. Never with .NET's GC/`Marshal` (would be UB). +//! * **Output buffers** (`out_ptr`/`out_len`): allocated by the shim as `Box<[u8]>`; the +//! caller frees them ONLY with `weft_buf_free(ptr, len)` using the same ptr/len. +//! * **Input buffers** (`ptr`+`len`): borrowed; the shim reads them during the call and does +//! not take ownership nor retain the pointer after return. //! -//! ## Reglas transversales -//! * **Panics**: cada cuerpo va envuelto en `catch_unwind`; un panic retorna `WEFT_ERR_PANIC`, -//! jamás cruza la frontera C (sería UB). -//! * **Thread-safety**: ninguna función que reciba `WeftDoc*` es thread-safe respecto al mismo -//! doc (`yrs` no es Send+Sync). El llamador serializa por documento. `weft_buf_free` sí lo es. -//! * **Índices**: `u32` en UTF-16 code units (semántica de yrs); fuera de rango → +//! ## Cross-cutting rules +//! * **Panics**: every body is wrapped in `catch_unwind`; a panic returns `WEFT_ERR_PANIC`, +//! never crosses the C boundary (would be UB). +//! * **Thread-safety**: no function taking `WeftDoc*` is thread-safe with respect to the same +//! doc (`yrs` is not Send+Sync). The caller serializes per document. `weft_buf_free` is. +//! * **Indices**: `u32` in UTF-16 code units (yrs semantics); out of range → //! `WEFT_ERR_OUT_OF_BOUNDS`. use std::os::raw::c_uchar; @@ -28,9 +28,9 @@ use yrs::updates::decoder::Decode; use yrs::updates::encoder::Encode; use yrs::{ClientID, Doc, GetString, OffsetKind, Options, ReadTxn, StateVector, Text, Transact, Update}; -/// Crea un `Doc` con índices en **UTF-16 code units** (no el default de yrs, que es bytes UTF-8). -/// Consistente con `string` de .NET y con Yjs (clientes de editor); crítico para que -/// insert/delete por índice sean correctos con texto no-ASCII. +/// Creates a `Doc` with indices in **UTF-16 code units** (not yrs's default, which is UTF-8 bytes). +/// Consistent with .NET `string` and with Yjs (editor clients); critical so that +/// index-based insert/delete are correct with non-ASCII text. fn new_doc() -> Doc { let opts = Options { offset_kind: OffsetKind::Utf16, @@ -39,11 +39,11 @@ fn new_doc() -> Doc { Doc::with_options(opts) } -/// Como [`new_doc`] pero con un `client_id` FIJO (siembra determinista para paridad -/// cross-implementación; FU-012/CHARTER-09). Mismo `OffsetKind::Utf16`. +/// Like [`new_doc`] but with a FIXED `client_id` (deterministic seeding for cross-implementation +/// parity; FU-012/CHARTER-09). Same `OffsetKind::Utf16`. fn new_doc_with_client_id(client_id: u64) -> Doc { - // El guard `< 2^53` en la frontera (CLIENT_ID_MAX_EXCLUSIVE) garantiza que `ClientID::new` - // no dispare su `debug_assert!(value & MASK == 0)` ni corrompa el id en release. + // The `< 2^53` guard at the boundary (CLIENT_ID_MAX_EXCLUSIVE) ensures `ClientID::new` + // neither trips its `debug_assert!(value & MASK == 0)` nor corrupts the id in release. let opts = Options { client_id: ClientID::new(client_id), offset_kind: OffsetKind::Utf16, @@ -52,11 +52,11 @@ fn new_doc_with_client_id(client_id: u64) -> Doc { Doc::with_options(opts) } -/// Cota superior (exclusiva) del `client_id`: yrs 0.26+ codifica los client IDs en **53 bits** -/// (antes 64). Un id `>= 2^53` no round-trippea por el encoding → se rechaza en la frontera. +/// Upper bound (exclusive) for `client_id`: yrs 0.26+ encodes client IDs in **53 bits** +/// (formerly 64). An id `>= 2^53` does not round-trip through the encoding → rejected at the boundary. const CLIENT_ID_MAX_EXCLUSIVE: u64 = 1 << 53; -// ── Códigos de estado (deben coincidir con weft_ffi.h y el mapeo de excepciones en C#) ── +// ── Status codes (must match weft_ffi.h and the exception mapping in C#) ── pub const WEFT_OK: i32 = 0; pub const WEFT_ERR_NULL_ARG: i32 = -1; pub const WEFT_ERR_DECODE: i32 = -2; @@ -65,13 +65,13 @@ pub const WEFT_ERR_UTF8: i32 = -4; pub const WEFT_ERR_OUT_OF_BOUNDS: i32 = -5; pub const WEFT_ERR_PANIC: i32 = -127; -/// Versión de la ABI. Se incrementa ante CUALQUIER cambio de firma o semántica; `Weft.Core` la -/// verifica al cargar el cdylib y lanza si no coincide con la esperada. +/// ABI version. Incremented on ANY signature or semantic change; `Weft.Core` +/// checks it when loading the cdylib and throws if it does not match the expected one. const WEFT_ABI_VERSION: u32 = 2; -// ── Helpers internos (no expuestos por la C-ABI) ──────────────────────────────────────────── +// ── Internal helpers (not exposed by the C-ABI) ──────────────────────────────────────────── -/// Envuelve el cuerpo de una fn FFI para que ningún panic cruce la frontera C. +/// Wraps the body of an FFI fn so that no panic crosses the C boundary. fn guard i32>(f: F) -> i32 { match catch_unwind(AssertUnwindSafe(f)) { Ok(code) => code, @@ -79,10 +79,10 @@ fn guard i32>(f: F) -> i32 { } } -/// Reconstruye un `&str` prestado desde (ptr, len). None si ptr es null o no es UTF-8. +/// Reconstructs a borrowed `&str` from (ptr, len). None if ptr is null or not UTF-8. /// /// # Safety -/// `ptr` debe apuntar a `len` bytes válidos y vivos durante la llamada. +/// `ptr` must point to `len` valid bytes that stay alive during the call. unsafe fn borrow_str<'a>(ptr: *const c_uchar, len: usize) -> Option<&'a str> { if ptr.is_null() && len != 0 { return None; @@ -95,10 +95,10 @@ unsafe fn borrow_str<'a>(ptr: *const c_uchar, len: usize) -> Option<&'a str> { std::str::from_utf8(bytes).ok() } -/// Reconstruye un `&[u8]` prestado desde (ptr, len). None solo si ptr es null con len != 0. +/// Reconstructs a borrowed `&[u8]` from (ptr, len). None only if ptr is null with len != 0. /// /// # Safety -/// `ptr` debe apuntar a `len` bytes válidos y vivos durante la llamada. +/// `ptr` must point to `len` valid bytes that stay alive during the call. unsafe fn borrow_bytes<'a>(ptr: *const c_uchar, len: usize) -> Option<&'a [u8]> { if ptr.is_null() { return if len == 0 { Some(&[]) } else { None }; @@ -106,29 +106,29 @@ unsafe fn borrow_bytes<'a>(ptr: *const c_uchar, len: usize) -> Option<&'a [u8]> Some(std::slice::from_raw_parts(ptr, len)) } -/// Entrega un `Vec` a través de out-params, cediendo su posesión al llamador. -/// La memoria se recupera con `weft_buf_free(ptr, len)`. +/// Hands out a `Vec` through out-params, transferring its ownership to the caller. +/// The memory is reclaimed with `weft_buf_free(ptr, len)`. /// /// # Safety -/// `out_ptr` y `out_len` deben ser punteros escribibles no nulos. +/// `out_ptr` and `out_len` must be non-null writable pointers. unsafe fn hand_out_buffer(data: Vec, out_ptr: *mut *mut c_uchar, out_len: *mut usize) -> i32 { if out_ptr.is_null() || out_len.is_null() { return WEFT_ERR_NULL_ARG; } - // Box<[u8]>: ptr+len bastan para reconstruir y liberar sin conocer la capacity. + // Box<[u8]>: ptr+len are enough to reconstruct and free without knowing the capacity. let mut boxed = data.into_boxed_slice(); let len = boxed.len(); let ptr = boxed.as_mut_ptr(); - std::mem::forget(boxed); // se recupera en weft_buf_free + std::mem::forget(boxed); // reclaimed in weft_buf_free *out_ptr = ptr; *out_len = len; WEFT_OK } -/// `*mut Doc` → `&Doc` prestado (sin tomar posesión). +/// `*mut Doc` → borrowed `&Doc` (without taking ownership). /// /// # Safety -/// `doc` debe provenir de `weft_doc_new`/`weft_doc_load` y no haber sido liberado. +/// `doc` must come from `weft_doc_new`/`weft_doc_load` and must not have been freed. unsafe fn doc_ref<'a>(doc: *mut Doc) -> Option<&'a Doc> { if doc.is_null() { None @@ -137,13 +137,13 @@ unsafe fn doc_ref<'a>(doc: *mut Doc) -> Option<&'a Doc> { } } -// ── Ciclo de vida del documento ───────────────────────────────────────────────────────────── +// ── Document lifecycle ───────────────────────────────────────────────────────────── -/// Crea un documento CRDT nuevo y vacío (GC del motor SIEMPRE activo). Escribe el puntero opaco -/// en `out_doc`. Liberar SOLO con `weft_doc_free`. +/// Creates a new, empty CRDT document (engine GC ALWAYS on). Writes the opaque pointer +/// into `out_doc`. Free ONLY with `weft_doc_free`. /// /// # Safety -/// `out_doc` debe ser un puntero escribible no nulo. +/// `out_doc` must be a non-null writable pointer. #[no_mangle] pub unsafe extern "C" fn weft_doc_new(out_doc: *mut *mut Doc) -> i32 { guard(|| { @@ -155,13 +155,13 @@ pub unsafe extern "C" fn weft_doc_new(out_doc: *mut *mut Doc) -> i32 { }) } -/// Crea un documento CRDT nuevo con un `client_id` **fijo** (siembra determinista para paridad -/// cross-implementación con Yjs; FU-012). Idéntico a [`weft_doc_new`] salvo por el id controlado. -/// `client_id` debe caber en **53 bits** (encoding de yrs 0.26+): `client_id >= 2^53` → -/// `WEFT_ERR_OUT_OF_BOUNDS`. Liberar SOLO con `weft_doc_free`. +/// Creates a new CRDT document with a **fixed** `client_id` (deterministic seeding for +/// cross-implementation parity with Yjs; FU-012). Identical to [`weft_doc_new`] except for the controlled id. +/// `client_id` must fit in **53 bits** (yrs 0.26+ encoding): `client_id >= 2^53` → +/// `WEFT_ERR_OUT_OF_BOUNDS`. Free ONLY with `weft_doc_free`. /// /// # Safety -/// `out_doc` debe ser un puntero escribible no nulo. +/// `out_doc` must be a non-null writable pointer. #[no_mangle] pub unsafe extern "C" fn weft_doc_new_with_client_id(client_id: u64, out_doc: *mut *mut Doc) -> i32 { guard(|| { @@ -176,10 +176,10 @@ pub unsafe extern "C" fn weft_doc_new_with_client_id(client_id: u64, out_doc: *m }) } -/// Reconstruye un documento desde un blob exportado (update v1). Escribe el puntero en `out_doc`. +/// Reconstructs a document from an exported blob (update v1). Writes the pointer into `out_doc`. /// /// # Safety -/// `blob` debe apuntar a `blob_len` bytes válidos; `out_doc` escribible no nulo. +/// `blob` must point to `blob_len` valid bytes; `out_doc` non-null writable. #[no_mangle] pub unsafe extern "C" fn weft_doc_load( blob: *const c_uchar, @@ -209,10 +209,10 @@ pub unsafe extern "C" fn weft_doc_load( }) } -/// Libera un documento. NULL es no-op. Idempotencia NO garantizada: llamar exactamente una vez. +/// Frees a document. NULL is a no-op. Idempotency NOT guaranteed: call exactly once. /// /// # Safety -/// `doc` debe ser null o un puntero válido de `weft_doc_new`/`weft_doc_load` no liberado antes. +/// `doc` must be null or a valid pointer from `weft_doc_new`/`weft_doc_load` not freed before. #[no_mangle] pub unsafe extern "C" fn weft_doc_free(doc: *mut Doc) { if doc.is_null() { @@ -223,12 +223,12 @@ pub unsafe extern "C" fn weft_doc_free(doc: *mut Doc) { })); } -// ── Texto por campo nombrado ──────────────────────────────────────────────────────────────── +// ── Text by named field ──────────────────────────────────────────────────────────────── -/// Inserta `text` (UTF-8) en el `Text` raíz `field`, en la posición `index` (UTF-16 units). +/// Inserts `text` (UTF-8) into the root `Text` `field`, at position `index` (UTF-16 units). /// /// # Safety -/// Punteros válidos por sus longitudes; `doc` válido. +/// Pointers valid for their lengths; `doc` valid. #[no_mangle] pub unsafe extern "C" fn weft_text_insert( doc: *mut Doc, @@ -256,10 +256,10 @@ pub unsafe extern "C" fn weft_text_insert( }) } -/// Borra `len` unidades desde `index` en el `Text` raíz `field`. +/// Deletes `len` units starting at `index` in the root `Text` `field`. /// /// # Safety -/// Punteros válidos por sus longitudes; `doc` válido. +/// Pointers valid for their lengths; `doc` valid. #[no_mangle] pub unsafe extern "C" fn weft_text_delete( doc: *mut Doc, @@ -286,11 +286,11 @@ pub unsafe extern "C" fn weft_text_delete( }) } -/// Lee el `Text` raíz `field` completo como UTF-8 en un buffer de salida (liberar con +/// Reads the entire root `Text` `field` as UTF-8 into an output buffer (free with /// `weft_buf_free`). /// /// # Safety -/// Ver contrato del módulo. +/// See the module contract. #[no_mangle] pub unsafe extern "C" fn weft_text_read( doc: *mut Doc, @@ -313,13 +313,13 @@ pub unsafe extern "C" fn weft_text_read( }) } -// ── Estado y sincronización ───────────────────────────────────────────────────────────────── +// ── State and synchronization ───────────────────────────────────────────────────────────────── -/// Export determinista del estado completo (update v1 vs state-vector vacío). Base del +/// Deterministic export of the full state (update v1 vs empty state-vector). Basis of /// content-addressing (P-III). /// /// # Safety -/// Ver contrato del módulo. +/// See the module contract. #[no_mangle] pub unsafe extern "C" fn weft_doc_export_state( doc: *mut Doc, @@ -337,10 +337,10 @@ pub unsafe extern "C" fn weft_doc_export_state( }) } -/// State vector del documento (resumen "qué conozco" para sync incremental). +/// State vector of the document ("what I know" summary for incremental sync). /// /// # Safety -/// Ver contrato del módulo. +/// See the module contract. #[no_mangle] pub unsafe extern "C" fn weft_doc_state_vector( doc: *mut Doc, @@ -356,10 +356,10 @@ pub unsafe extern "C" fn weft_doc_state_vector( }) } -/// Delta de cambios que el poseedor de `sv` (state vector v1) no conoce. +/// Delta of changes the holder of `sv` (state vector v1) does not know. /// /// # Safety -/// Ver contrato del módulo. +/// See the module contract. #[no_mangle] pub unsafe extern "C" fn weft_doc_export_since( doc: *mut Doc, @@ -384,10 +384,10 @@ pub unsafe extern "C" fn weft_doc_export_since( }) } -/// Aplica un update/estado de otra réplica (convergente). +/// Applies an update/state from another replica (convergent). /// /// # Safety -/// Ver contrato del módulo. +/// See the module contract. #[no_mangle] pub unsafe extern "C" fn weft_doc_apply_update( doc: *mut Doc, @@ -413,14 +413,14 @@ pub unsafe extern "C" fn weft_doc_apply_update( }) } -// ── Memoria ───────────────────────────────────────────────────────────────────────────────── +// ── Memory ───────────────────────────────────────────────────────────────────────────────── -/// Libera un buffer devuelto por el shim (ptr+len exactamente los recibidos). NULL es no-op. +/// Frees a buffer returned by the shim (ptr+len exactly as received). NULL is a no-op. /// Thread-safe. /// /// # Safety -/// `ptr` debe ser null o provenir de una función de este shim que entregó (ptr, len), no -/// liberado antes. +/// `ptr` must be null or come from a function of this shim that handed out (ptr, len), not +/// freed before. #[no_mangle] pub unsafe extern "C" fn weft_buf_free(ptr: *mut c_uchar, len: usize) { if ptr.is_null() { @@ -432,20 +432,20 @@ pub unsafe extern "C" fn weft_buf_free(ptr: *mut c_uchar, len: usize) { })); } -// ── Diagnóstico ───────────────────────────────────────────────────────────────────────────── +// ── Diagnostics ───────────────────────────────────────────────────────────────────────────── -/// Versión de la ABI (entero monotónico) para detectar desalineación paquete/binario. +/// ABI version (monotonic integer) to detect package/binary misalignment. #[no_mangle] pub extern "C" fn weft_abi_version() -> u32 { WEFT_ABI_VERSION } -// ── Test hooks (SOLO con la feature de Cargo `test-hooks`) ────────────────────────────────── +// ── Test hooks (ONLY with the Cargo `test-hooks` feature) ────────────────────────────────── -/// Provoca un `panic!` interno deliberado para verificar `catch_unwind` end-to-end (SC-009). -/// NUNCA presente en binarios de release. Retorna `WEFT_ERR_PANIC` si el shim es correcto. +/// Triggers a deliberate internal `panic!` to verify `catch_unwind` end-to-end (SC-009). +/// NEVER present in release binaries. Returns `WEFT_ERR_PANIC` if the shim is correct. #[cfg(feature = "test-hooks")] #[no_mangle] pub extern "C" fn weft_test_panic() -> i32 { - guard(|| panic!("weft_test_panic: panic deliberado para verificar la frontera")) + guard(|| panic!("weft_test_panic: deliberate panic to verify the boundary")) } diff --git a/native/weft-yrs-ffi/tests/mem_asan.rs b/native/weft-yrs-ffi/tests/mem_asan.rs index c26b501..7a07b8c 100644 --- a/native/weft-yrs-ffi/tests/mem_asan.rs +++ b/native/weft-yrs-ffi/tests/mem_asan.rs @@ -1,11 +1,11 @@ -//! Suite de integración del shim `weft-yrs-ffi`. +//! Integration suite for the `weft-yrs-ffi` shim. //! -//! Doble propósito: -//! 1. **Funcional**: round-trip byte-idéntico, convergencia, rutas de error tipificadas. -//! 2. **Memoria (gate P-II)**: cada función se ejercita ≥2000 iteraciones incluyendo rutas de -//! error; bajo ASan/LSan (nightly) debe cerrar con 0 fugas / 0 double-free. +//! Dual purpose: +//! 1. **Functional**: byte-identical round-trip, convergence, typed error paths. +//! 2. **Memory (P-II gate)**: each function is exercised ≥2000 iterations including error +//! paths; under ASan/LSan (nightly) it must finish with 0 leaks / 0 double-free. //! -//! Ejecutar el gate de memoria: +//! Run the memory gate: //! ```bash //! RUSTFLAGS="-Zsanitizer=address" cargo +nightly test \ //! -p weft-yrs-ffi --features test-hooks --target x86_64-unknown-linux-gnu @@ -16,7 +16,7 @@ use std::ptr; use weft_yrs_ffi::*; use yrs::Doc; -/// Crea un doc y aborta si el shim falla. +/// Creates a doc and aborts if the shim fails. unsafe fn new_doc() -> *mut Doc { let mut doc: *mut Doc = ptr::null_mut(); assert_eq!(weft_doc_new(&mut doc), WEFT_OK); @@ -24,7 +24,7 @@ unsafe fn new_doc() -> *mut Doc { doc } -/// Inserta texto en un campo, asumiendo entradas válidas. +/// Inserts text into a field, assuming valid inputs. unsafe fn insert(doc: *mut Doc, field: &str, index: u32, text: &str) -> i32 { weft_text_insert( doc, @@ -36,7 +36,7 @@ unsafe fn insert(doc: *mut Doc, field: &str, index: u32, text: &str) -> i32 { ) } -/// Recupera un buffer de salida y lo libera con weft_buf_free (patrón TakeOwnedBuffer). +/// Retrieves an output buffer and frees it with weft_buf_free (TakeOwnedBuffer pattern). unsafe fn take_buf(f: impl FnOnce(*mut *mut u8, *mut usize) -> i32) -> Result, i32> { let mut out_ptr: *mut u8 = ptr::null_mut(); let mut out_len: usize = 0; @@ -69,14 +69,14 @@ fn round_trip_and_text_ops() { assert_eq!(insert(doc, "body", 0, "Hola mundo"), WEFT_OK); assert_eq!(read_text(doc, "body"), "Hola mundo"); - // Borrado en rango válido: elimina " mundo" (6 unidades desde el índice 4). + // Delete in valid range: removes " mundo" (6 units from index 4). assert_eq!( weft_text_delete(doc, "body".as_ptr(), "body".len(), 4, 6), WEFT_OK ); assert_eq!(read_text(doc, "body"), "Hola"); - // Round-trip: load(export(d)).export() es byte-idéntico (P-III). + // Round-trip: load(export(d)).export() is byte-identical (P-III). let blob = export_state(doc); let mut reloaded: *mut Doc = ptr::null_mut(); assert_eq!(weft_doc_load(blob.as_ptr(), blob.len(), &mut reloaded), WEFT_OK); @@ -96,13 +96,13 @@ fn incremental_sync_converges() { assert_eq!(insert(a, "t", 0, "abc"), WEFT_OK); assert_eq!(insert(b, "t", 0, "XYZ"), WEFT_OK); - // b pide a `a` solo lo que no conoce (delta vs su state vector). + // b asks `a` only for what it does not know (delta vs its state vector). let sv_b = take_buf(|p, l| weft_doc_state_vector(b, p, l)).unwrap(); let delta = take_buf(|p, l| weft_doc_export_since(a, sv_b.as_ptr(), sv_b.len(), p, l)) .unwrap(); assert_eq!(weft_doc_apply_update(b, delta.as_ptr(), delta.len()), WEFT_OK); - // Y `a` recibe el estado completo de `b`. Ambos convergen byte-idéntico. + // And `a` receives the full state of `b`. Both converge byte-identical. let full_b = export_state(b); assert_eq!(weft_doc_apply_update(a, full_b.as_ptr(), full_b.len()), WEFT_OK); assert_eq!(export_state(a), export_state(b)); @@ -117,18 +117,18 @@ fn error_paths_are_typed_not_panics() { unsafe { let doc = new_doc(); - // NULL_ARG: out-param nulo y doc nulo. + // NULL_ARG: null out-param and null doc. assert_eq!(weft_doc_new(ptr::null_mut()), WEFT_ERR_NULL_ARG); assert_eq!(insert(ptr::null_mut(), "f", 0, "x"), WEFT_ERR_NULL_ARG); - // UTF8: field con bytes inválidos. + // UTF8: field with invalid bytes. let bad = [0xFFu8, 0xFE]; assert_eq!( weft_text_insert(doc, bad.as_ptr(), bad.len(), 0, b"x".as_ptr(), 1), WEFT_ERR_UTF8 ); - // OUT_OF_BOUNDS: insertar más allá del final; borrar fuera de rango. + // OUT_OF_BOUNDS: insert past the end; delete out of range. assert_eq!(insert(doc, "f", 5, "x"), WEFT_ERR_OUT_OF_BOUNDS); assert_eq!(insert(doc, "f", 0, "abc"), WEFT_OK); assert_eq!( @@ -136,7 +136,7 @@ fn error_paths_are_typed_not_panics() { WEFT_ERR_OUT_OF_BOUNDS ); - // DECODE: blob basura a load y a apply_update. + // DECODE: garbage blob to load and to apply_update. let garbage = [1u8, 2, 3, 4, 5, 6, 7, 8]; let mut d: *mut Doc = ptr::null_mut(); assert_eq!( @@ -149,14 +149,14 @@ fn error_paths_are_typed_not_panics() { ); weft_doc_free(doc); - // free de null es no-op seguro. + // free of null is a safe no-op. weft_doc_free(ptr::null_mut()); weft_buf_free(ptr::null_mut(), 0); } } -/// Gate de memoria: bucle de estrés que ejercita todas las funciones muchas veces. -/// Bajo ASan/LSan cualquier fuga o double-free rompe aquí. +/// Memory gate: stress loop that exercises all functions many times. +/// Under ASan/LSan any leak or double-free breaks here. #[test] fn stress_all_functions_2000_iterations() { unsafe { @@ -169,7 +169,7 @@ fn stress_all_functions_2000_iterations() { let _ = export_state(doc); let _ = take_buf(|p, l| weft_doc_state_vector(doc, p, l)).unwrap(); - // Round-trip por iteración (ejercita load + free del reconstruido). + // Round-trip per iteration (exercises load + free of the reconstructed one). let blob = export_state(doc); let mut reloaded: *mut Doc = ptr::null_mut(); assert_eq!(weft_doc_load(blob.as_ptr(), blob.len(), &mut reloaded), WEFT_OK); @@ -177,14 +177,14 @@ fn stress_all_functions_2000_iterations() { let _ = take_buf(|p, l| weft_doc_export_since(doc, sv.as_ptr(), sv.len(), p, l)) .unwrap(); - // Ruta de error también en el bucle (UTF8 inválido) para cubrirla ≥2000 veces. + // Error path also in the loop (invalid UTF8) to cover it ≥2000 times. let bad = [0xFFu8]; assert_eq!( weft_text_insert(doc, bad.as_ptr(), bad.len(), 0, b"x".as_ptr(), 1), WEFT_ERR_UTF8 ); - // Borra parte del contenido (ejercita remove_range) si hay suficientes unidades. + // Deletes part of the content (exercises remove_range) if there are enough units. let _ = weft_text_delete(doc, field.as_ptr(), field.len(), 0, 3); weft_doc_free(reloaded); @@ -194,17 +194,17 @@ fn stress_all_functions_2000_iterations() { } } -/// Regresión (hallazgo del fuzzer, R6): un update malformado que declara una longitud enorme en -/// pocos bytes debe degradar a `WEFT_ERR_DECODE`, nunca panic/UB. El "OOM" que reportó libFuzzer -/// era su presupuesto de RSS de 2 GB ante la asignación transitoria del decoder de yrs; con memoria -/// normal el shim devuelve DECODE limpiamente. Ver AILOG R6 (mitigación de recursos en la capa de -/// servidor, M2). +/// Regression (fuzzer finding, R6): a malformed update that declares an enormous length in +/// few bytes must degrade to `WEFT_ERR_DECODE`, never panic/UB. The "OOM" libFuzzer reported +/// was its 2 GB RSS budget facing the transient allocation of yrs's decoder; with normal +/// memory the shim returns DECODE cleanly. See AILOG R6 (resource mitigation in the +/// server layer, M2). #[test] fn malformed_update_with_huge_declared_length_decodes_cleanly() { unsafe { - // Inputs reproductores hallados por cargo-fuzz sobre weft_doc_load: declaran una longitud - // enorme en 4 bytes → yrs reserva capacidad virtual grande pero falla el decode sin - // llenarla (RSS real medido ~150 MB) → WEFT_ERR_DECODE, nunca panic/UB. + // Reproducer inputs found by cargo-fuzz over weft_doc_load: they declare an enormous + // length in 4 bytes → yrs reserves large virtual capacity but fails the decode without + // filling it (real RSS measured ~150 MB) → WEFT_ERR_DECODE, never panic/UB. for data in [[0xd8u8, 0xd8, 0xeb, 0x23], [0xfa, 0xff, 0xa4, 0x25]] { let mut loaded: *mut Doc = ptr::null_mut(); assert_eq!(weft_doc_load(data.as_ptr(), data.len(), &mut loaded), WEFT_ERR_DECODE); @@ -220,20 +220,20 @@ fn malformed_update_with_huge_declared_length_decodes_cleanly() { } } -/// Regresión (hallazgo del fuzzer, R6): un update malformado que hace `panic!` dentro de yrs -/// (assertion en `block.rs`) NO debe cruzar la frontera — `catch_unwind` lo contiene como código -/// de error. Verifica el contrato P-I con panic=unwind (igual que producción; el fuzz de CI corre -/// con un hook silenciado para ejercitar este mismo camino). -/// Siembra de client_id (FU-012/CHARTER-09): dos docs con el MISMO client_id + las mismas ops -/// exportan bytes idénticos (base de la paridad cross-impl); el guard de 53 bits rechaza en la -/// frontera; el borde superior válido (2^53 - 1) se acepta. +/// Regression (fuzzer finding, R6): a malformed update that `panic!`s inside yrs +/// (assertion in `block.rs`) must NOT cross the boundary — `catch_unwind` contains it as an +/// error code. Verifies the P-I contract with panic=unwind (same as production; the CI fuzz runs +/// with a silenced hook to exercise this very path). +/// client_id seeding (FU-012/CHARTER-09): two docs with the SAME client_id + the same ops +/// export identical bytes (basis of cross-impl parity); the 53-bit guard rejects at the +/// boundary; the valid upper edge (2^53 - 1) is accepted. #[test] fn seed_client_id_is_deterministic_and_bounded() { unsafe { let field = b"body"; let text = b"hola"; - // Mismo client_id + mismas ops → export byte-idéntico. + // Same client_id + same ops → byte-identical export. let mut a: *mut Doc = ptr::null_mut(); let mut b: *mut Doc = ptr::null_mut(); assert_eq!(weft_doc_new_with_client_id(42, &mut a), WEFT_OK); @@ -249,14 +249,14 @@ fn seed_client_id_is_deterministic_and_bounded() { assert_eq!( std::slice::from_raw_parts(pa, la), std::slice::from_raw_parts(pb, lb), - "misma siembra + mismas ops debe exportar bytes idénticos" + "same seed + same ops must export byte-identical bytes" ); weft_buf_free(pa, la); weft_buf_free(pb, lb); weft_doc_free(a); weft_doc_free(b); - // Guard de 53 bits: 2^53 se rechaza, 2^53 - 1 se acepta. + // 53-bit guard: 2^53 is rejected, 2^53 - 1 is accepted. let mut over: *mut Doc = ptr::null_mut(); assert_eq!( weft_doc_new_with_client_id(1u64 << 53, &mut over), @@ -289,7 +289,7 @@ fn malformed_update_that_panics_yrs_is_contained_not_ub() { } } -/// Panic-safety en la frontera (SC-009): solo con la feature `test-hooks`. +/// Panic-safety at the boundary (SC-009): only with the `test-hooks` feature. #[cfg(feature = "test-hooks")] #[test] fn test_panic_is_caught_at_boundary() { diff --git a/samples/README.md b/samples/README.md new file mode 100644 index 0000000..2fc48ea --- /dev/null +++ b/samples/README.md @@ -0,0 +1,46 @@ +# Weft samples + +Runnable examples, one per core user story of the library. Each is self-contained and +mirrors a scenario from the feature quickstart +([`specs/001-weft-crdt-versioning/quickstart.md`](../specs/001-weft-crdt-versioning/quickstart.md)). + +| Sample | Demonstrates | Needs a server? | Run | +| --- | --- | --- | --- | +| [`Weft.Sample.Versioning`](./Weft.Sample.Versioning) | **US1** — local content-addressed versioning (publish → diff → checkout → branch → merge) | No | `dotnet run --project samples/Weft.Sample.Versioning` | +| [`Weft.Sample.Server`](./Weft.Sample.Server) | **US3** — the y-sync relay server (`Weft.Server`) over WebSocket | It *is* the server | `dotnet run --project samples/Weft.Sample.Server` | +| [`tiptap-client`](./tiptap-client) | **US3** — a real Tiptap editor + a headless wire-compat check against the relay | Yes — the sample server | `cd samples/tiptap-client && npm install && npm run dev` | + +## Prerequisites + +- **.NET 10 SDK** (`net10.0`) for the two C# samples. +- **Node.js + npm** for `tiptap-client` only. + +The native shims (`weft-yrs-ffi`, `weft-loro-ffi`) are built and resolved automatically by +the project references — no manual native setup. + +## The 60-second tour + +```bash +# 1. Local versioning — no network, prints a full publish/diff/branch/merge journey. +dotnet run --project samples/Weft.Sample.Versioning + +# 2. Start the relay (leave it running); serves ws://127.0.0.1:5199/collab/{docId}. +dotnet run --project samples/Weft.Sample.Server + +# 3. In another shell: prove the wire is Yjs-compatible without a browser. +cd samples/tiptap-client && npm install && npm run check + +# 4. Or open the real editor and collaborate across browser tabs. +npm run dev # then open http://localhost:5173/?doc=demo in 2+ tabs +``` + +## Notes + +- The relay listens on **port 5199** by default; the Tiptap client points there out of the box, + so no configuration is needed when running both as-is. +- `Weft.Sample.Server` persists documents to disk (`FileSystemDocumentStore`), so they survive a + restart. The two US3 samples together cover live convergence, awareness (presence), incremental + reconnect, and persistence. +- Not every user story has an interactive demo: **US2** (concurrency at scale) and **US5** + (dual-engine Loro) are exercised by the load tests and the dual-engine test suite rather than a + sample app. diff --git a/samples/Weft.Sample.Server/Program.cs b/samples/Weft.Sample.Server/Program.cs index b173e47..4c759c0 100644 --- a/samples/Weft.Sample.Server/Program.cs +++ b/samples/Weft.Sample.Server/Program.cs @@ -2,18 +2,18 @@ using Weft.Server.Auth; using Weft.Server.Persistence; -// Relay y-sync de ejemplo: sirve un endpoint WebSocket compatible con clientes Yjs (y-websocket/Tiptap) en -// ws://127.0.0.1:5199/collab/{docId}. Ver samples/tiptap-client para el cliente y la validación de compat. +// Example y-sync relay: serves a WebSocket endpoint compatible with Yjs clients (y-websocket/Tiptap) at +// ws://127.0.0.1:5199/collab/{docId}. See samples/tiptap-client for the client and the wire-compat check. var builder = WebApplication.CreateBuilder(args); builder.WebHost.UseUrls(Environment.GetEnvironmentVariable("WEFT_SAMPLE_URLS") ?? "http://127.0.0.1:5199"); builder.Services.AddWeftServer(); -// OBLIGATORIO: sin IWeftAuthorizer, MapWeft falla al arrancar. Este demo concede ReadWrite a todos — -// un consumidor real decide con su propia identidad (JWT/cookies) a partir del HttpContext. +// REQUIRED: without an IWeftAuthorizer, MapWeft fails at startup. This demo grants ReadWrite to everyone — +// a real consumer decides access from its own identity (JWT/cookies) via the HttpContext. builder.Services.AddSingleton(); -// Persistencia durable en disco (v1). Los documentos sobreviven al reinicio del servidor. +// Durable on-disk persistence (v1). Documents survive a server restart. string dataDir = Path.Combine(AppContext.BaseDirectory, "weft-data"); builder.Services.AddSingleton(new FileSystemDocumentStore(dataDir)); @@ -21,11 +21,11 @@ app.UseWebSockets(); app.MapWeft("/collab"); -app.Logger.LogInformation("Weft sample relay en {Urls} — endpoint WebSocket /collab/{{docId}} — datos en {DataDir}", +app.Logger.LogInformation("Weft sample relay on {Urls} — WebSocket endpoint /collab/{{docId}} — data in {DataDir}", string.Join(", ", app.Urls.DefaultIfEmpty("http://127.0.0.1:5199")), dataDir); app.Run(); -/// Authorizer de demostración: concede ReadWrite a toda conexión. NO usar en producción. +/// Demo authorizer: grants ReadWrite to every connection. Do NOT use in production. internal sealed class DemoAuthorizer : IWeftAuthorizer { public ValueTask AuthorizeAsync(HttpContext context, string docId, CancellationToken ct) diff --git a/samples/Weft.Sample.Server/README.md b/samples/Weft.Sample.Server/README.md new file mode 100644 index 0000000..c0b3336 --- /dev/null +++ b/samples/Weft.Sample.Server/README.md @@ -0,0 +1,42 @@ +# Weft.Sample.Server + +**User Story 3 (M2): the y-sync relay server.** + +A minimal ASP.NET Core host (~30 lines) that stands up the Weft relay: a WebSocket endpoint +compatible with the Yjs ecosystem (`y-websocket` / Tiptap), with no Weft-specific client adaptation. + +## Run + +```bash +dotnet run --project samples/Weft.Sample.Server +``` + +Then it serves: + +```text +ws://127.0.0.1:5199/collab/{docId} +``` + +Wait for the log line `Weft sample relay on http://127.0.0.1:5199 …` before connecting a client. + +## What it shows + +- **`AddWeftServer()` + `MapWeft("/collab")`** — the whole server wiring. +- **Authorization extension point (`IWeftAuthorizer`).** This demo ships a `DemoAuthorizer` that + grants `ReadWrite` to everyone — **not for production**. A real consumer decides access from the + `HttpContext` using its own identity (JWT/cookies). Without an `IWeftAuthorizer` registered, + `MapWeft` fails at startup by design. +- **Durable persistence** via `FileSystemDocumentStore` — documents are written under + `bin//net10.0/weft-data/` and survive a server restart. + +## Configuration + +| Env var | Default | Purpose | +| ------------------ | ----------------------- | ----------------------------- | +| `WEFT_SAMPLE_URLS` | `http://127.0.0.1:5199` | Address(es) the host binds to | + +## Pair it with a client + +- **Browser editor + headless wire check:** [`../tiptap-client`](../tiptap-client) — a real Tiptap + editor plus `npm run check`, the headless proof that yrs (server) and Yjs (client) updates are + binary-interchangeable. diff --git a/samples/Weft.Sample.Versioning/Program.cs b/samples/Weft.Sample.Versioning/Program.cs index 4654048..e382f89 100644 --- a/samples/Weft.Sample.Versioning/Program.cs +++ b/samples/Weft.Sample.Versioning/Program.cs @@ -3,31 +3,31 @@ using Weft.Versioning.Blobs; using Weft.Yrs; -// Sample de US1: editar y versionar documentos content-addressed desde .NET (T030). -// Recorre el user journey completo: publicar → diff → checkout → branch → merge. +// US1 sample: edit and version content-addressed documents from .NET (T030). +// Walks the full user journey: publish → diff → checkout → branch → merge. ICrdtEngine engine = YrsEngine.Instance; var store = new VersionStore(engine, new InMemoryBlobStore()); -Console.WriteLine($"Motor: {engine.Name}\n"); +Console.WriteLine($"Engine: {engine.Name}\n"); -// 1. Crear y editar un documento. +// 1. Create and edit a document. using ICrdtDoc doc = engine.CreateDoc(); -doc.InsertText("titulo", 0, "El veloz murciélago"); +doc.InsertText("title", 0, "The quick brown bat"); VersionId v1 = await store.PublishAsync(doc); -Console.WriteLine($"v1 publicada → {v1}"); -Console.WriteLine($" titulo: \"{doc.GetText("titulo")}\"\n"); +Console.WriteLine($"v1 published → {v1}"); +Console.WriteLine($" title: \"{doc.GetText("title")}\"\n"); -// 2. Editar y publicar una segunda versión. -doc.DeleteText("titulo", 9, 10); // borra "murciélago" -doc.InsertText("titulo", 9, "colibrí"); +// 2. Edit and publish a second version. +doc.DeleteText("title", 10, 9); // deletes "brown bat" +doc.InsertText("title", 10, "hummingbird"); VersionId v2 = await store.PublishAsync(doc); -Console.WriteLine($"v2 publicada → {v2}"); -Console.WriteLine($" titulo: \"{doc.GetText("titulo")}\"\n"); +Console.WriteLine($"v2 published → {v2}"); +Console.WriteLine($" title: \"{doc.GetText("title")}\"\n"); -// 3. Diff entre versiones (por palabras). -TextDiff diff = await store.DiffAsync(v1, v2, "titulo"); -Console.WriteLine("Diff v1 → v2 (titulo):"); +// 3. Diff between versions (word level). +TextDiff diff = await store.DiffAsync(v1, v2, "title"); +Console.WriteLine("Diff v1 → v2 (title):"); foreach (TextDiffSegment seg in diff.Segments) { string mark = seg.Op switch { DiffOp.Inserted => "+", DiffOp.Deleted => "-", _ => " " }; @@ -35,18 +35,18 @@ } Console.WriteLine(); -// 4. Checkout: reconstruir el documento de la v1 (verifica integridad). +// 4. Checkout: reconstruct the v1 document (verifies integrity). using ICrdtDoc restored = await store.CheckoutAsync(v1); -Console.WriteLine($"Checkout v1 → titulo: \"{restored.GetText("titulo")}\"\n"); +Console.WriteLine($"Checkout v1 → title: \"{restored.GetText("title")}\"\n"); -// 5. Branch + merge: dos ramas concurrentes desde v2 que convergen. +// 5. Branch + merge: two concurrent branches off v2 that converge. using ICrdtDoc branchA = await store.BranchAsync(v2); using ICrdtDoc branchB = await store.BranchAsync(v2); -branchA.InsertText("titulo", 0, "[A] "); -branchB.InsertText("titulo", 0, "[B] "); +branchA.InsertText("title", 0, "[A] "); +branchB.InsertText("title", 0, "[B] "); store.Merge(branchA, branchB); VersionId merged = await store.PublishAsync(branchA); Console.WriteLine($"Merge A◁B → {merged}"); -Console.WriteLine($" titulo: \"{branchA.GetText("titulo")}\""); +Console.WriteLine($" title: \"{branchA.GetText("title")}\""); -Console.WriteLine("\n✓ Journey de versionado completado."); +Console.WriteLine("\n✓ Versioning journey complete."); diff --git a/samples/Weft.Sample.Versioning/README.md b/samples/Weft.Sample.Versioning/README.md new file mode 100644 index 0000000..150d401 --- /dev/null +++ b/samples/Weft.Sample.Versioning/README.md @@ -0,0 +1,25 @@ +# Weft.Sample.Versioning + +**User Story 1 (M0): edit and version documents from .NET — no server, no network.** + +A console app that walks the full content-addressed versioning journey on top of the `yrs` +engine, entirely in memory. It's the "hello world" of `Weft.Versioning`. + +## Run + +```bash +dotnet run --project samples/Weft.Sample.Versioning +``` + +## What it does + +1. **Create & edit** a document, then **publish v1** — you get a `VersionId` (the SHA-256 of the + deterministic export; same content → same id, always). +2. **Edit & publish v2**, then **diff v1 → v2** word by word. +3. **Checkout** v1 — reconstructs the document from its hash and verifies integrity. +4. **Branch ×2 + merge** two concurrent branches off v2 — they converge automatically — and publish + the merged result. + +No authentication, no persistence adapter, no relay: this is the pure versioning layer, which +delivers citable value with only the library referenced. For the real-time collaboration story see +[`../Weft.Sample.Server`](../Weft.Sample.Server) and [`../tiptap-client`](../tiptap-client). diff --git a/samples/tiptap-client/README.md b/samples/tiptap-client/README.md index 4468e68..8f2798a 100644 --- a/samples/tiptap-client/README.md +++ b/samples/tiptap-client/README.md @@ -1,39 +1,42 @@ -# Weft · cliente Tiptap + validación de compat del wire +# Weft · Tiptap client + wire-compat validation -Sample de US3 (CHARTER-05): un editor **Tiptap** colaborativo real contra el relay `Weft.Server`, más un -check **headless** de compatibilidad del wire con `yjs`/`y-websocket`. Demuestra que el relay interopera con -el ecosistema Yjs **sin adaptación** — el servidor habla `y-sync` estándar. +Sample for US3 (CHARTER-05): a real collaborative **Tiptap** editor against the `Weft.Server` relay, +plus a **headless** wire-compatibility check with `yjs`/`y-websocket`. Demonstrates that the relay +interoperates with the Yjs ecosystem **without adaptation** — the server speaks standard `y-sync`. -## Requisitos +## Requirements + +- The sample server running (listens on `ws://127.0.0.1:5199/collab/{docId}`): -- El sample server corriendo: ```bash - dotnet run --project ../Weft.Sample.Server # escucha en ws://127.0.0.1:5199/collab/{docId} + dotnet run --project ../Weft.Sample.Server ``` -- Node.js + npm. Instalar deps una vez: `npm install`. -## 1) Validación headless (sin navegador) — gate de compat del wire +- Node.js + npm. Install deps once: `npm install`. + +## 1) Headless validation (no browser) — wire-compat gate -Dos `Y.Doc` reales de Yjs se conectan vía `y-websocket` y deben converger tras ediciones cruzadas: +Two real Yjs `Y.Doc`s connect via `y-websocket` and must converge after cross edits: ```bash npm run check -# ✓ convergencia Yjs (y-websocket) ↔ Weft.Server (yrs): "Hello from A. And B too." +# ✓ convergence Yjs (y-websocket) ↔ Weft.Server (yrs): "Hello from A. And B too." ``` -Sale con código 0 si converge; 1 si diverge o da timeout. Es la evidencia de que los updates de yrs (servidor) -y Yjs (cliente) son intercambiables a nivel binario. +Exits 0 if they converge; 1 if they diverge or time out. This is the evidence that yrs updates +(server) and Yjs updates (client) are interchangeable at the binary level. -## 2) Validación manual con Tiptap (quickstart §US3) +## 2) Manual validation with Tiptap (quickstart §US3) ```bash -npm run dev # Vite sirve el editor en http://localhost:5173 +npm run dev # Vite serves the editor at http://localhost:5173 ``` -1. Abre `http://localhost:5173/?doc=demo` en **2+ pestañas** (o navegadores). -2. Escribe en una pestaña → el texto aparece en vivo en las demás (convergencia). -3. Los cursores/nombres de los pares se ven (awareness); al cerrar una pestaña, su cursor desaparece (retirada). -4. Recarga una pestaña → recupera el estado desde el relay (delta en reconexión). -5. Reinicia el sample server → los documentos persisten (`FileSystemDocumentStore`). +1. Open `http://localhost:5173/?doc=demo` in **2+ tabs** (or browsers). +2. Type in one tab → the text appears live in the others (convergence). +3. Peer cursors/names are visible (awareness); closing a tab makes its cursor disappear (retirement). +4. Reload a tab → it recovers state from the relay (delta on reconnect). +5. Restart the sample server → documents persist (`FileSystemDocumentStore`). -El `docId` es el parámetro `?doc=`; cambia la URL base con `?url=ws://host:port/collab` si hace falta. +The `docId` is the `?doc=` query parameter; change the base URL with `?url=ws://host:port/collab` +if needed. diff --git a/samples/tiptap-client/src/main.js b/samples/tiptap-client/src/main.js index 2e00031..bdc2cb8 100644 --- a/samples/tiptap-client/src/main.js +++ b/samples/tiptap-client/src/main.js @@ -1,5 +1,5 @@ -// Cliente Tiptap colaborativo real contra el relay Weft.Server (gate de compat del wire de US3, T052). -// Tiptap + y-prosemirror + y-websocket, sin adaptación específica de Weft: el relay habla y-sync estándar. +// Real collaborative Tiptap client against the Weft.Server relay (US3 wire-compat gate, T052). +// Tiptap + y-prosemirror + y-websocket, with no Weft-specific adaptation: the relay speaks standard y-sync. import { Editor } from '@tiptap/core'; import StarterKit from '@tiptap/starter-kit'; import Collaboration from '@tiptap/extension-collaboration'; @@ -20,7 +20,7 @@ const color = '#' + Math.floor(Math.random() * 0xffffff).toString(16).padStart(6 const editor = new Editor({ element: document.querySelector('#editor'), extensions: [ - StarterKit.configure({ history: false }), // el historial/undo lo gestiona Yjs, no Tiptap + StarterKit.configure({ history: false }), // history/undo is handled by Yjs, not Tiptap Collaboration.configure({ document: ydoc }), CollaborationCursor.configure({ provider, user: { name, color } }), ], @@ -28,4 +28,4 @@ const editor = new Editor({ provider.on('status', (e) => { document.querySelector('#status').textContent = e.status; }); document.querySelector('#room').textContent = room; -window.__weft = { editor, ydoc, provider }; // para inspección manual en la consola +window.__weft = { editor, ydoc, provider }; // for manual inspection in the console diff --git a/samples/tiptap-client/wire-check.mjs b/samples/tiptap-client/wire-check.mjs index 541891d..6328ebe 100644 --- a/samples/tiptap-client/wire-check.mjs +++ b/samples/tiptap-client/wire-check.mjs @@ -1,8 +1,8 @@ -// Validación HEADLESS de compatibilidad del wire (retira R1 sin navegador): dos Y.Doc reales de Yjs se -// conectan al relay Weft.Server vía y-websocket y deben converger tras ediciones cruzadas. Si yrs (servidor) y -// Yjs (cliente) no fueran compatibles a nivel de update binario, esto divergiría o daría timeout. +// HEADLESS wire-compatibility check (retires R1 without a browser): two real Yjs Y.Docs connect to the +// Weft.Server relay via y-websocket and must converge after cross edits. If yrs (server) and Yjs (client) +// were not compatible at the binary update level, this would diverge or time out. // -// Uso: arrancar el sample server (dotnet run --project samples/Weft.Sample.Server), luego `npm run check`. +// Usage: start the sample server (dotnet run --project samples/Weft.Sample.Server), then `npm run check`. import * as Y from 'yjs'; import { WebsocketProvider } from 'y-websocket'; import WS from 'ws'; @@ -22,7 +22,7 @@ function waitFor(cond, label, ms = 5000) { const t0 = Date.now(); const iv = setInterval(() => { if (cond()) { clearInterval(iv); resolve(); } - else if (Date.now() - t0 > ms) { clearInterval(iv); reject(new Error('timeout esperando: ' + label)); } + else if (Date.now() - t0 > ms) { clearInterval(iv); reject(new Error('timeout waiting for: ' + label)); } }, 20); }); } @@ -31,24 +31,24 @@ const a = connect(); const b = connect(); let code = 0; try { - await waitFor(() => a.provider.wsconnected && b.provider.wsconnected, 'conexión de ambos clientes'); + await waitFor(() => a.provider.wsconnected && b.provider.wsconnected, 'both clients to connect'); - // A edita → B debe converger. + // A edits → B must converge. a.doc.getText(FIELD).insert(0, 'Hello from A. '); - await waitFor(() => b.doc.getText(FIELD).toString().includes('Hello from A.'), 'B recibe la edición de A'); + await waitFor(() => b.doc.getText(FIELD).toString().includes('Hello from A.'), 'B to receive A\'s edit'); - // B edita → A debe converger. + // B edits → A must converge. const t = b.doc.getText(FIELD); t.insert(t.length, 'And B too.'); - await waitFor(() => a.doc.getText(FIELD).toString().includes('And B too.'), 'A recibe la edición de B'); + await waitFor(() => a.doc.getText(FIELD).toString().includes('And B too.'), 'A to receive B\'s edit'); const ta = a.doc.getText(FIELD).toString(); const tb = b.doc.getText(FIELD).toString(); - if (ta !== tb) throw new Error(`divergencia: A="${ta}" B="${tb}"`); + if (ta !== tb) throw new Error(`divergence: A="${ta}" B="${tb}"`); - console.log('✓ convergencia Yjs (y-websocket) ↔ Weft.Server (yrs):', JSON.stringify(ta)); + console.log('✓ convergence Yjs (y-websocket) ↔ Weft.Server (yrs):', JSON.stringify(ta)); } catch (e) { - console.error('✗ FALLO de compat del wire:', e.message); + console.error('✗ wire-compat FAILURE:', e.message); code = 1; } finally { a.provider.destroy(); diff --git a/src/Weft.Core/Abstractions/ICrdtDoc.cs b/src/Weft.Core/Abstractions/ICrdtDoc.cs index 98f23e6..00627b0 100644 --- a/src/Weft.Core/Abstractions/ICrdtDoc.cs +++ b/src/Weft.Core/Abstractions/ICrdtDoc.cs @@ -1,39 +1,39 @@ namespace Weft; /// -/// Documento CRDT vivo. NO es thread-safe: el dueño serializa el acceso (o usa el -/// DocumentBroker, que lo garantiza). Ver constitución P-V. +/// Live CRDT document. NOT thread-safe: the owner serializes access (or uses the +/// DocumentBroker, which guarantees it). See constitution P-V. /// public interface ICrdtDoc : IDisposable { /// - /// Nombre estable del motor que respalda este documento ("yrs", "loro"). Coincide con - /// . Permite rechazar mezclas cross-engine antes de cruzar el FFI. + /// Stable name of the engine backing this document ("yrs", "loro"). Matches + /// . Allows rejecting cross-engine mixes before crossing the FFI. /// string EngineName { get; } - // -- Texto por campo nombrado (v1) -- + // -- Text by named field (v1) -- - /// Inserta en el campo en la posición . + /// Inserts into field at position . void InsertText(string field, int index, string text); - /// Borra unidades desde en el campo . + /// Deletes units from in field . void DeleteText(string field, int index, int length); - /// Devuelve el contenido completo del campo . + /// Returns the full content of field . string GetText(string field); - // -- Estado y sincronización -- + // -- State and synchronization -- - /// Export byte-determinista del estado completo (base del content-addressing, P-III). + /// Byte-deterministic export of the full state (basis of content-addressing, P-III). byte[] ExportState(); - /// Resumen "qué conozco" para sync incremental. + /// "What I know" summary for incremental sync. byte[] ExportStateVector(); - /// Delta con los cambios que el emisor del no conoce. + /// Delta with the changes the sender of does not know. byte[] ExportUpdateSince(ReadOnlySpan stateVector); - /// Fusiona un update/estado de otra réplica (convergente). + /// Merges an update/state from another replica (convergent). void ApplyUpdate(ReadOnlySpan update); } diff --git a/src/Weft.Core/Abstractions/ICrdtEngine.cs b/src/Weft.Core/Abstractions/ICrdtEngine.cs index 49597c9..de1a00d 100644 --- a/src/Weft.Core/Abstractions/ICrdtEngine.cs +++ b/src/Weft.Core/Abstractions/ICrdtEngine.cs @@ -1,25 +1,25 @@ namespace Weft; -/// Fábrica de documentos de un motor CRDT. Thread-safe. +/// Document factory of a CRDT engine. Thread-safe. public interface ICrdtEngine { - /// Nombre estable del motor ("yrs", "loro"). + /// Stable engine name ("yrs", "loro"). string Name { get; } - /// Crea un documento vacío. + /// Creates an empty document. ICrdtDoc CreateDoc(); - /// Reconstruye un documento desde un blob exportado. - /// Estado exportado por (update v1). - /// El blob no es decodificable. + /// Rebuilds a document from an exported blob. + /// State exported by (update v1). + /// The blob is not decodable. ICrdtDoc LoadDoc(ReadOnlySpan blob); - /// Capacidad opcional de versionado nativo; null si el motor no la ofrece. + /// Optional native versioning capability; null if the engine does not offer it. INativeVersioning? NativeVersioning { get; } /// - /// Capacidad opcional para sembrar la identidad de réplica (determinismo de test/corpus); - /// null si el motor no la ofrece. + /// Optional capability to seed the replica identity (test/corpus determinism); + /// null if the engine does not offer it. /// IDeterministicSeeding? DeterministicSeeding { get; } } diff --git a/src/Weft.Core/Abstractions/IDeterministicSeeding.cs b/src/Weft.Core/Abstractions/IDeterministicSeeding.cs index 2e139bf..f8fc3b1 100644 --- a/src/Weft.Core/Abstractions/IDeterministicSeeding.cs +++ b/src/Weft.Core/Abstractions/IDeterministicSeeding.cs @@ -1,37 +1,37 @@ namespace Weft; /// -/// Capacidad opcional para sembrar la identidad de réplica de un documento nuevo (el client_id -/// de yrs, el peer_id de Loro), habilitando exports reproducibles cross-run y cross-RID. +/// Optional capability to seed the replica identity of a new document (yrs's client_id, +/// Loro's peer_id), enabling reproducible exports cross-run and cross-RID. /// /// /// -/// Se expone como capacidad opcional —no como un método de — por la -/// asimetría del dominio válido entre motores: yrs acepta ids < 2^53 (encoding de 53 -/// bits), Loro acepta todo ulong salvo ulong.MaxValue (reservado). Un método único no -/// podría enunciar un contrato uniforme sobre ese dominio, forzando al llamador a ramificar por motor -/// — la fuga que la constitución P-IV existe para evitar. hace del -/// dominio parte del contrato. Es el mismo patrón que . +/// It is exposed as an optional capability —not as a method of — because of the +/// asymmetry of the valid domain between engines: yrs accepts ids < 2^53 (53-bit +/// encoding), Loro accepts every ulong except ulong.MaxValue (reserved). A single method +/// could not state a uniform contract over that domain, forcing the caller to branch by engine +/// — the leak the constitution P-IV exists to prevent. makes the +/// domain part of the contract. It is the same pattern as . /// /// -/// Uso previsto: determinismo de test/corpus, NO identidad de producción. Reusar la misma -/// identidad de réplica entre escritores concurrentes rompe la garantía CRDT (Loro lo documenta como -/// corrupción del documento). El relay y el broker crean documentos con -/// , que asigna una identidad aleatoria; no siembran. +/// Intended use: test/corpus determinism, NOT production identity. Reusing the same +/// replica identity across concurrent writers breaks the CRDT guarantee (Loro documents it as +/// document corruption). The relay and the broker create documents with +/// , which assigns a random identity; they do not seed. /// /// public interface IDeterministicSeeding { /// - /// Cota superior EXCLUSIVA de la identidad de réplica válida para este motor. yrs: 1UL << - /// 53. Loro: (todo ulong salvo el valor reservado). + /// EXCLUSIVE upper bound of the valid replica identity for this engine. yrs: 1UL << + /// 53. Loro: (every ulong except the reserved value). /// ulong MaxReplicaIdExclusive { get; } /// - /// Crea un documento vacío con la identidad de réplica fija. + /// Creates an empty document with the fixed replica identity . /// - /// Identidad de réplica; debe ser < . - /// fuera del dominio válido. + /// Replica identity; must be < . + /// outside the valid domain. ICrdtDoc CreateDoc(ulong replicaId); } diff --git a/src/Weft.Core/Abstractions/INativeVersioning.cs b/src/Weft.Core/Abstractions/INativeVersioning.cs index 7ef7ab5..c82f084 100644 --- a/src/Weft.Core/Abstractions/INativeVersioning.cs +++ b/src/Weft.Core/Abstractions/INativeVersioning.cs @@ -1,14 +1,14 @@ namespace Weft; -/// Capacidad opcional para motores con versionado nativo (Loro). Probes de paridad. +/// Optional capability for engines with native versioning (Loro). Parity probes. public interface INativeVersioning { - /// Descripción (JSON) del diff nativo del motor para el campo dado. + /// Description (JSON) of the engine's native diff for the given field. string NativeDiffProbe(ICrdtDoc doc, string field); - /// Descripción (JSON) de la operación nativa de fork/merge del motor. + /// Description (JSON) of the engine's native fork/merge operation. string NativeBranchMergeProbe(ICrdtDoc doc, string field); - /// Snapshot superficial (shallow) nativo del documento. + /// Native shallow snapshot of the document. byte[] ShallowSnapshot(ICrdtDoc doc); } diff --git a/src/Weft.Core/Concurrency/DocumentActor.cs b/src/Weft.Core/Concurrency/DocumentActor.cs index 87937f2..64401f1 100644 --- a/src/Weft.Core/Concurrency/DocumentActor.cs +++ b/src/Weft.Core/Concurrency/DocumentActor.cs @@ -2,27 +2,27 @@ namespace Weft.Concurrency; -/// Estado observable del actor de un documento (constitución P-V). +/// Observable state of a document's actor (constitution P-V). internal enum DocumentActorState { - /// Acepta y procesa operaciones. + /// Accepts and processes operations. Active, - /// Seleccionado para desalojo; drenando la cola pendiente antes de liberar. + /// Selected for eviction; draining the pending queue before releasing. Idle, - /// Terminado con normalidad; documento persistido (si había hook) y liberado. + /// Terminated normally; document persisted (if there was a hook) and released. Evicted, - /// Terminado por fallo irrecuperable; documento liberado sin persistir. + /// Terminated by unrecoverable fault; document released without persisting. Faulted, } /// -/// Serializa TODO el acceso a un nativo mediante un único lector que drena un -/// canal de operaciones (patrón actor, constitución P-V). Nunca ejecuta dos operaciones del mismo -/// documento a la vez; libera el documento exactamente una vez al terminar. internal: se usa a -/// través de y . +/// Serializes ALL access to a native through a single reader that drains a +/// channel of operations (actor pattern, constitution P-V). Never executes two operations of the same +/// document at once; releases the document exactly once when finished. internal: used +/// through and . /// internal sealed class DocumentActor { @@ -44,8 +44,8 @@ internal DocumentActor(string docId, ICrdtDoc doc, Func(new UnboundedChannelOptions { - SingleReader = true, // un único lector: la garantía de serialización (P-V) - SingleWriter = false, // varias sesiones/hilos encolan + SingleReader = true, // a single reader: the serialization guarantee (P-V) + SingleWriter = false, // several sessions/threads enqueue }); _runLoop = Task.Run(RunAsync); } @@ -54,7 +54,7 @@ internal DocumentActor(string docId, ICrdtDoc doc, Func _state; - /// Milisegundos transcurridos desde la última operación procesada (para desalojo idle). + /// Milliseconds elapsed since the last processed operation (for idle eviction). internal long IdleMilliseconds => Environment.TickCount64 - Interlocked.Read(ref _lastActivityTick); internal int SessionCount @@ -73,8 +73,8 @@ internal void RemoveSession(DocumentSession session) } /// - /// Encola una operación sobre el documento y devuelve su resultado de forma asíncrona. La operación - /// se ejecuta dentro del turno del actor (nunca concurrente con otra del mismo documento). + /// Enqueues an operation on the document and returns its result asynchronously. The operation + /// executes within the actor's turn (never concurrent with another of the same document). /// internal ValueTask EnqueueAsync(Func op, bool mutating, CancellationToken ct) { @@ -87,9 +87,9 @@ internal ValueTask EnqueueAsync(Func op, bool mutating, Cance } /// - /// Inicia el desalojo cooperativo: no acepta más operaciones, drena las pendientes, persiste con el - /// hook (si aplica) y libera el documento. El devuelto completa cuando el documento - /// ha sido liberado. + /// Starts the cooperative eviction: accepts no more operations, drains the pending ones, persists with the + /// hook (if applicable) and releases the document. The returned completes when the document + /// has been released. /// internal Task BeginEvictionAsync() { @@ -103,7 +103,7 @@ internal Task BeginEvictionAsync() private Exception ClosedReason() => _fault ?? new ObjectDisposedException(nameof(DocumentSession), - $"El documento '{DocId}' fue desalojado o el broker se cerró."); + $"The document '{DocId}' was evicted or the broker was closed."); private async Task RunAsync() { @@ -113,7 +113,7 @@ private async Task RunAsync() { if (_fault is not null) { - item.Fail(_fault); // tras faultear, drenamos la cola fallando cada pendiente + item.Fail(_fault); // after faulting, we drain the queue failing each pending item continue; } @@ -136,8 +136,8 @@ private async Task RunAsync() } catch (Exception ex) { - // La operación falló DENTRO del turno: el estado del documento puede ser inválido. - // El item ya propagó la excepción a su llamador; el actor entra en Faulted y drena. + // The operation failed WITHIN the turn: the document state may be invalid. + // The item already propagated the exception to its caller; the actor enters Faulted and drains. _fault = ex; _state = DocumentActorState.Faulted; _channel.Writer.TryComplete(); @@ -152,10 +152,10 @@ private async Task RunAsync() private async ValueTask FinalizeAsync() { - // Persistencia solo en desalojo grácil (no en fallo): drenar → OnEvicting → liberar. - // `_fault` es la señal AUTORITATIVA de fallo: si es no-nulo, el documento está en estado - // desconocido y no debe persistirse, independientemente de `_state` (que otro hilo pudo dejar - // en Idle al iniciar el desalojo justo antes de que el turno faultease). + // Persistence only on graceful eviction (not on fault): drain → OnEvicting → release. + // `_fault` is the AUTHORITATIVE fault signal: if non-null, the document is in an + // unknown state and must not be persisted, regardless of `_state` (which another thread may have left + // in Idle when starting the eviction right before the turn faulted). if (_fault is null) { if (_onEvicting is not null) @@ -167,17 +167,17 @@ private async ValueTask FinalizeAsync() } catch (Exception ex) { - // La persistencia es best-effort: si el hook falla, igual liberamos el documento - // (no dejar memoria nativa colgada prima sobre no perder el snapshot). El fallo del - // hook es responsabilidad del consumidor; lo exponemos por trazas para observabilidad. + // Persistence is best-effort: if the hook fails, we release the document anyway + // (not leaving native memory dangling takes priority over not losing the snapshot). The hook's + // failure is the consumer's responsibility; we surface it via traces for observability. System.Diagnostics.Debug.WriteLine( - $"[DocumentActor] OnEvicting falló para '{DocId}': {ex.GetType().Name}: {ex.Message}"); + $"[DocumentActor] OnEvicting failed for '{DocId}': {ex.GetType().Name}: {ex.Message}"); } } _state = DocumentActorState.Evicted; } - _doc.Dispose(); // exactamente una vez: el bucle termina una sola vez (P-I) + _doc.Dispose(); // exactly once: the loop terminates only once (P-I) } private bool AnySessionWantsUpdates() @@ -209,8 +209,8 @@ private void NotifySessions(byte[] delta) var mem = new ReadOnlyMemory(delta); foreach (DocumentSession s in snapshot) { - // Un handler de UpdateApplied que lanza NO debe faultear el documento para todas las sesiones: - // el bug es del consumidor (relay/persistencia de M2), no del publicador. Se aísla y se traza. + // An UpdateApplied handler that throws must NOT fault the document for all sessions: + // the bug is the consumer's (relay/persistence of M2), not the publisher's. It is isolated and traced. try { s.RaiseUpdateApplied(mem); @@ -218,12 +218,12 @@ private void NotifySessions(byte[] delta) catch (Exception ex) { System.Diagnostics.Debug.WriteLine( - $"[DocumentActor] handler de UpdateApplied lanzó para '{DocId}': {ex.GetType().Name}: {ex.Message}"); + $"[DocumentActor] UpdateApplied handler threw for '{DocId}': {ex.GetType().Name}: {ex.Message}"); } } } - // -- Unidades de trabajo encoladas -- + // -- Enqueued work items -- private interface IWorkItem { @@ -254,7 +254,7 @@ public void Execute(ICrdtDoc doc) { if (_ct.IsCancellationRequested) { - _tcs.TrySetCanceled(_ct); // cancelación no faultea el actor + _tcs.TrySetCanceled(_ct); // cancellation does not fault the actor return; } try @@ -263,8 +263,8 @@ public void Execute(ICrdtDoc doc) } catch (Exception ex) { - _tcs.TrySetException(ex); // el llamador ve la excepción causal... - throw; // ...y el actor faultea (turno abortado) + _tcs.TrySetException(ex); // the caller sees the causal exception... + throw; // ...and the actor faults (turn aborted) } } diff --git a/src/Weft.Core/Concurrency/DocumentBroker.cs b/src/Weft.Core/Concurrency/DocumentBroker.cs index 7acc991..0b2ae9e 100644 --- a/src/Weft.Core/Concurrency/DocumentBroker.cs +++ b/src/Weft.Core/Concurrency/DocumentBroker.cs @@ -1,16 +1,16 @@ namespace Weft.Concurrency; /// -/// Gestiona documentos activos con acceso serializado por documento (un actor/canal por docId, -/// constitución P-V). Thread-safe y el único camino soportado para compartir un documento entre hilos. -/// Registra y reutiliza actores por identidad, los desaloja por inactividad y por presión de memoria -/// (LRU), y libera los recursos nativos de forma determinista. +/// Manages active documents with per-document serialized access (one actor/channel per docId, +/// constitution P-V). Thread-safe and the only supported way to share a document across threads. +/// Registers and reuses actors by identity, evicts them by inactivity and by memory pressure +/// (LRU), and releases native resources deterministically. /// /// -/// El límite es "suave": se reafirma en el barrido -/// periódico, no de forma síncrona en , y nunca desaloja un documento con sesiones -/// vivas. Puede excederse transitoriamente bajo ráfagas de aperturas o cuando todos los documentos activos -/// tienen sesiones abiertas. +/// The limit is "soft": it is reasserted in the periodic +/// sweep, not synchronously in , and it never evicts a document with live +/// sessions. It may be exceeded transiently under bursts of openings or when all active documents +/// have open sessions. /// public sealed class DocumentBroker : IAsyncDisposable { @@ -24,7 +24,7 @@ public sealed class DocumentBroker : IAsyncDisposable private readonly Task _sweeper; private bool _disposed; - /// Crea el broker sobre un motor CRDT y opciones de ciclo de vida (por defecto si se omiten). + /// Creates the broker over a CRDT engine and lifecycle options (defaults if omitted). public DocumentBroker(ICrdtEngine engine, DocumentBrokerOptions? options = null) { ArgumentNullException.ThrowIfNull(engine); @@ -35,16 +35,16 @@ public DocumentBroker(ICrdtEngine engine, DocumentBrokerOptions? options = null) _sweeper = Task.Run(SweepLoopAsync); } - /// Número de documentos actualmente activos (registrados). + /// Number of currently active (registered) documents. public int ActiveDocumentCount { get { lock (_gate) { return _actors.Count; } } } /// - /// Abre (o reutiliza) el documento . Si no está activo, lo carga con - /// (un loader que devuelve null/vacío ⇒ documento nuevo). - /// Devuelve una para operarlo de forma asíncrona. + /// Opens (or reuses) the document . If it is not active, it loads it with + /// (a loader that returns null/empty ⇒ new document). + /// Returns a to operate it asynchronously. /// public async ValueTask OpenAsync( string docId, @@ -64,8 +64,8 @@ public async ValueTask OpenAsync( ObjectDisposedException.ThrowIf(_disposed, this); if (_evicting.TryGetValue(docId, out Task? eviction)) { - // Hay un desalojo de este documento en vuelo: esperar a que persista su estado antes - // de cargar, o cargaríamos un snapshot a medio escribir (updates perdidos, SC-006). + // There is an in-flight eviction of this document: wait for it to persist its state before + // loading, or we would load a half-written snapshot (lost updates, SC-006). existing = null; loadTask = null!; inflightEviction = eviction; @@ -78,8 +78,8 @@ public async ValueTask OpenAsync( } else { - // Un actor terminado (Faulted/Evicted) que quedó registrado se descarta para recrearlo - // limpio — nunca reintentar sobre él (evita el giro infinito sobre un actor muerto). + // A terminated actor (Faulted/Evicted) that remained registered is discarded to recreate it + // clean — never retry over it (avoids the infinite spin over a dead actor). if (found is not null) { _actors.Remove(docId); @@ -91,8 +91,8 @@ public async ValueTask OpenAsync( } else { - // La carga compartida usa el token del broker, NO el del caller: la cancelación de - // un caller no debe envenenar la carga para los demás waiters del mismo docId. + // The shared load uses the broker's token, NOT the caller's: a caller's + // cancellation must not poison the load for the other waiters of the same docId. loadTask = LoadAndRegisterAsync(docId, loader, _shutdown.Token); _loading[docId] = loadTask; } @@ -101,16 +101,16 @@ public async ValueTask OpenAsync( if (inflightEviction is not null) { - try { await inflightEviction.WaitAsync(ct).ConfigureAwait(false); } catch (OperationCanceledException) { throw; } catch { /* el desalojo reporta aparte */ } - continue; // el estado ya está persistido; reintentar cargará el snapshot correcto + try { await inflightEviction.WaitAsync(ct).ConfigureAwait(false); } catch (OperationCanceledException) { throw; } catch { /* the eviction reports separately */ } + continue; // the state is already persisted; retrying will load the correct snapshot } - // WaitAsync aplica el ct de ESTE caller solo a la espera; la carga compartida sigue viva para - // otros waiters aunque este cancele (finding H). + // WaitAsync applies THIS caller's ct only to the wait; the shared load stays alive for + // other waiters even if this one cancels (finding H). DocumentActor actor = existing ?? await loadTask.WaitAsync(ct).ConfigureAwait(false); - // Añadir la sesión atómicamente respecto al barrido: solo si el actor sigue registrado y - // no terminó. Si fue desalojado en la ventana, reintentar (reabrirá o reutilizará). + // Add the session atomically with respect to the sweep: only if the actor is still registered and + // did not terminate. If it was evicted in the window, retry (will reopen or reuse). lock (_gate) { if (_actors.TryGetValue(docId, out DocumentActor? still) @@ -122,7 +122,7 @@ public async ValueTask OpenAsync( return session; } } - // desalojado entre carga y registro de la sesión (raro): ceder y reintentar sin quemar CPU + // evicted between load and session registration (rare): yield and retry without burning CPU await System.Threading.Tasks.Task.Yield(); } } @@ -132,9 +132,9 @@ private async Task LoadAndRegisterAsync( Func>? loader, CancellationToken ct) { - // Cede ANTES de trabajar: garantiza que nunca completa síncronamente dentro del lock de OpenAsync, - // así OpenAsync ya asignó `_loading[docId]` antes de que el `finally` de aquí lo retire (esto evita - // la entrada rancia que causaba el livelock R6, y permite que la carga gestione su propia entrada). + // Yield BEFORE working: guarantees it never completes synchronously inside OpenAsync's lock, + // so OpenAsync already assigned `_loading[docId]` before the `finally` here removes it (this avoids + // the stale entry that caused the R6 livelock, and lets the load manage its own entry). await System.Threading.Tasks.Task.Yield(); try { @@ -153,9 +153,9 @@ private async Task LoadAndRegisterAsync( } if (disposedRace) { - // Broker cerrado durante la carga: liberar el actor de forma DETERMINISTA (await, no - // fire-and-forget) para que DisposeAsync —que espera las cargas en vuelo— no retorne - // antes de que este documento quede liberado (finding F). + // Broker closed during the load: release the actor DETERMINISTICALLY (await, not + // fire-and-forget) so that DisposeAsync —which waits for the in-flight loads— does not return + // before this document is released (finding F). await actor.BeginEvictionAsync().ConfigureAwait(false); throw new ObjectDisposedException(nameof(DocumentBroker)); } @@ -180,8 +180,8 @@ private async Task SweepLoopAsync() } catch (Exception ex) when (ex is not OperationCanceledException) { - // un barrido fallido no debe matar el barrido de fondo (quedaría sin desalojar nunca). - System.Diagnostics.Debug.WriteLine($"[DocumentBroker] barrido falló: {ex}"); + // a failed sweep must not kill the background sweep (it would never evict again). + System.Diagnostics.Debug.WriteLine($"[DocumentBroker] sweep failed: {ex}"); } } } @@ -191,7 +191,7 @@ private async Task SweepLoopAsync() } } - /// Un pase de desalojo: inactividad (idle) + presión de memoria (LRU). Visible para tests. + /// A single eviction pass: inactivity (idle) + memory pressure (LRU). Visible for tests. internal async ValueTask SweepOnceAsync() { List evictions = []; @@ -218,10 +218,10 @@ internal async ValueTask SweepOnceAsync() int over = remaining - _options.MaxActiveDocuments; if (over > 0) { - // Presión de memoria: desalojar los menos recientemente usados SIN sesión, aunque estén - // "tibios". El orden por inactividad descendente protege a los recién usados/creados; si - // alguno se desaloja en la ventana previa a su primera sesión, OpenAsync reintenta. - // `toEvict` es un HashSet → la exclusión es O(1) por candidato (finding K). + // Memory pressure: evict the least recently used ones WITHOUT a session, even if they are + // "warm". Ordering by descending inactivity protects the recently used/created ones; if + // one is evicted in the window before its first session, OpenAsync retries. + // `toEvict` is a HashSet → the exclusion is O(1) per candidate (finding K). List lru = _actors.Values .Where(a => !toEvict.Contains(a) && a.SessionCount == 0) .OrderByDescending(a => a.IdleMilliseconds) @@ -237,26 +237,26 @@ internal async ValueTask SweepOnceAsync() { _actors.Remove(a.DocId); Task eviction = EvictActorAsync(a); - _evicting[a.DocId] = eviction; // los OpenAsync concurrentes esperan a que persista + _evicting[a.DocId] = eviction; // concurrent OpenAsync calls wait for it to persist evictions.Add(eviction); } } - // Esperar a que los desalojos que este barrido inició terminen (persistencia incluida). Da - // determinismo a los tests; en el barrido de fondo solo pausa hasta el siguiente tick. + // Wait for the evictions this sweep started to finish (persistence included). It gives + // determinism to the tests; in the background sweep it only pauses until the next tick. await Task.WhenAll(evictions).ConfigureAwait(false); } private async Task EvictActorAsync(DocumentActor actor) { - await System.Threading.Tasks.Task.Yield(); // no completar síncronamente: _evicting se asigna antes del finally + await System.Threading.Tasks.Task.Yield(); // do not complete synchronously: _evicting is assigned before the finally try { await actor.BeginEvictionAsync().ConfigureAwait(false); } catch { - // el desalojo de un actor no debe tumbar el barrido de los demás + // one actor's eviction must not bring down the sweep of the others } finally { @@ -267,7 +267,7 @@ private async Task EvictActorAsync(DocumentActor actor) } } - /// Drena y libera todos los documentos exactamente una vez; detiene el barrido. + /// Drains and releases all documents exactly once; stops the sweep. public async ValueTask DisposeAsync() { lock (_gate) @@ -286,13 +286,13 @@ public async ValueTask DisposeAsync() } catch { - // el sweeper ya está terminando + // the sweeper is already terminating } - // Esperar las cargas y los desalojos en vuelo antes de drenar el resto, para que la liberación - // sea DETERMINISTA respecto al retorno de DisposeAsync (finding F). Como `_disposed` ya es true, - // ninguna carga posterior registra en `_actors`: las que estaban en vuelo o bien ya registraron - // (capturadas en `all`), o ven `_disposed` y liberan su actor ellas mismas (await, no fire-and-forget). + // Wait for the in-flight loads and evictions before draining the rest, so that the release + // is DETERMINISTIC with respect to DisposeAsync's return (finding F). Since `_disposed` is already true, + // no later load registers in `_actors`: the ones that were in flight either already registered + // (captured in `all`), or see `_disposed` and release their actor themselves (await, not fire-and-forget). Task[] loading; Task[] inflight; List all; @@ -309,7 +309,7 @@ public async ValueTask DisposeAsync() } catch { - // una carga durante el apagado lanza ObjectDisposedException tras liberar su actor; no bloquea + // a load during shutdown throws ObjectDisposedException after releasing its actor; it does not block } try { @@ -317,7 +317,7 @@ public async ValueTask DisposeAsync() } catch { - // cada desalojo reporta su propio fallo; no bloquear el cierre + // each eviction reports its own failure; do not block the shutdown } foreach (DocumentActor a in all) @@ -328,7 +328,7 @@ public async ValueTask DisposeAsync() } catch { - // liberar el resto pese a un fallo aislado + // release the rest despite an isolated failure } } diff --git a/src/Weft.Core/Concurrency/DocumentBrokerOptions.cs b/src/Weft.Core/Concurrency/DocumentBrokerOptions.cs index 20a4b36..29a3c28 100644 --- a/src/Weft.Core/Concurrency/DocumentBrokerOptions.cs +++ b/src/Weft.Core/Concurrency/DocumentBrokerOptions.cs @@ -1,35 +1,35 @@ namespace Weft.Concurrency; /// -/// Opciones de ciclo de vida del : cuándo desalojar documentos inactivos, -/// cuántos mantener activos a la vez (desalojo LRU al superarlo) y cómo persistir antes de desalojar. -/// Inmutable tras construir el broker. +/// Lifecycle options of the : when to evict idle documents, +/// how many to keep active at once (LRU eviction when exceeded) and how to persist before evicting. +/// Immutable after the broker is constructed. /// public sealed class DocumentBrokerOptions { /// - /// Tiempo sin actividad tras el cual un documento activo es candidato a desalojo. Un documento se - /// reabre después desde su estado persistido (vía el loader de ). + /// Time without activity after which an active document becomes a candidate for eviction. A document is + /// then reopened from its persisted state (via the loader of ). /// public TimeSpan IdleEviction { get; init; } = TimeSpan.FromMinutes(5); /// - /// Máximo de documentos activos simultáneos. Al superarse, se desaloja el menos recientemente usado - /// (LRU) para acotar la memoria (SC-006). + /// Maximum of simultaneously active documents. When exceeded, the least recently used + /// (LRU) one is evicted to bound memory (SC-006). /// public int MaxActiveDocuments { get; init; } = 1024; /// - /// Hook invocado antes de liberar un documento desalojado, con su estado exportado, para persistirlo. - /// El desalojo espera a que termine. No se invoca cuando el documento se desaloja por fallo del actor - /// (estado potencialmente inválido). null = no persistir (los cambios no guardados se pierden - /// al desalojar). + /// Hook invoked before releasing an evicted document, with its exported state, to persist it. + /// The eviction waits for it to finish. It is not invoked when the document is evicted due to an actor + /// fault (potentially invalid state). null = do not persist (unsaved changes are lost + /// on eviction). /// public Func? OnEvicting { get; init; } /// - /// Cadencia del barrido de inactividad. El broker revisa periódicamente si hay documentos que superan - /// . Por defecto, un tercio de (acotado a [1s, 60s]). + /// Cadence of the idle sweep. The broker periodically checks whether there are documents exceeding + /// . By default, a third of (bounded to [1s, 60s]). /// public TimeSpan? IdleSweepInterval { get; init; } diff --git a/src/Weft.Core/Concurrency/DocumentSession.cs b/src/Weft.Core/Concurrency/DocumentSession.cs index 8e815ca..3af8169 100644 --- a/src/Weft.Core/Concurrency/DocumentSession.cs +++ b/src/Weft.Core/Concurrency/DocumentSession.cs @@ -1,11 +1,11 @@ namespace Weft.Concurrency; /// -/// Fachada asíncrona de un documento gestionado por el . Espejo de -/// donde cada llamada se encola al actor del documento y se ejecuta serializada -/// (constitución P-V). Varias sesiones pueden compartir el mismo documento; todas reciben el evento -/// . No expone el subyacente salvo, transitoriamente, -/// dentro del delegado de . +/// Asynchronous facade of a document managed by the . Mirror of +/// where each call is enqueued to the document's actor and executed serialized +/// (constitution P-V). Several sessions can share the same document; all receive the +/// event. It does not expose the underlying except, +/// transiently, inside the delegate of . /// public sealed class DocumentSession : IAsyncDisposable { @@ -18,17 +18,17 @@ internal DocumentSession(DocumentActor actor) DocId = actor.DocId; } - /// Identificador lógico del documento. + /// Logical identifier of the document. public string DocId { get; } /// - /// Se dispara tras cada update aplicado al documento (propio o importado por otra sesión), con el - /// delta correspondiente. Pensado para relay/persistencia (M2). El handler se invoca dentro del turno - /// del actor: no debe bloquear esperando otra operación del mismo documento. + /// Fires after each update applied to the document (own or imported by another session), with the + /// corresponding delta. Intended for relay/persistence (M2). The handler is invoked within the actor's + /// turn: it must not block waiting for another operation of the same document. /// public event Action>? UpdateApplied; - /// Inserta texto en el campo indicado (encolado y serializado). + /// Inserts text into the given field (enqueued and serialized). public async ValueTask InsertTextAsync(string field, int index, string text, CancellationToken ct = default) { ArgumentException.ThrowIfNullOrEmpty(field); @@ -39,7 +39,7 @@ public async ValueTask InsertTextAsync(string field, int index, string text, Can .ConfigureAwait(false); } - /// Borra texto del campo indicado (encolado y serializado). + /// Deletes text from the given field (enqueued and serialized). public async ValueTask DeleteTextAsync(string field, int index, int length, CancellationToken ct = default) { ArgumentException.ThrowIfNullOrEmpty(field); @@ -50,7 +50,7 @@ public async ValueTask DeleteTextAsync(string field, int index, int length, Canc .ConfigureAwait(false); } - /// Lee el contenido completo del campo indicado. + /// Reads the full content of the given field. public ValueTask GetTextAsync(string field, CancellationToken ct = default) { ArgumentException.ThrowIfNullOrEmpty(field); @@ -58,48 +58,48 @@ public ValueTask GetTextAsync(string field, CancellationToken ct = defau return _actor.EnqueueAsync(doc => doc.GetText(field), mutating: false, ct); } - /// Exporta el estado completo del documento (base del content-addressing). + /// Exports the full state of the document (basis of content-addressing). public ValueTask ExportStateAsync(CancellationToken ct = default) { ThrowIfDisposed(); return _actor.EnqueueAsync(doc => doc.ExportState(), mutating: false, ct); } - /// Exporta el state vector ("qué conozco") para sync incremental. + /// Exports the state vector ("what I know") for incremental sync. public ValueTask ExportStateVectorAsync(CancellationToken ct = default) { ThrowIfDisposed(); return _actor.EnqueueAsync(doc => doc.ExportStateVector(), mutating: false, ct); } - /// Exporta el delta con los cambios que el emisor del state vector no conoce. + /// Exports the delta with the changes the sender of the state vector does not know. public ValueTask ExportUpdateSinceAsync(ReadOnlyMemory stateVector, CancellationToken ct = default) { ThrowIfDisposed(); - byte[] sv = stateVector.ToArray(); // copia defensiva: el buffer del llamador puede cambiar antes del turno + byte[] sv = stateVector.ToArray(); // defensive copy: the caller's buffer may change before the turn return _actor.EnqueueAsync(doc => doc.ExportUpdateSince(sv), mutating: false, ct); } - /// Fusiona un update/estado de otra réplica (convergente); dispara . + /// Merges an update/state from another replica (convergent); fires . public async ValueTask ApplyUpdateAsync(ReadOnlyMemory update, CancellationToken ct = default) { ThrowIfDisposed(); - byte[] u = update.ToArray(); // copia defensiva (ver ExportUpdateSinceAsync) + byte[] u = update.ToArray(); // defensive copy (see ExportUpdateSinceAsync) await _actor.EnqueueAsync(doc => { doc.ApplyUpdate(u); return true; }, mutating: true, ct) .ConfigureAwait(false); } /// - /// Aplica un update y DEVUELVE su delta en el mismo turno del actor. Pensado para el relay con - /// persist-before-broadcast (FU-010): el delta se captura como valor de retorno —race-free frente a - /// varias conexiones concurrentes del mismo documento— para difundirlo tras persistir, en vez de - /// depender del evento (que se dispara dentro del turno, antes de - /// persistir). El delta es vacío si el update no aportó cambios nuevos (idempotente). + /// Applies an update and RETURNS its delta within the same actor turn. Intended for the relay with + /// persist-before-broadcast (FU-010): the delta is captured as a return value —race-free against + /// several concurrent connections of the same document— to broadcast it after persisting, instead of + /// relying on the event (which fires within the turn, before + /// persisting). The delta is empty if the update brought no new changes (idempotent). /// public ValueTask ApplyAndCaptureDeltaAsync(ReadOnlyMemory update, CancellationToken ct = default) { ThrowIfDisposed(); - byte[] u = update.ToArray(); // copia defensiva (ver ExportUpdateSinceAsync) + byte[] u = update.ToArray(); // defensive copy (see ExportUpdateSinceAsync) return _actor.EnqueueAsync( doc => { @@ -112,9 +112,9 @@ public ValueTask ApplyAndCaptureDeltaAsync(ReadOnlyMemory update, } /// - /// Ejecuta un delegado como turno atómico respecto a las demás operaciones del mismo documento - /// (transacción lógica). El recibido NO debe capturarse ni usarse fuera del - /// delegado: solo es válido durante la ejecución del turno. + /// Executes a delegate as an atomic turn with respect to the other operations of the same document + /// (logical transaction). The received must NOT be captured or used outside the + /// delegate: it is only valid during the execution of the turn. /// public ValueTask ExecuteAsync(Func operation, CancellationToken ct = default) { @@ -129,8 +129,8 @@ public ValueTask ExecuteAsync(Func operation, CancellationTok private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(_disposed, this); - /// Cierra la sesión: deja de recibir eventos y libera su referencia en el actor. No desaloja - /// el documento (su ciclo de vida lo gestiona el broker por inactividad/LRU). + /// Closes the session: stops receiving events and releases its reference in the actor. It does not + /// evict the document (its lifecycle is managed by the broker via idle/LRU). public ValueTask DisposeAsync() { if (_disposed) diff --git a/src/Weft.Core/WeftException.cs b/src/Weft.Core/WeftException.cs index 261298d..db497c0 100644 --- a/src/Weft.Core/WeftException.cs +++ b/src/Weft.Core/WeftException.cs @@ -1,61 +1,61 @@ namespace Weft; -/// Código de error del motor CRDT tal como cruza la frontera FFI. +/// Error code of the CRDT engine as it crosses the FFI boundary. public enum WeftErrorCode { - /// Blob/update no decodificable. + /// Blob/update not decodable. Decode, - /// Fallo aplicando un update. + /// Failure applying an update. Apply, - /// Texto de entrada no es UTF-8 válido. + /// Input text is not valid UTF-8. Utf8, - /// Índice/longitud fuera de rango. + /// Index/length out of range. OutOfBounds, - /// Un panic del motor fue capturado en la frontera (P-I). + /// An engine panic was captured at the boundary (P-I). Panic, } -/// Base de toda excepción de Weft. +/// Base of every Weft exception. public class WeftException : Exception { - /// Crea una excepción con mensaje. + /// Creates an exception with a message. public WeftException(string message) : base(message) { } - /// Crea una excepción con mensaje y causa. + /// Creates an exception with a message and cause. public WeftException(string message, Exception innerException) : base(message, innerException) { } } -/// Un blob o update no se pudo decodificar (código FFI DECODE). +/// A blob or update could not be decoded (FFI code DECODE). public sealed class CorruptUpdateException : WeftException { - /// Crea la excepción con un mensaje por defecto. + /// Creates the exception with a default message. public CorruptUpdateException() - : base("El blob o update no es decodificable (formato corrupto o incompatible).") { } + : base("The blob or update is not decodable (corrupt or incompatible format).") { } - /// Crea la excepción con un mensaje explícito. + /// Creates the exception with an explicit message. public CorruptUpdateException(string message) : base(message) { } } -/// Error del motor CRDT a través de la frontera (apply/utf8/panic). +/// CRDT engine error across the boundary (apply/utf8/panic). public sealed class WeftEngineException : WeftException { - /// Código de error tipificado del motor. + /// Typed error code of the engine. public WeftErrorCode ErrorCode { get; } - /// Crea la excepción con su código y mensaje. + /// Creates the exception with its code and message. public WeftEngineException(WeftErrorCode errorCode, string message) : base(message) { ErrorCode = errorCode; } } -/// La integridad de un blob content-addressed no verifica (usado por Weft.Versioning). +/// The integrity of a content-addressed blob does not verify (used by Weft.Versioning). public sealed class BlobIntegrityException : WeftException { - /// Crea la excepción con un mensaje explícito. + /// Creates the exception with an explicit message. public BlobIntegrityException(string message) : base(message) { } } diff --git a/src/Weft.Core/Yrs/DocHandle.cs b/src/Weft.Core/Yrs/DocHandle.cs index 7c70e42..3edaf96 100644 --- a/src/Weft.Core/Yrs/DocHandle.cs +++ b/src/Weft.Core/Yrs/DocHandle.cs @@ -3,15 +3,15 @@ namespace Weft.Yrs; /// -/// Handle seguro para el puntero opaco WeftDoc*. Un SafeHandle resuelve de -/// raíz las tres patologías de FFI: fuga (finalizador de respaldo), double-free (el runtime -/// garantiza un solo ) y use-after-free (ref-count durante la llamada -/// nativa vía ). +/// Safe handle for the opaque WeftDoc* pointer. A SafeHandle resolves at the +/// root the three FFI pathologies: leak (backing finalizer), double-free (the runtime +/// guarantees a single ) and use-after-free (ref-count during the native +/// call via ). /// /// -/// FRICCIÓN FFI (research R2): el source generator [LibraryImport] no marshala -/// SafeHandle (SYSLIB1051). Por eso las declaraciones P/Invoke usan nint crudo -/// y las llamadas prestan el puntero con . +/// FFI FRICTION (research R2): the source generator [LibraryImport] does not marshal +/// SafeHandle (SYSLIB1051). That is why the P/Invoke declarations use a raw nint +/// and the calls lend the pointer with . /// internal sealed class DocHandle : SafeHandleZeroOrMinusOneIsInvalid { @@ -19,23 +19,23 @@ internal sealed class DocHandle : SafeHandleZeroOrMinusOneIsInvalid protected override bool ReleaseHandle() { - // El documento se libera SOLO con la función del shim, nunca con el GC/Marshal (P-I). + // The document is freed ONLY with the shim function, never with the GC/Marshal (P-I). NativeMethods.weft_doc_free(handle); return true; } } /// -/// Presta el puntero crudo de un incrementando su ref-count mientras dura -/// la llamada nativa (equivalente manual al marshalling de SafeHandle que [LibraryImport] no -/// ofrece). Liberar con using: el ref-count se decrementa al salir del ámbito. +/// Lends the raw pointer of a by incrementing its ref-count for the duration +/// of the native call (manual equivalent of the SafeHandle marshalling that [LibraryImport] +/// does not offer). Release with using: the ref-count is decremented on leaving the scope. /// internal readonly ref struct HandleLease { private readonly DocHandle _handle; private readonly bool _added; - /// Puntero nativo válido durante la vida del lease. + /// Native pointer valid for the lifetime of the lease. public readonly nint Ptr; public HandleLease(DocHandle handle) diff --git a/src/Weft.Core/Yrs/FfiStatus.cs b/src/Weft.Core/Yrs/FfiStatus.cs index 1aa47a3..f7e4df2 100644 --- a/src/Weft.Core/Yrs/FfiStatus.cs +++ b/src/Weft.Core/Yrs/FfiStatus.cs @@ -1,9 +1,9 @@ namespace Weft.Yrs; /// -/// Traduce un código de estado i32 del shim a la excepción idiomática correspondiente -/// (mapeo de contracts/ffi-abi.md). Centralizado para que el mapeo sea verificable -/// end-to-end (p. ej. la ruta de panic, SC-009). +/// Translates an i32 status code from the shim into the corresponding idiomatic exception +/// (mapping from contracts/ffi-abi.md). Centralized so the mapping is verifiable +/// end-to-end (e.g. the panic path, SC-009). /// internal static class FfiStatus { @@ -13,20 +13,20 @@ internal static void ThrowIfError(int rc) { case 0: // WEFT_OK return; - case -1: // NULL_ARG — defensa; la capa C# valida antes de cruzar - throw new WeftException("Argumento nulo inesperado en la frontera FFI."); + case -1: // NULL_ARG — defense; the C# layer validates before crossing + throw new WeftException("Unexpected null argument at the FFI boundary."); case -2: // DECODE throw new CorruptUpdateException(); case -3: // APPLY - throw new WeftEngineException(WeftErrorCode.Apply, "El motor no pudo aplicar el update."); + throw new WeftEngineException(WeftErrorCode.Apply, "The engine could not apply the update."); case -4: // UTF8 - throw new WeftEngineException(WeftErrorCode.Utf8, "El texto de entrada no es UTF-8 válido."); + throw new WeftEngineException(WeftErrorCode.Utf8, "The input text is not valid UTF-8."); case -5: // OUT_OF_BOUNDS - throw new ArgumentOutOfRangeException("index", "El índice o la longitud están fuera de rango."); + throw new ArgumentOutOfRangeException("index", "The index or length is out of range."); case -127: // PANIC - throw new WeftEngineException(WeftErrorCode.Panic, "El motor sufrió un panic capturado en la frontera."); + throw new WeftEngineException(WeftErrorCode.Panic, "The engine hit a panic captured at the boundary."); default: - throw new WeftEngineException(WeftErrorCode.Apply, $"Código FFI desconocido del shim: {rc}."); + throw new WeftEngineException(WeftErrorCode.Apply, $"Unknown FFI code from the shim: {rc}."); } } } diff --git a/src/Weft.Core/Yrs/NativeLibraryResolver.cs b/src/Weft.Core/Yrs/NativeLibraryResolver.cs index 5a2f4c0..51b3db6 100644 --- a/src/Weft.Core/Yrs/NativeLibraryResolver.cs +++ b/src/Weft.Core/Yrs/NativeLibraryResolver.cs @@ -5,20 +5,20 @@ namespace Weft.Yrs; /// -/// Resuelve el cdylib weft_yrs_ffi por RID (research R11) y verifica que su -/// weft_abi_version coincide con la esperada por este binding — desalineación -/// paquete/binario falla ruidosamente al cargar, no de forma silenciosa. +/// Resolves the weft_yrs_ffi cdylib by RID (research R11) and verifies that its +/// weft_abi_version matches the one expected by this binding — a package/binary +/// mismatch fails loudly at load time, not silently. /// internal static class NativeLibraryResolver { - // ABI v2 (CHARTER-09): añade weft_doc_new_with_client_id (siembra determinista, FU-012). + // ABI v2 (CHARTER-09): adds weft_doc_new_with_client_id (deterministic seeding, FU-012). private const uint ExpectedAbiVersion = 2; private static int _registered; [System.Diagnostics.CodeAnalysis.SuppressMessage( "Usage", "CA2255:The 'ModuleInitializer' attribute should not be used in libraries", - Justification = "Registro único del DllImportResolver nativo antes de cualquier P/Invoke; " + - "patrón idiomático y deliberado para un binding nativo por RID.")] + Justification = "Single registration of the native DllImportResolver before any P/Invoke; " + + "idiomatic and deliberate pattern for a per-RID native binding.")] [ModuleInitializer] internal static void Register() { @@ -32,7 +32,7 @@ private static nint Resolve(string libraryName, Assembly assembly, DllImportSear { if (libraryName != NativeMethods.Lib) { - return nint.Zero; // otras libs las resuelve el runtime + return nint.Zero; // other libs are resolved by the runtime } string fileName = NativeFileName(); @@ -45,7 +45,7 @@ private static nint Resolve(string libraryName, Assembly assembly, DllImportSear } } - // Fallback: rutas por defecto del runtime (NATIVE_DLL_SEARCH_DIRECTORIES). + // Fallback: runtime default paths (NATIVE_DLL_SEARCH_DIRECTORIES). if (NativeLibrary.TryLoad(NativeMethods.Lib, assembly, searchPath, out nint fallback)) { VerifyAbi(fallback, NativeMethods.Lib); @@ -58,7 +58,7 @@ private static nint Resolve(string libraryName, Assembly assembly, DllImportSear private static IEnumerable Candidates(string fileName) { string baseDir = AppContext.BaseDirectory; - string rid = RuntimeInformation.RuntimeIdentifier; // p. ej. "linux-x64" o "fedora.44-x64" + string rid = RuntimeInformation.RuntimeIdentifier; // e.g. "linux-x64" or "fedora.44-x64" string portable = PortableRid(); yield return Path.Combine(baseDir, "runtimes", rid, "native", fileName); @@ -69,7 +69,7 @@ private static IEnumerable Candidates(string fileName) yield return Path.Combine(baseDir, fileName); } - /// RID portable canónico (SO + arquitectura) para el layout de paquete NuGet. + /// Canonical portable RID (OS + architecture) for the NuGet package layout. private static string PortableRid() { string os = @@ -106,7 +106,7 @@ private static unsafe void VerifyAbi(nint handle, string source) { NativeLibrary.Free(handle); throw new WeftException( - $"El binario nativo '{source}' no exporta weft_abi_version: no es un shim de Weft válido."); + $"The native binary '{source}' does not export weft_abi_version: it is not a valid Weft shim."); } uint actual = ((delegate* unmanaged)fn)(); @@ -114,8 +114,8 @@ private static unsafe void VerifyAbi(nint handle, string source) { NativeLibrary.Free(handle); throw new WeftException( - $"ABI del shim nativo '{source}' = {actual}, se esperaba {ExpectedAbiVersion}. " + - "Reinstala el paquete de Weft con los binarios nativos correctos."); + $"ABI of the native shim '{source}' = {actual}, expected {ExpectedAbiVersion}. " + + "Reinstall the Weft package with the correct native binaries."); } } } diff --git a/src/Weft.Core/Yrs/NativeMethods.cs b/src/Weft.Core/Yrs/NativeMethods.cs index 511bdb4..ba5bb64 100644 --- a/src/Weft.Core/Yrs/NativeMethods.cs +++ b/src/Weft.Core/Yrs/NativeMethods.cs @@ -3,23 +3,23 @@ namespace Weft.Yrs; /// -/// Declaraciones P/Invoke sobre la C-ABI del shim weft-yrs-ffi, generadas por el source -/// generator (marshalling en compilación, sin stubs IL). -/// Deben coincidir con native/weft-yrs-ffi/include/weft_ffi.h, que es la fuente de verdad del -/// contrato; HeaderBindingParityTests lo valida (paridad sintáctica: conjunto de funciones, -/// aridad, orden y tipos — no la semántica ni el ownership, que cubren ASan y los round-trips). +/// P/Invoke declarations over the C-ABI of the weft-yrs-ffi shim, generated by the source +/// generator (compile-time marshalling, no IL stubs). +/// They must match native/weft-yrs-ffi/include/weft_ffi.h, which is the source of truth of the +/// contract; HeaderBindingParityTests validates it (syntactic parity: function set, +/// arity, order and types — not the semantics or ownership, which ASan and the round-trips cover). /// /// -/// Convenciones de marshalling: bytes de ENTRADA como (pin -/// automático, cero copias); bytes de SALIDA como out nint + out nuint (memoria de -/// Rust, se copia a gestionada y se libera con ); documento como -/// nint crudo prestado por (SYSLIB1051, research R2). +/// Marshalling conventions: INPUT bytes as (automatic pin, +/// zero copies); OUTPUT bytes as out nint + out nuint (Rust memory, +/// copied to managed and freed with ); document as +/// a raw nint lent by (SYSLIB1051, research R2). /// internal static partial class NativeMethods { internal const string Lib = "weft_yrs_ffi"; - // ── Ciclo de vida ── + // ── Lifecycle ── [LibraryImport(Lib)] internal static partial int weft_doc_new(out nint outDoc); @@ -32,7 +32,7 @@ internal static partial class NativeMethods [LibraryImport(Lib)] internal static partial void weft_doc_free(nint doc); - // ── Texto ── + // ── Text ── [LibraryImport(Lib)] internal static partial int weft_text_insert( nint doc, ReadOnlySpan field, nuint fieldLen, @@ -46,7 +46,7 @@ internal static partial int weft_text_delete( internal static partial int weft_text_read( nint doc, ReadOnlySpan field, nuint fieldLen, out nint outPtr, out nuint outLen); - // ── Estado y sincronización ── + // ── State and synchronization ── [LibraryImport(Lib)] internal static partial int weft_doc_export_state(nint doc, out nint outPtr, out nuint outLen); @@ -60,11 +60,11 @@ internal static partial int weft_doc_export_since( [LibraryImport(Lib)] internal static partial int weft_doc_apply_update(nint doc, ReadOnlySpan update, nuint updateLen); - // ── Memoria ── + // ── Memory ── [LibraryImport(Lib)] internal static partial void weft_buf_free(nint ptr, nuint len); - // ── Diagnóstico ── + // ── Diagnostics ── [LibraryImport(Lib)] internal static partial uint weft_abi_version(); } diff --git a/src/Weft.Core/Yrs/YrsDeterministicSeeding.cs b/src/Weft.Core/Yrs/YrsDeterministicSeeding.cs index 574cb17..3285ad4 100644 --- a/src/Weft.Core/Yrs/YrsDeterministicSeeding.cs +++ b/src/Weft.Core/Yrs/YrsDeterministicSeeding.cs @@ -1,8 +1,8 @@ namespace Weft.Yrs; /// -/// Siembra determinista para : fija el client_id del documento. El -/// dominio válido es < 2^53 (encoding de client-ids de 53 bits de yrs 0.26+). +/// Deterministic seeding for : fixes the document's client_id. The +/// valid domain is < 2^53 (yrs 0.26+ 53-bit client-id encoding). /// internal sealed class YrsDeterministicSeeding : IDeterministicSeeding { diff --git a/src/Weft.Core/Yrs/YrsDoc.cs b/src/Weft.Core/Yrs/YrsDoc.cs index 2f74b69..53e7a3e 100644 --- a/src/Weft.Core/Yrs/YrsDoc.cs +++ b/src/Weft.Core/Yrs/YrsDoc.cs @@ -4,9 +4,9 @@ namespace Weft.Yrs; /// -/// Documento CRDT respaldado por yrs. Envoltorio gestionado sobre que -/// esconde punteros, longitudes y liberación manual. NO es thread-safe (constitución P-V): el dueño -/// serializa el acceso; el DocumentBroker (M1) lo garantiza para acceso compartido. +/// CRDT document backed by yrs. Managed wrapper over that +/// hides pointers, lengths and manual freeing. NOT thread-safe (constitution P-V): the owner +/// serializes access; the DocumentBroker (M1) guarantees it for shared access. /// internal sealed class YrsDoc : ICrdtDoc { @@ -23,9 +23,9 @@ internal static YrsDoc Create() } /// - /// Crea un doc con un FIJO (siembra determinista para paridad - /// cross-implementación con Yjs; FU-012). Debe caber en 53 bits (encoding de yrs 0.26+); - /// un valor mayor lanza vía WEFT_ERR_OUT_OF_BOUNDS. + /// Creates a doc with a FIXED (deterministic seeding for + /// cross-implementation parity with Yjs; FU-012). Must fit in 53 bits (yrs 0.26+ encoding); + /// a larger value throws via WEFT_ERR_OUT_OF_BOUNDS. /// internal static YrsDoc Create(ulong clientId) { @@ -112,9 +112,9 @@ public void ApplyUpdate(ReadOnlySpan update) private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(_handle.IsClosed, this); /// - /// Copia un buffer cuya memoria pertenece a Rust a un byte[] gestionado y lo devuelve a - /// Rust con weft_buf_free. Punto exacto del contrato de ownership (P-I): el GC jamás - /// toca esa memoria. + /// Copies a buffer whose memory belongs to Rust into a managed byte[] and returns it to + /// Rust with weft_buf_free. The exact point of the ownership contract (P-I): the GC never + /// touches that memory. /// private static byte[] TakeOwnedBuffer(nint ptr, nuint len) { diff --git a/src/Weft.Core/Yrs/YrsEngine.cs b/src/Weft.Core/Yrs/YrsEngine.cs index 355988d..ac9710f 100644 --- a/src/Weft.Core/Yrs/YrsEngine.cs +++ b/src/Weft.Core/Yrs/YrsEngine.cs @@ -1,18 +1,18 @@ namespace Weft.Yrs; /// -/// Motor CRDT respaldado por yrs vía el shim weft-yrs-ffi. Sin estado propio: el -/// motor es una fábrica de documentos. es null (yrs no ofrece -/// versionado nativo; el versionado vive en la capa de dominio engine-agnóstica). +/// CRDT engine backed by yrs via the weft-yrs-ffi shim. Stateless: the +/// engine is a document factory. is null (yrs offers no +/// native versioning; versioning lives in the engine-agnostic domain layer). /// public sealed class YrsEngine : ICrdtEngine { private YrsEngine() { } - /// Instancia compartida del motor (sin estado, thread-safe). + /// Shared engine instance (stateless, thread-safe). public static YrsEngine Instance { get; } = new(); - /// Nombre estable del motor; fuente única compartida con . + /// Stable engine name; single source shared with . internal const string EngineName = "yrs"; /// @@ -28,11 +28,11 @@ private YrsEngine() { } public ICrdtDoc CreateDoc() => YrsDoc.Create(); /// - /// Crea un documento con un FIJO. Habilita la paridad byte-idéntica - /// cross-implementación con Yjs (gate determinism-yjs, FU-012), que exige client-ids - /// deterministas. El id debe caber en 53 bits (encoding de yrs 0.26+). Este método concreto se - /// conserva; la capacidad cross-engine equivalente vive en - /// (CHARTER-13/FU-016), que Loro también implementa vía set_peer_id. + /// Creates a document with a FIXED . Enables byte-identical + /// cross-implementation parity with Yjs (gate determinism-yjs, FU-012), which requires + /// deterministic client-ids. The id must fit in 53 bits (yrs 0.26+ encoding). This concrete method + /// is kept; the equivalent cross-engine capability lives in + /// (CHARTER-13/FU-016), which Loro also implements via set_peer_id. /// public ICrdtDoc CreateDoc(ulong clientId) => YrsDoc.Create(clientId); diff --git a/src/Weft.Loro/Interop/DocHandle.cs b/src/Weft.Loro/Interop/DocHandle.cs index 000b80c..dad527a 100644 --- a/src/Weft.Loro/Interop/DocHandle.cs +++ b/src/Weft.Loro/Interop/DocHandle.cs @@ -2,7 +2,7 @@ namespace Weft.Loro.Interop; -/// Handle seguro para el puntero opaco WeftLoroDoc* (mismo patrón que Weft.Yrs). +/// Safe handle for the opaque WeftLoroDoc* pointer (same pattern as Weft.Yrs). internal sealed class DocHandle : SafeHandleZeroOrMinusOneIsInvalid { public DocHandle(nint handle) : base(ownsHandle: true) => SetHandle(handle); @@ -14,7 +14,7 @@ protected override bool ReleaseHandle() } } -/// Presta el puntero crudo con ref-count durante la llamada nativa (SYSLIB1051, research R2). +/// Leases the raw pointer with ref-count for the duration of the native call (SYSLIB1051, research R2). internal readonly ref struct HandleLease { private readonly DocHandle _handle; diff --git a/src/Weft.Loro/Interop/FfiStatus.cs b/src/Weft.Loro/Interop/FfiStatus.cs index b88aa11..5a7b786 100644 --- a/src/Weft.Loro/Interop/FfiStatus.cs +++ b/src/Weft.Loro/Interop/FfiStatus.cs @@ -1,6 +1,6 @@ namespace Weft.Loro.Interop; -/// Traduce un código de estado del shim Loro a la excepción idiomática (mismo mapeo que Weft.Yrs). +/// Translates a status code from the Loro shim into the idiomatic exception (same mapping as Weft.Yrs). internal static class FfiStatus { internal static void ThrowIfError(int rc) @@ -10,19 +10,19 @@ internal static void ThrowIfError(int rc) case 0: return; case -1: - throw new WeftException("Argumento nulo inesperado en la frontera FFI (Loro)."); + throw new WeftException("Unexpected null argument at the FFI boundary (Loro)."); case -2: throw new CorruptUpdateException(); case -3: - throw new WeftEngineException(WeftErrorCode.Apply, "El motor Loro no pudo aplicar el update."); + throw new WeftEngineException(WeftErrorCode.Apply, "The Loro engine could not apply the update."); case -4: - throw new WeftEngineException(WeftErrorCode.Utf8, "El texto de entrada no es UTF-8 válido."); + throw new WeftEngineException(WeftErrorCode.Utf8, "The input text is not valid UTF-8."); case -5: - throw new ArgumentOutOfRangeException("index", "El índice o la longitud están fuera de rango."); + throw new ArgumentOutOfRangeException("index", "The index or length is out of range."); case -127: - throw new WeftEngineException(WeftErrorCode.Panic, "El motor Loro sufrió un panic capturado en la frontera."); + throw new WeftEngineException(WeftErrorCode.Panic, "The Loro engine hit a panic captured at the boundary."); default: - throw new WeftEngineException(WeftErrorCode.Apply, $"Código FFI desconocido del shim Loro: {rc}."); + throw new WeftEngineException(WeftErrorCode.Apply, $"Unknown FFI code from the Loro shim: {rc}."); } } } diff --git a/src/Weft.Loro/Interop/NativeLibraryResolver.cs b/src/Weft.Loro/Interop/NativeLibraryResolver.cs index 59c7c45..e79db8c 100644 --- a/src/Weft.Loro/Interop/NativeLibraryResolver.cs +++ b/src/Weft.Loro/Interop/NativeLibraryResolver.cs @@ -4,16 +4,16 @@ namespace Weft.Loro.Interop; -/// Resuelve el cdylib weft_loro_ffi por RID y verifica su ABI (igual que Weft.Yrs). +/// Resolves the weft_loro_ffi cdylib per RID and verifies its ABI (same as Weft.Yrs). internal static class NativeLibraryResolver { - // ABI v2 (CHARTER-10): + probes de versionado nativo (INativeVersioning, FU-006). + // ABI v2 (CHARTER-10): + native versioning probes (INativeVersioning, FU-006). private const uint ExpectedAbiVersion = 3; private static int _registered; [System.Diagnostics.CodeAnalysis.SuppressMessage( "Usage", "CA2255:The 'ModuleInitializer' attribute should not be used in libraries", - Justification = "Registro único del DllImportResolver nativo; patrón idiomático de binding por RID.")] + Justification = "Single registration of the native DllImportResolver; idiomatic per-RID binding pattern.")] [ModuleInitializer] internal static void Register() { @@ -95,14 +95,14 @@ private static unsafe void VerifyAbi(nint handle, string source) if (!NativeLibrary.TryGetExport(handle, "weft_loro_abi_version", out nint fn)) { NativeLibrary.Free(handle); - throw new WeftException($"El binario nativo '{source}' no exporta weft_loro_abi_version."); + throw new WeftException($"The native binary '{source}' does not export weft_loro_abi_version."); } uint actual = ((delegate* unmanaged)fn)(); if (actual != ExpectedAbiVersion) { NativeLibrary.Free(handle); throw new WeftException( - $"ABI del shim Loro '{source}' = {actual}, se esperaba {ExpectedAbiVersion}."); + $"ABI of the Loro shim '{source}' = {actual}, expected {ExpectedAbiVersion}."); } } } diff --git a/src/Weft.Loro/Interop/NativeMethods.cs b/src/Weft.Loro/Interop/NativeMethods.cs index e8e0ce4..4ec8b20 100644 --- a/src/Weft.Loro/Interop/NativeMethods.cs +++ b/src/Weft.Loro/Interop/NativeMethods.cs @@ -2,7 +2,7 @@ namespace Weft.Loro.Interop; -/// P/Invoke sobre la C-ABI del shim weft-loro-ffi (simétrica a weft-yrs-ffi). +/// P/Invoke over the C-ABI of the weft-loro-ffi shim (symmetric to weft-yrs-ffi). internal static partial class NativeMethods { internal const string Lib = "weft_loro_ffi"; @@ -45,7 +45,7 @@ internal static partial int weft_loro_doc_export_since( [LibraryImport(Lib)] internal static partial int weft_loro_doc_apply_update(nint doc, ReadOnlySpan update, nuint updateLen); - // ── Versionado nativo (INativeVersioning, capacidad opcional — CHARTER-10/FU-006) ── + // ── Native versioning (INativeVersioning, optional capability — CHARTER-10/FU-006) ── [LibraryImport(Lib)] internal static partial int weft_loro_shallow_snapshot(nint doc, out nint outPtr, out nuint outLen); diff --git a/src/Weft.Loro/LoroDeterministicSeeding.cs b/src/Weft.Loro/LoroDeterministicSeeding.cs index 74ab29f..9aa6580 100644 --- a/src/Weft.Loro/LoroDeterministicSeeding.cs +++ b/src/Weft.Loro/LoroDeterministicSeeding.cs @@ -1,8 +1,8 @@ namespace Weft.Loro; /// -/// Siembra determinista para : fija el peer_id del documento. El -/// dominio válido es todo ulong salvo (reservado por Loro). +/// Deterministic seeding for : pins the document's peer_id. The +/// valid domain is every ulong except (reserved by Loro). /// internal sealed class LoroDeterministicSeeding : IDeterministicSeeding { diff --git a/src/Weft.Loro/LoroDoc.cs b/src/Weft.Loro/LoroDoc.cs index 9e0dd73..88b059a 100644 --- a/src/Weft.Loro/LoroDoc.cs +++ b/src/Weft.Loro/LoroDoc.cs @@ -4,7 +4,7 @@ namespace Weft.Loro; -/// Documento CRDT respaldado por Loro. Envoltorio gestionado sobre el shim weft-loro-ffi. +/// CRDT document backed by Loro. Managed wrapper over the weft-loro-ffi shim. internal sealed class LoroDoc : ICrdtDoc { private readonly DocHandle _handle; @@ -99,9 +99,9 @@ public void ApplyUpdate(ReadOnlySpan update) FfiStatus.ThrowIfError(NativeMethods.weft_loro_doc_apply_update(lease.Ptr, update, (nuint)update.Length)); } - // ── Versionado nativo (INativeVersioning vía LoroNativeVersioning — CHARTER-10/FU-006) ── - // Probes DEMOSTRATIVOS de la capacidad nativa de Loro. Su salida NO es determinista y NO alimenta - // VersionId (usar ExportState para eso). No mutan este documento (el branch/merge forkea aparte). + // ── Native versioning (INativeVersioning via LoroNativeVersioning — CHARTER-10/FU-006) ── + // DEMONSTRATIVE probes of Loro's native capability. Their output is NOT deterministic and does NOT feed + // VersionId (use ExportState for that). They do not mutate this document (branch/merge forks separately). internal byte[] ShallowSnapshotNative() { diff --git a/src/Weft.Loro/LoroEngine.cs b/src/Weft.Loro/LoroEngine.cs index 9794efa..5aa1db9 100644 --- a/src/Weft.Loro/LoroEngine.cs +++ b/src/Weft.Loro/LoroEngine.cs @@ -1,18 +1,18 @@ namespace Weft.Loro; /// -/// Motor CRDT respaldado por Loro (vía el shim weft-loro-ffi). Adaptador dual-path que prueba -/// la portabilidad de la abstracción (constitución P-IV): la misma suite de -/// versionado corre idéntica sobre yrs y Loro. +/// CRDT engine backed by Loro (via the weft-loro-ffi shim). Dual-path adapter that proves +/// the portability of the abstraction (constitution P-IV): the same +/// versioning suite runs identically over yrs and Loro. /// public sealed class LoroEngine : ICrdtEngine { private LoroEngine() { } - /// Instancia compartida del motor (sin estado, thread-safe). + /// Shared engine instance (stateless, thread-safe). public static LoroEngine Instance { get; } = new(); - /// Nombre estable del motor; fuente única compartida con . + /// Stable engine name; single source shared with . internal const string EngineName = "loro"; /// @@ -20,10 +20,10 @@ private LoroEngine() { } /// /// - /// Loro ofrece versionado nativo (diff/branch/shallow-snapshot); expuesto como - /// opcional vía probes demostrativos (CHARTER-10/FU-006). El - /// versionado del núcleo (content-addressed, engine-agnóstico) NO depende de estos probes; su salida - /// no es determinista y no alimenta VersionId. + /// Loro offers native versioning (diff/branch/shallow-snapshot); exposed as an optional + /// via demonstrative probes (CHARTER-10/FU-006). The + /// core versioning (content-addressed, engine-agnostic) does NOT depend on these probes; their output + /// is not deterministic and does not feed VersionId. /// public INativeVersioning? NativeVersioning => LoroNativeVersioning.Instance; diff --git a/src/Weft.Loro/LoroNativeVersioning.cs b/src/Weft.Loro/LoroNativeVersioning.cs index 92dc888..53dddef 100644 --- a/src/Weft.Loro/LoroNativeVersioning.cs +++ b/src/Weft.Loro/LoroNativeVersioning.cs @@ -1,11 +1,11 @@ namespace Weft.Loro; /// -/// Implementación de para Loro (CHARTER-10/FU-006). Probes -/// demostrativos de la capacidad de versionado nativo de Loro (diff/fork/shallow snapshot), -/// que yrs no tiene. No son content-addressing: su salida no es byte-determinista entre -/// réplicas y no alimenta VersionId (que usa el export determinista de ICrdtDoc.ExportState). -/// Sin estado; se expone como singleton vía . +/// Implementation of for Loro (CHARTER-10/FU-006). Demonstrative +/// probes of Loro's native versioning capability (diff/fork/shallow snapshot), +/// which yrs does not have. They are not content-addressing: their output is not byte-deterministic across +/// replicas and does not feed VersionId (which uses the deterministic export of ICrdtDoc.ExportState). +/// Stateless; exposed as a singleton via . /// internal sealed class LoroNativeVersioning : INativeVersioning { @@ -22,14 +22,14 @@ private LoroNativeVersioning() { } /// public byte[] ShallowSnapshot(ICrdtDoc doc) => AsLoro(doc).ShallowSnapshotNative(); - // El versionado nativo de Loro solo opera sobre documentos de Loro. Un doc de otro motor - // (p. ej. yrs) es un error de uso claro, no un fallo en la frontera nativa. + // Loro's native versioning only operates on Loro documents. A doc from another engine + // (e.g. yrs) is a clear usage error, not a failure at the native boundary. private static LoroDoc AsLoro(ICrdtDoc doc) { ArgumentNullException.ThrowIfNull(doc); return doc as LoroDoc ?? throw new ArgumentException( - $"El versionado nativo de Loro requiere un documento de Loro, no '{doc.GetType().Name}' (motor '{doc.EngineName}').", + $"Loro native versioning requires a Loro document, not '{doc.GetType().Name}' (engine '{doc.EngineName}').", nameof(doc)); } } diff --git a/src/Weft.Server.Persistence.EFCore/EFCoreDocumentStore.cs b/src/Weft.Server.Persistence.EFCore/EFCoreDocumentStore.cs index 8cf6a3b..ac29fe5 100644 --- a/src/Weft.Server.Persistence.EFCore/EFCoreDocumentStore.cs +++ b/src/Weft.Server.Persistence.EFCore/EFCoreDocumentStore.cs @@ -4,23 +4,23 @@ namespace Weft.Server.Persistence.EFCore; /// -/// respaldado por Entity Framework Core. Cada documento es un snapshot consolidado -/// (a lo sumo uno) más los updates incrementales acumulados desde él, en filas de WeftDocumentRecords. -/// Provider-agnóstico: el consumidor configura el provider vía . +/// backed by Entity Framework Core. Each document is a consolidated snapshot +/// (at most one) plus the incremental updates accumulated since it, in rows of WeftDocumentRecords. +/// Provider-agnostic: the consumer configures the provider via . /// /// /// -/// Concurrencia. Un por documento serializa todas las operaciones de -/// ese doc (igual que FileSystemDocumentStore): asigna el Seq monotónico sin carreras y evita la -/// contención de escritura del backend (p. ej. SQLITE_BUSY bajo appends concurrentes al mismo doc). -/// Documentos distintos usan semáforos distintos y progresan en paralelo. Cada operación abre su propio -/// vía la factory (un DbContext es una unidad de trabajo de un -/// solo uso, no es thread-safe). +/// Concurrency. One per document serializes all operations of +/// that doc (like FileSystemDocumentStore): it assigns the monotonic Seq without races and avoids +/// the backend's write contention (e.g. SQLITE_BUSY under concurrent appends to the same doc). +/// Different documents use different semaphores and progress in parallel. Each operation opens its own +/// via the factory (a DbContext is a single-use unit of work, +/// not thread-safe). /// /// -/// Compaction. borra los records del doc e inserta el snapshot como base -/// dentro de una transacción: un lector concurrente ve el estado previo íntegro o el nuevo, nunca una -/// mezcla parcial. +/// Compaction. deletes the doc's records and inserts the snapshot as base +/// inside a transaction: a concurrent reader sees either the whole previous state or the new one, never a +/// partial mix. /// /// public sealed class EFCoreDocumentStore : IDocumentStore @@ -28,7 +28,7 @@ public sealed class EFCoreDocumentStore : IDocumentStore private readonly IDbContextFactory _factory; private readonly ConcurrentDictionary _locks = new(StringComparer.Ordinal); - /// Crea el store sobre la factory de contextos configurada por el consumidor. + /// Creates the store over the context factory configured by the consumer. public EFCoreDocumentStore(IDbContextFactory contextFactory) { ArgumentNullException.ThrowIfNull(contextFactory); @@ -119,7 +119,7 @@ public async ValueTask SaveSnapshotAsync(string docId, ReadOnlyMemory stat await using WeftDocumentStoreContext ctx = await _factory.CreateDbContextAsync(ct).ConfigureAwait(false); await using var tx = await ctx.Database.BeginTransactionAsync(ct).ConfigureAwait(false); - // Compaction atómica: descarta lo acumulado e inserta el snapshot como base (Seq 0). + // Atomic compaction: discards what was accumulated and inserts the snapshot as base (Seq 0). await ctx.Records.Where(r => r.DocId == docId).ExecuteDeleteAsync(ct).ConfigureAwait(false); ctx.Records.Add(new DocumentRecord { diff --git a/src/Weft.Server.Persistence.EFCore/EFCoreServiceCollectionExtensions.cs b/src/Weft.Server.Persistence.EFCore/EFCoreServiceCollectionExtensions.cs index 32782eb..b73d0a0 100644 --- a/src/Weft.Server.Persistence.EFCore/EFCoreServiceCollectionExtensions.cs +++ b/src/Weft.Server.Persistence.EFCore/EFCoreServiceCollectionExtensions.cs @@ -4,16 +4,16 @@ namespace Microsoft.Extensions.DependencyInjection; -/// Registro DI del adaptador . +/// DI registration for the adapter. public static class WeftEFCoreServiceCollectionExtensions { /// - /// Registra como (singleton) sobre una - /// factory de . El consumidor elige el provider en - /// , p. ej. opts => opts.UseSqlite(connectionString). + /// Registers as (singleton) over a + /// factory. The consumer chooses the provider in + /// , e.g. opts => opts.UseSqlite(connectionString). /// - /// Colección de servicios. - /// Configura el (provider + conexión). + /// The service collection. + /// Configures the (provider + connection). public static IServiceCollection AddWeftEFCoreDocumentStore( this IServiceCollection services, Action configureContext) diff --git a/src/Weft.Server.Persistence.EFCore/WeftDocumentStoreContext.cs b/src/Weft.Server.Persistence.EFCore/WeftDocumentStoreContext.cs index 940cd0a..eb4cb10 100644 --- a/src/Weft.Server.Persistence.EFCore/WeftDocumentStoreContext.cs +++ b/src/Weft.Server.Persistence.EFCore/WeftDocumentStoreContext.cs @@ -2,46 +2,46 @@ namespace Weft.Server.Persistence.EFCore; -/// Clase de record persistido: distingue el snapshot consolidado de un update incremental. +/// Kind of persisted record: distinguishes the consolidated snapshot from an incremental update. internal enum RecordKind : byte { - /// Snapshot consolidado del documento (a lo sumo uno por doc, es la base del framing). + /// Consolidated snapshot of the document (at most one per doc; it is the base of the framing). Snapshot = 0, - /// Update incremental acumulado sobre el snapshot, en orden de . + /// Incremental update accumulated on top of the snapshot, in order of . Update = 1, } /// -/// Fila de estado durable de un documento. El es un blob opaco (P-IV): EF Core -/// nunca interpreta bytes de yrs. El orden de reconstrucción lo fija (monotónico por doc). +/// Durable state row of a document. The is an opaque blob (P-IV): EF Core +/// never interprets yrs bytes. The reconstruction order is fixed by (monotonic per doc). /// internal sealed class DocumentRecord { - /// Clave primaria sustituta generada por la base de datos. + /// Surrogate primary key generated by the database. public long Id { get; set; } - /// Identificador opaco del documento (puede contener cualquier carácter). + /// Opaque document identifier (may contain any character). public string DocId { get; set; } = default!; - /// Índice de orden dentro del documento; el snapshot es la base y los updates crecen desde ahí. + /// Ordering index within the document; the snapshot is the base and updates grow from there. public long Seq { get; set; } - /// Snapshot o update. + /// Snapshot or update. public RecordKind Kind { get; set; } - /// Bytes opacos del record (snapshot consolidado o un update incremental). + /// Opaque bytes of the record (consolidated snapshot or a single incremental update). public byte[] Payload { get; set; } = default!; } /// -/// del adaptador . Mapea una única tabla de records -/// (WeftDocumentRecords) indexada por (DocId, Seq). El provider (SQLite, PostgreSQL, …) lo elige -/// el consumidor vía ; este contexto es provider-agnóstico. +/// for the adapter. Maps a single record table +/// (WeftDocumentRecords) indexed by (DocId, Seq). The provider (SQLite, PostgreSQL, …) is chosen +/// by the consumer via ; this context is provider-agnostic. /// public sealed class WeftDocumentStoreContext : DbContext { - /// Crea el contexto con las opciones (provider + cadena de conexión) del consumidor. + /// Creates the context with the consumer's options (provider + connection string). public WeftDocumentStoreContext(DbContextOptions options) : base(options) { @@ -59,7 +59,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) record.HasKey(r => r.Id); record.Property(r => r.DocId).IsRequired(); record.Property(r => r.Payload).IsRequired(); - // El acceso siempre es "todos los records de un doc, en orden de Seq": índice compuesto que lo cubre. + // Access is always "all records of a doc, in Seq order": a composite index that covers it. record.HasIndex(r => new { r.DocId, r.Seq }); } } diff --git a/src/Weft.Server.Persistence.Redis/RedisDocumentStore.cs b/src/Weft.Server.Persistence.Redis/RedisDocumentStore.cs index b4bc19c..e13388b 100644 --- a/src/Weft.Server.Persistence.Redis/RedisDocumentStore.cs +++ b/src/Weft.Server.Persistence.Redis/RedisDocumentStore.cs @@ -5,21 +5,21 @@ namespace Weft.Server.Persistence.Redis; /// -/// respaldado por Redis (vía StackExchange.Redis). Cada documento usa dos -/// claves derivadas de un hash del docId opaco: un string con el snapshot consolidado y una -/// lista con los updates acumulados (en orden de RPUSH). Compatible con cualquier servidor -/// wire-Redis (Redis, Valkey). +/// backed by Redis (via StackExchange.Redis). Each document uses two +/// keys derived from a hash of the opaque docId: a string with the consolidated snapshot and a +/// list with the accumulated updates (in RPUSH order). Compatible with any wire-Redis server +/// (Redis, Valkey). /// /// /// -/// Claves. El docId es opaco (puede contener cualquier byte, incl. : y /): se mapea -/// por SHA-256 hex a un prefijo estable, sobre el que se cuelgan los sufijos :s (snapshot) y :u -/// (updates). El hash es hex fijo, así que ningún docId puede colisionar con las claves de otro. +/// Keys. The docId is opaque (may contain any byte, incl. : and /): it is mapped +/// by SHA-256 hex to a stable prefix, onto which the suffixes :s (snapshot) and :u +/// (updates) are appended. The hash is fixed hex, so no docId can collide with another's keys. /// /// -/// Atomicidad. es un RPUSH atómico de suyo. -/// hace la compaction en una transacción (MULTI/EXEC): fija el snapshot y borra la lista de updates -/// como una sola unidad, de modo que un lector concurrente nunca ve el snapshot nuevo junto a los updates viejos. +/// Atomicity. is an atomic RPUSH by itself. +/// performs the compaction in a transaction (MULTI/EXEC): it sets the snapshot and deletes the updates list +/// as a single unit, so a concurrent reader never sees the new snapshot alongside the old updates. /// /// public sealed class RedisDocumentStore : IDocumentStore @@ -31,10 +31,10 @@ public sealed class RedisDocumentStore : IDocumentStore private readonly string _keyPrefix; private readonly int _database; - /// Crea el store sobre una conexión Redis compartida. - /// Multiplexer de StackExchange.Redis (compartido, thread-safe). - /// Prefijo de todas las claves (aislamiento por instancia/entorno). - /// Índice de base de datos Redis; -1 usa la por defecto de la conexión. + /// Creates the store over a shared Redis connection. + /// StackExchange.Redis multiplexer (shared, thread-safe). + /// Prefix for all keys (isolation per instance/environment). + /// Redis database index; -1 uses the connection's default. public RedisDocumentStore(IConnectionMultiplexer redis, string keyPrefix = "weft:doc:", int database = -1) { ArgumentNullException.ThrowIfNull(redis); @@ -86,7 +86,7 @@ public async ValueTask SaveSnapshotAsync(string docId, ReadOnlyMemory stat IDatabase db = Db(); (RedisKey snapshotKey, RedisKey updatesKey) = Keys(docId); - // Compaction atómica: fija el snapshot y descarta los updates acumulados como una sola unidad. + // Atomic compaction: sets the snapshot and discards the accumulated updates as a single unit. ITransaction tx = db.CreateTransaction(); _ = tx.StringSetAsync(snapshotKey, state.ToArray()); _ = tx.KeyDeleteAsync(updatesKey); diff --git a/src/Weft.Server.Persistence.Redis/RedisServiceCollectionExtensions.cs b/src/Weft.Server.Persistence.Redis/RedisServiceCollectionExtensions.cs index 205b569..2d3769c 100644 --- a/src/Weft.Server.Persistence.Redis/RedisServiceCollectionExtensions.cs +++ b/src/Weft.Server.Persistence.Redis/RedisServiceCollectionExtensions.cs @@ -5,16 +5,16 @@ namespace Microsoft.Extensions.DependencyInjection; -/// Registro DI del adaptador . +/// DI registration for the adapter. public static class WeftRedisServiceCollectionExtensions { /// - /// Registra como (singleton). Si no hay un - /// ya registrado, crea uno con . + /// Registers as (singleton). If no + /// is already registered, creates one with . /// - /// Colección de servicios. - /// Cadena de conexión de StackExchange.Redis (p. ej. localhost:6379). - /// Prefijo de claves para aislar la instancia/entorno. + /// The service collection. + /// StackExchange.Redis connection string (e.g. localhost:6379). + /// Key prefix to isolate the instance/environment. public static IServiceCollection AddWeftRedisDocumentStore( this IServiceCollection services, string configuration, diff --git a/src/Weft.Server/Auth/IWeftAuthorizer.cs b/src/Weft.Server/Auth/IWeftAuthorizer.cs index 93ad43d..a7a3620 100644 --- a/src/Weft.Server/Auth/IWeftAuthorizer.cs +++ b/src/Weft.Server/Auth/IWeftAuthorizer.cs @@ -2,36 +2,36 @@ namespace Weft.Server.Auth; -/// Decisión de acceso de una conexión a un documento (FR-019). +/// Access decision of a connection to a document (FR-019). public enum WeftAccess { - /// Sin acceso: se rechaza con 403 antes del upgrade WebSocket (ningún byte de contenido viaja). + /// No access: rejected with 403 before the WebSocket upgrade (no content byte travels). Deny, - /// Lectura: recibe sync/updates/awareness; si envía un update de documento, se cierra con 1008. + /// Read: receives sync/updates/awareness; if it sends a document update, it is closed with 1008. ReadOnly, - /// Lectura y escritura: flujo y-sync completo. + /// Read and write: full y-sync flow. ReadWrite, } /// -/// Hook de autorización del consumidor (FR-019). Weft no conoce usuarios ni parsea tokens: el consumidor -/// decide el acceso con su propia identidad (JWT, cookies, headers…) a partir del de -/// la petición de upgrade. +/// Consumer's authorization hook (FR-019). Weft does not know users or parse tokens: the consumer +/// decides access with its own identity (JWT, cookies, headers…) from the of +/// the upgrade request. /// /// -/// La decisión es por-conexión (se re-evalúa en cada reconexión). La autorización nunca es opcional ni -/// por-defecto-permisiva: registrar el servidor sin una implementación de es un -/// fallo explícito al arrancar (SC-010). El enforcement de la decisión en el handshake (403 / cierre 1008 / -/// flujo completo) pertenece al connection handler (CHARTER-05); aquí se congela solo el contrato. +/// The decision is per-connection (re-evaluated on each reconnection). Authorization is never optional nor +/// permissive-by-default: registering the server without an implementation of is an +/// explicit failure at startup (SC-010). The enforcement of the decision in the handshake (403 / 1008 close / +/// full flow) belongs to the connection handler (CHARTER-05); here only the contract is frozen. /// public interface IWeftAuthorizer { - /// Decide el acceso de la conexión entrante al documento . - /// Contexto HTTP de la petición de upgrade (identidad, headers, cookies). - /// Identificador del documento solicitado (último segmento de la ruta, URL-decoded). - /// Token de cancelación de la petición. - /// El nivel de acceso concedido. + /// Decides the access of the incoming connection to document . + /// HTTP context of the upgrade request (identity, headers, cookies). + /// Identifier of the requested document (last path segment, URL-decoded). + /// Cancellation token of the request. + /// The granted access level. ValueTask AuthorizeAsync(HttpContext context, string docId, CancellationToken ct); } diff --git a/src/Weft.Server/DocumentHub.cs b/src/Weft.Server/DocumentHub.cs index a708ecc..777c959 100644 --- a/src/Weft.Server/DocumentHub.cs +++ b/src/Weft.Server/DocumentHub.cs @@ -6,10 +6,10 @@ namespace Weft.Server; /// -/// Punto de encuentro de todas las conexiones de un mismo documento. Mantiene una -/// por documento (el refcount de sesiones mantiene el documento residente -/// mientras haya conexiones). Aplica cada update, lo persiste y difunde su delta; el orden entre persistir y -/// difundir lo fija (FU-010). +/// Meeting point of all connections for a single document. Keeps one +/// per document (the session refcount keeps the document resident +/// while there are connections). Applies each update, persists it and broadcasts its delta; the order between +/// persisting and broadcasting is set by (FU-010). /// internal sealed class DocumentHub : IAsyncDisposable { @@ -24,26 +24,26 @@ public DocumentHub(string docId, DocumentSession session, IDocumentStore store, Session = session; _store = store; _durability = durability; - // El broadcast es EXPLÍCITO (ApplyAndPersistAsync), no vía el evento UpdateApplied: capturar el - // delta como valor de retorno del turno permite ordenar append/broadcast según el modo y es - // race-free entre conexiones concurrentes del mismo documento. El evento se conserva para otros - // consumidores de DocumentSession, pero el relay ya no depende de él. + // The broadcast is EXPLICIT (ApplyAndPersistAsync), not via the UpdateApplied event: capturing the + // delta as the return value of the turn allows ordering append/broadcast according to the mode and is + // race-free between concurrent connections of the same document. The event is kept for other + // DocumentSession consumers, but the relay no longer depends on it. } - /// Identificador del documento. + /// Document identifier. public string DocId { get; } - /// Sesión async compartida del documento (turno del actor serializado, P-V). + /// Shared async session of the document (serialized actor turn, P-V). public DocumentSession Session { get; } - /// Conexiones activas del documento. + /// Active connections of the document. public int ConnectionCount => _connections.Count; public void Add(WeftConnection connection) => _connections.TryAdd(connection, 0); public void Remove(WeftConnection connection) => _connections.TryRemove(connection, out _); - /// Pide el cierre de todas las conexiones del documento (su teardown las retira del hub). + /// Requests the close of all the document's connections (their teardown removes them from the hub). public void DisconnectAll() { foreach (WeftConnection c in _connections.Keys) @@ -53,8 +53,8 @@ public void DisconnectAll() } /// - /// Difunde un frame a las conexiones del documento, opcionalmente excluyendo el origen. Cada envío se aísla: - /// un fallo/backpressure de una conexión (que la cierra) no afecta a los pares. + /// Broadcasts a frame to the document's connections, optionally excluding the origin. Each send is isolated: + /// a failure/backpressure of one connection (which closes it) does not affect the peers. /// public void Broadcast(byte[] frame, WeftConnection? exclude) { @@ -68,15 +68,15 @@ public void Broadcast(byte[] frame, WeftConnection? exclude) } /// - /// Aplica un update entrante al documento (turno del actor), lo persiste y difunde su delta. El orden - /// entre persistir y difundir lo fija (FU-010). + /// Applies an incoming update to the document (actor turn), persists it and broadcasts its delta. The order + /// between persisting and broadcasting is set by (FU-010). /// /// - /// En , si el append falla, el update ya está en el - /// documento vivo pero NUNCA se difundió: los pares quedarían callados y desactualizados para siempre - /// (la próxima edición difunde solo su delta, no el que faltó). Por eso un fallo de append cierra - /// TODAS las conexiones del documento (el llamador las cierra con 1011): reconectan, mandan SyncStep1 - /// y el servidor —autoritativo, que sí tiene el update— reenvía el estado. Convergencia recuperada. + /// In , if the append fails, the update is already in the + /// live document but was NEVER broadcast: the peers would stay silent and out of date forever + /// (the next edit broadcasts only its own delta, not the one that was missed). That is why an append + /// failure closes ALL the document's connections (the caller closes them with 1011): they reconnect, send + /// SyncStep1, and the server —authoritative, which does hold the update— resends the state. Convergence recovered. /// public async ValueTask ApplyAndPersistAsync(byte[] update, CancellationToken ct) { @@ -89,15 +89,15 @@ public async ValueTask ApplyAndPersistAsync(byte[] update, CancellationToken ct) return; } - // PersistThenBroadcast (default): persistir antes de que ningún par lo vea. + // PersistThenBroadcast (default): persist before any peer sees it. try { await _store.AppendUpdateAsync(DocId, update, ct).ConfigureAwait(false); } catch { - // El update quedó aplicado pero sin difundir: cerrar el documento entero fuerza el re-sync - // autoritativo. Relanzar deja que el llamador cierre esta conexión con 1011. + // The update stayed applied but not broadcast: closing the whole document forces the authoritative + // re-sync. Rethrowing lets the caller close this connection with 1011. DisconnectAll(); throw; } @@ -105,9 +105,9 @@ public async ValueTask ApplyAndPersistAsync(byte[] update, CancellationToken ct) BroadcastDelta(delta); } - // El delta se difunde a TODAS las conexiones del documento. Reaplicar su propio delta en el origen es un - // no-op CRDT idempotente (los clientes Yjs lo toleran), lo que evita rastrear el origen dentro del turno del - // actor (que sería una carrera). El coste es un eco al emisor; aceptable para v1. + // The delta is broadcast to ALL the document's connections. Reapplying its own delta at the origin is an + // idempotent CRDT no-op (Yjs clients tolerate it), which avoids tracking the origin inside the actor turn + // (which would be a race). The cost is an echo to the sender; acceptable for v1. private void BroadcastDelta(byte[] delta) { if (delta.Length == 0) @@ -119,8 +119,8 @@ private void BroadcastDelta(byte[] delta) } /// - /// Cierra el hub: desuscribe el broadcast, consolida un snapshot (compaction) y libera la sesión (lo que - /// permite al broker desalojar el documento por inactividad). Idempotente. + /// Closes the hub: unsubscribes the broadcast, consolidates a snapshot (compaction) and releases the session + /// (which lets the broker evict the document for inactivity). Idempotent. /// public async ValueTask DisposeAsync() { @@ -131,14 +131,14 @@ public async ValueTask DisposeAsync() try { - // Snapshot de consolidación: el estado completo dentro del turno del actor reemplaza los updates - // acumulados en el store (compaction). Best-effort: un fallo no debe romper el cierre de la sesión. + // Consolidation snapshot: the full state within the actor turn replaces the updates + // accumulated in the store (compaction). Best-effort: a failure must not break the session close. byte[] snapshot = await Session.ExportStateAsync().ConfigureAwait(false); await _store.SaveSnapshotAsync(DocId, snapshot).ConfigureAwait(false); } catch { - // El desalojo del broker (OnEvicting) también persiste; la durabilidad no depende solo de aquí. + // The broker's eviction (OnEvicting) also persists; durability does not depend on this alone. } finally { diff --git a/src/Weft.Server/Persistence/DocumentStateFraming.cs b/src/Weft.Server/Persistence/DocumentStateFraming.cs index 10473a8..af6bc83 100644 --- a/src/Weft.Server/Persistence/DocumentStateFraming.cs +++ b/src/Weft.Server/Persistence/DocumentStateFraming.cs @@ -3,23 +3,23 @@ namespace Weft.Server.Persistence; /// -/// Framing compartido del estado persistido de un documento. Como los blobs de -/// son opacos (P-IV: el store no conoce tipos de yrs y no puede fusionar updates), el estado que -/// devuelve es una secuencia plana de records: el snapshot (si -/// existe) seguido de cada update acumulado desde ese snapshot, en orden de append. Cada record se enmarca -/// como un VarUint8Array lib0. +/// Shared framing of a document's persisted state. Since the blobs are +/// opaque (P-IV: the store does not know yrs types and cannot merge updates), the state that +/// returns is a flat sequence of records: the snapshot (if it +/// exists) followed by each update accumulated since that snapshot, in append order. Each record is framed +/// as a lib0 VarUint8Array. /// /// -/// El relay (CHARTER-05) reconstruye el documento aplicando cada record en orden a un doc fresco de yrs -/// (todos son operaciones apply_update idempotentes: reaplicar un update ya integrado es un no-op CRDT, -/// lo que hace la recuperación tolerante a un snapshot y updates que se solapen). Esta clase es el único punto -/// que conoce el formato del contenedor; los adaptadores lo comparten y la contract suite lo verifica. +/// The relay (CHARTER-05) reconstructs the document by applying each record in order to a fresh yrs doc +/// (all are idempotent apply_update operations: reapplying an already-integrated update is a CRDT no-op, +/// which makes recovery tolerant of a snapshot and updates that overlap). This class is the only point +/// that knows the container format; the adapters share it and the contract suite verifies it. /// public static class DocumentStateFraming { /// - /// Enmarca (opcional) y en una secuencia de records. - /// Devuelve null si no hay nada persistido (sin snapshot y sin updates) — la señal de "doc inexistente". + /// Frames (optional) and into a sequence of records. + /// Returns null if nothing is persisted (no snapshot and no updates) — the "nonexistent doc" signal. /// public static byte[]? Frame(byte[]? snapshot, IReadOnlyList updates) { @@ -43,9 +43,9 @@ public static class DocumentStateFraming } /// - /// Lee de vuelta los records de un estado enmarcado por . Cada elemento es una copia de - /// un record; se aplican en orden. Un contenedor truncado falla con - /// (la misma guarda estructural del códec). + /// Reads back the records of a state framed by . Each element is a copy of + /// a record; they are applied in order. A truncated container fails with + /// (the same structural guard of the codec). /// public static IReadOnlyList ReadRecords(ReadOnlySpan framed) { diff --git a/src/Weft.Server/Persistence/FileSystemDocumentStore.cs b/src/Weft.Server/Persistence/FileSystemDocumentStore.cs index 9737d7d..eee927f 100644 --- a/src/Weft.Server/Persistence/FileSystemDocumentStore.cs +++ b/src/Weft.Server/Persistence/FileSystemDocumentStore.cs @@ -6,28 +6,28 @@ namespace Weft.Server.Persistence; /// -/// respaldado por el sistema de archivos: persistencia durable para v1. Cada -/// documento vive en su propio directorio bajo la raíz configurada, con un archivo snapshot y un -/// archivo por update acumulado (u-{seq}). +/// backed by the file system: durable persistence for v1. Each +/// document lives in its own directory under the configured root, with a snapshot file and one +/// file per accumulated update (u-{seq}). /// /// /// -/// Atomicidad. Toda escritura (snapshot y cada update) se materializa con temp + rename: se -/// escribe a un archivo temporal, se vuelca a disco y se renombra sobre el destino final (rename es atómico en -/// POSIX y equivalente en Windows). Un update es su propio archivo con rename atómico —nunca un append -/// truncable—, así que un fallo a media escritura deja el archivo destino intacto o inexistente, jamás un -/// record corrupto que rompa la carga. +/// Atomicity. Every write (snapshot and each update) is materialized with temp + rename: it is +/// written to a temporary file, flushed to disk and renamed over the final target (rename is atomic on +/// POSIX and equivalent on Windows). An update is its own file with an atomic rename —never a truncatable +/// append—, so a failure mid-write leaves the target file intact or nonexistent, never a +/// corrupt record that breaks the load. /// /// -/// Compaction. reemplaza el snapshot atómicamente y luego borra todos -/// los archivos de update acumulados. El snapshot se escribe antes de borrar los updates: un fallo entre ambos -/// pasos solo deja updates que ya están incorporados al snapshot, y su reaplicación es un no-op CRDT -/// idempotente (ver ). +/// Compaction. replaces the snapshot atomically and then deletes all +/// the accumulated update files. The snapshot is written before deleting the updates: a failure between the two +/// steps only leaves updates that are already incorporated into the snapshot, and reapplying them is an +/// idempotent CRDT no-op (see ). /// /// -/// Concurrencia. Un por documento serializa las operaciones de ese doc; el -/// mapa global de semáforos es concurrente. El docId (opaco, puede contener cualquier carácter) se -/// mapea a un nombre de directorio por hash SHA-256 hex — evita traversal de rutas y longitudes ilegales. +/// Concurrency. A per document serializes that doc's operations; the +/// global map of semaphores is concurrent. The docId (opaque, may contain any character) is +/// mapped to a directory name by SHA-256 hex hash — avoids path traversal and illegal lengths. /// /// public sealed class FileSystemDocumentStore : IDocumentStore @@ -40,14 +40,14 @@ private sealed class DocLock { public readonly SemaphoreSlim Gate = new(1, 1); - /// Siguiente índice de update; -1 = aún no inicializado desde disco. + /// Next update index; -1 = not yet initialized from disk. public long NextSeq = -1; } private readonly string _root; private readonly ConcurrentDictionary _locks = new(StringComparer.Ordinal); - /// Crea el store bajo (se crea si no existe). + /// Creates the store under (created if it does not exist). public FileSystemDocumentStore(string rootDirectory) { ArgumentException.ThrowIfNullOrEmpty(rootDirectory); @@ -124,11 +124,11 @@ public async ValueTask SaveSnapshotAsync(string docId, ReadOnlyMemory stat { Directory.CreateDirectory(dir); - // 1) Reemplazo atómico del snapshot. + // 1) Atomic replacement of the snapshot. string snapshotPath = Path.Combine(dir, SnapshotFileName); await AtomicWriteAsync(snapshotPath, state, ct).ConfigureAwait(false); - // 2) Compaction: borrar los updates ya incorporados al snapshot y reiniciar la numeración. + // 2) Compaction: delete the updates already incorporated into the snapshot and reset the numbering. foreach (string updatePath in EnumerateUpdateFilesOrdered(dir)) { File.Delete(updatePath); @@ -171,7 +171,7 @@ private static IEnumerable EnumerateUpdateFilesOrdered(string dir) => .Where(p => TryParseSeq(Path.GetFileName(p), out _)) .OrderBy(p => Path.GetFileName(p), StringComparer.Ordinal); - // Nombre zero-padded a 20 dígitos → el orden lexicográfico coincide con el orden numérico de append. + // Name zero-padded to 20 digits → the lexicographic order matches the numeric append order. private static string UpdateFileName(long seq) => UpdatePrefix + seq.ToString("D20", CultureInfo.InvariantCulture); @@ -195,17 +195,17 @@ private static async Task AtomicWriteAsync(string finalPath, ReadOnlyMemory -/// Estado durable por documento (FR-017). Los blobs son opacos: el store nunca interpreta bytes de yrs -/// (P-IV). Toda implementación es thread-safe por documento y pasa la misma contract suite compartida -/// (DocumentStoreContractSuite), garantía de intercambiabilidad que exige el escenario de aceptación de -/// US3. +/// Durable per-document state (FR-017). The blobs are opaque: the store never interprets yrs bytes +/// (P-IV). Every implementation is thread-safe per document and passes the same shared contract suite +/// (DocumentStoreContractSuite), the interchangeability guarantee required by the US3 acceptance +/// scenario. /// /// -/// El estado persistido de un documento es un snapshot consolidado más los updates incrementales acumulados -/// desde ese snapshot. los devuelve enmarcados por -/// (snapshot primero, luego los updates en orden de append); el relay aplica cada record en orden para -/// reconstruir el documento. hace compaction: reemplaza el snapshot y -/// descarta los updates acumulados. +/// A document's persisted state is a consolidated snapshot plus the incremental updates accumulated +/// since that snapshot. returns them framed by +/// (snapshot first, then the updates in append order); the relay applies each record in order to +/// reconstruct the document. performs compaction: it replaces the snapshot and +/// discards the accumulated updates. /// public interface IDocumentStore { /// - /// Estado completo persistido del documento (snapshot + updates acumulados, enmarcados por - /// ), o null si el documento no existe. + /// Full persisted state of the document (snapshot + accumulated updates, framed by + /// ), or null if the document does not exist. /// ValueTask LoadAsync(string docId, CancellationToken ct = default); - /// Añade un update incremental a la cola durable del documento (durabilidad entre snapshots). + /// Appends an incremental update to the document's durable queue (durability between snapshots). ValueTask AppendUpdateAsync(string docId, ReadOnlyMemory update, CancellationToken ct = default); /// - /// Guarda un snapshot consolidado: reemplaza el estado y descarta los updates acumulados (compaction). + /// Saves a consolidated snapshot: replaces the state and discards the accumulated updates (compaction). /// ValueTask SaveSnapshotAsync(string docId, ReadOnlyMemory state, CancellationToken ct = default); } diff --git a/src/Weft.Server/Persistence/InMemoryDocumentStore.cs b/src/Weft.Server/Persistence/InMemoryDocumentStore.cs index 484ea66..ce7d9a3 100644 --- a/src/Weft.Server/Persistence/InMemoryDocumentStore.cs +++ b/src/Weft.Server/Persistence/InMemoryDocumentStore.cs @@ -3,8 +3,8 @@ namespace Weft.Server.Persistence; /// -/// respaldado por memoria: para tests y desarrollo. No sobrevive al proceso. -/// Thread-safe por documento vía un lock por entrada; el mapa global usa . +/// backed by memory: for tests and development. Does not survive the process. +/// Thread-safe per document via a per-entry lock; the global map uses . /// public sealed class InMemoryDocumentStore : IDocumentStore { diff --git a/src/Weft.Server/Protocol/AwarenessProtocol.cs b/src/Weft.Server/Protocol/AwarenessProtocol.cs index f4b55e8..4992143 100644 --- a/src/Weft.Server/Protocol/AwarenessProtocol.cs +++ b/src/Weft.Server/Protocol/AwarenessProtocol.cs @@ -3,26 +3,26 @@ namespace Weft.Server.Protocol; /// -/// Parsing mínimo del protocolo y-awareness — lo justo para que el relay difunda la retirada del -/// estado de una conexión al cerrarse (FR-015). El relay no interpreta el contenido del estado (es -/// opaco); solo necesita los clientID que una conexión anunció para poder marcarlos offline al salir. +/// Minimal parsing of the y-awareness protocol — just enough for the relay to broadcast the removal of +/// a connection's state when it closes (FR-015). The relay does not interpret the content of the state (it is +/// opaque); it only needs the clientIDs that a connection announced in order to mark them offline on leaving. /// /// -/// Formato de un awareness update (payload interno del mensaje ): +/// Format of an awareness update (inner payload of the message): /// /// <numClients:varUint> ( <clientID:varUint> <clock:varUint> <state:VarUint8Array (JSON UTF-8)> )* /// -/// La retirada es un update con clock+1 y estado "null" por cada clientID, como hace -/// y-protocols/awareness. +/// The removal is an update with clock+1 and state "null" for each clientID, as +/// y-protocols/awareness does. /// internal static class AwarenessProtocol { private static readonly byte[] NullStateUtf8 = Encoding.UTF8.GetBytes("null"); /// - /// Extrae los pares clientID → clock de un awareness update, acumulando en - /// (el clock más alto visto por cliente). Tolerante a payloads que no parsean - /// (best-effort: el awareness no es crítico para la convergencia del documento). + /// Extracts the clientID → clock pairs from an awareness update, accumulating into + /// (the highest clock seen per client). Tolerant of payloads that do not parse + /// (best-effort: awareness is not critical for the document's convergence). /// public static void TrackClients(ReadOnlySpan awarenessPayload, Dictionary tracked) { @@ -34,9 +34,9 @@ public static void TrackClients(ReadOnlySpan awarenessPayload, Dictionary< { uint clientId = r.ReadVarUint(); uint clock = r.ReadVarUint(); - _ = r.ReadVarUint8Array(); // estado (opaco): se salta - // Insertar el cliente aunque su clock sea 0 (común en el primer awareness de un cliente Yjs); - // indexar `tracked[clientId]` en el else con la clave ausente lanzaría KeyNotFoundException. + _ = r.ReadVarUint8Array(); // state (opaque): skipped + // Insert the client even if its clock is 0 (common in the first awareness of a Yjs client); + // indexing `tracked[clientId]` in the else with the absent key would throw KeyNotFoundException. if (!tracked.TryGetValue(clientId, out uint prevClock) || clock > prevClock) { tracked[clientId] = clock; @@ -45,14 +45,14 @@ public static void TrackClients(ReadOnlySpan awarenessPayload, Dictionary< } catch (MalformedMessageException) { - // Awareness malformado: se ignora para el tracking (el broadcast del mensaje ya lo maneja el relay). + // Malformed awareness: ignored for tracking (the relay already handles the message's broadcast). } } /// - /// Construye un mensaje completo que marca offline a - /// (estado "null", clock+1). Devuelve null si no hay clientes - /// que retirar. + /// Builds a complete message that marks + /// offline (state "null", clock+1). Returns null if there are no + /// clients to remove. /// public static byte[]? EncodeRemoval(IReadOnlyDictionary clients) { diff --git a/src/Weft.Server/Protocol/Lib0Encoding.cs b/src/Weft.Server/Protocol/Lib0Encoding.cs index 33cbe88..5e5a2e4 100644 --- a/src/Weft.Server/Protocol/Lib0Encoding.cs +++ b/src/Weft.Server/Protocol/Lib0Encoding.cs @@ -3,70 +3,70 @@ namespace Weft.Server.Protocol; /// -/// Se lanza cuando un frame de red no respeta el formato lib0/y-sync esperado: varint truncado o -/// sobredimensionado, longitud declarada mayor que los bytes disponibles, frame que excede el cap de -/// tamaño, o bytes sobrantes tras un mensaje completo. El relay traduce esto a un cierre WebSocket 1002 -/// de la conexión afectada, sin impacto en los pares (edge case de spec, CHARTER-05). +/// Thrown when a network frame does not respect the expected lib0/y-sync format: truncated or oversized +/// varint, declared length larger than the available bytes, frame exceeding the size cap, or trailing bytes +/// after a complete message. The relay translates this into a WebSocket 1002 close of the affected +/// connection, with no impact on the peers (spec edge case, CHARTER-05). /// public sealed class MalformedMessageException : Exception { - /// Crea la excepción con un mensaje explícito. + /// Creates the exception with an explicit message. public MalformedMessageException(string message) : base(message) { } } /// -/// Encoding lib0 (varint) compatible con y-protocols/y-websocket v1/v2. Provee el lector y el -/// escritor de bajo nivel sobre los que se construye el framing y-sync (). +/// lib0 (varint) encoding compatible with y-protocols/y-websocket v1/v2. Provides the low-level +/// reader and writer on top of which the y-sync framing () is built. /// /// /// -/// El varint lib0 es un entero sin signo de ancho variable, grupos de 7 bits en orden little-endian, con el -/// bit alto (0x80) como bandera de continuación. Un VarUint8Array es un varint de longitud -/// seguido de esos bytes. +/// The lib0 varint is a variable-width unsigned integer, groups of 7 bits in little-endian order, with the +/// high bit (0x80) as the continuation flag. A VarUint8Array is a length varint followed by +/// those bytes. /// /// -/// Frontera de confianza (P-I/P-II, FU-002 parte a). Este es el primer punto que recibe bytes de red -/// no confiables: antes de que cualquier update llegue al decoder de yrs (cuya amplificación de memoria -/// es el DoS que describe FU-002). Dos guardas estructurales lo contienen: (1) el frame completo se rechaza si -/// excede (o el cap que pase el llamador) antes de parsear; y (2) -/// nunca asigna ni avanza según una longitud declarada mayor que -/// los bytes que realmente quedan — un prefijo mentiroso de pocos bytes no puede inducir una asignación -/// gigante. Ambas fallan con , jamás con un abort. +/// Trust boundary (P-I/P-II, FU-002 part a). This is the first point that receives untrusted +/// network bytes: before any update reaches the yrs decoder (whose memory amplification is the DoS that +/// FU-002 describes). Two structural guards contain it: (1) the whole frame is rejected if it exceeds +/// (or the cap the caller passes) before parsing; and (2) +/// never allocates or advances based on a declared length larger +/// than the bytes that actually remain — a lying prefix of a few bytes cannot induce a giant allocation. +/// Both fail with , never with an abort. /// /// public static class Lib0Encoding { /// - /// Cap por defecto del tamaño de un frame WebSocket entrante (16 MiB). Configurable por el llamador: - /// documentos grandes legítimos consolidan vía snapshot; los updates incrementales en vivo son pequeños. + /// Default size cap of an incoming WebSocket frame (16 MiB). Configurable by the caller: + /// large legitimate documents consolidate via snapshot; live incremental updates are small. /// public const int DefaultMaxMessageBytes = 16 * 1024 * 1024; /// - /// Lector de bajo nivel sobre un frame en memoria. ref struct: los - /// que devuelve apuntan al buffer del frame (zero-copy), válidos mientras viva el frame de origen. + /// Low-level reader over an in-memory frame. ref struct: the + /// it returns point to the frame buffer (zero-copy), valid while the source frame lives. /// public ref struct Lib0Reader { private readonly ReadOnlySpan _buffer; private int _pos; - /// Crea un lector posicionado al inicio de . + /// Creates a reader positioned at the start of . public Lib0Reader(ReadOnlySpan buffer) { _buffer = buffer; _pos = 0; } - /// Bytes aún no consumidos. + /// Bytes not yet consumed. public readonly int Remaining => _buffer.Length - _pos; - /// true si se consumió el frame completo. + /// true if the whole frame was consumed. public readonly bool AtEnd => _pos >= _buffer.Length; /// - /// Lee un varint sin signo (hasta 32 bits). Falla si el varint se trunca al final del buffer o si - /// codifica un valor que no cabe en 32 bits (defensa contra prefijos degenerados). + /// Reads an unsigned varint (up to 32 bits). Fails if the varint is truncated at the end of the buffer + /// or if it encodes a value that does not fit in 32 bits (defense against degenerate prefixes). /// public uint ReadVarUint() { @@ -76,15 +76,15 @@ public uint ReadVarUint() { if (_pos >= _buffer.Length) { - throw new MalformedMessageException("varint truncado: fin del buffer antes de cerrar el número."); + throw new MalformedMessageException("truncated varint: end of buffer before the number was closed."); } byte b = _buffer[_pos++]; - // Los grupos de 7 bits caben en 32 bits solo hasta shift=28 (5 bytes); el 5.º no debe aportar - // bits por encima del bit 31. + // The 7-bit groups fit in 32 bits only up to shift=28 (5 bytes); the 5th must not contribute + // bits above bit 31. if (shift > 28 || (shift == 28 && (b & 0x7F) > 0x0F)) { - throw new MalformedMessageException("varint sobredimensionado: excede 32 bits."); + throw new MalformedMessageException("oversized varint: exceeds 32 bits."); } value |= (uint)(b & 0x7F) << shift; @@ -98,9 +98,9 @@ public uint ReadVarUint() } /// - /// Lee un bloque VarUint8Array (longitud varint + bytes) y devuelve una vista zero-copy de esos - /// bytes. Guarda clave anti-DoS: si la longitud declarada supera los bytes restantes, falla en - /// vez de intentar leer/asignar — un frame pequeño no puede reclamar un array gigante. + /// Reads a VarUint8Array block (varint length + bytes) and returns a zero-copy view of those + /// bytes. Key anti-DoS guard: if the declared length exceeds the remaining bytes, it fails + /// instead of trying to read/allocate — a small frame cannot claim a giant array. /// public ReadOnlySpan ReadVarUint8Array() { @@ -108,7 +108,7 @@ public ReadOnlySpan ReadVarUint8Array() if (len > (uint)Remaining) { throw new MalformedMessageException( - $"VarUint8Array declara {len} bytes pero solo quedan {Remaining} en el frame."); + $"VarUint8Array declares {len} bytes but only {Remaining} remain in the frame."); } ReadOnlySpan slice = _buffer.Slice(_pos, (int)len); @@ -118,27 +118,27 @@ public ReadOnlySpan ReadVarUint8Array() } /// - /// Escritor de bajo nivel con buffer creciente. El - /// subyacente amortiza las reasignaciones; / exponen el - /// resultado. Un uso, un mensaje: no reutilizar entre frames. + /// Low-level writer with a growing buffer. The underlying + /// amortizes the reallocations; / expose the + /// result. One use, one message: do not reuse across frames. /// public sealed class Lib0Writer { private readonly ArrayBufferWriter _writer = new(); - /// Bytes escritos hasta el momento. + /// Bytes written so far. public int Length => _writer.WrittenCount; - /// Vista de los bytes escritos. + /// View of the written bytes. public ReadOnlySpan WrittenSpan => _writer.WrittenSpan; - /// Copia los bytes escritos en un nuevo array (el frame listo para enviar). + /// Copies the written bytes into a new array (the frame ready to send). public byte[] ToArray() => _writer.WrittenSpan.ToArray(); - /// Escribe un varint sin signo (grupos de 7 bits little-endian, bit alto de continuación). + /// Writes an unsigned varint (little-endian 7-bit groups, high continuation bit). public void WriteVarUint(uint value) { - // A lo sumo 5 bytes para 32 bits. + // At most 5 bytes for 32 bits. Span tmp = stackalloc byte[5]; int n = 0; while (value >= 0x80) @@ -151,7 +151,7 @@ public void WriteVarUint(uint value) _writer.Write(tmp[..n]); } - /// Escribe un bloque VarUint8Array: longitud varint seguida de los bytes. + /// Writes a VarUint8Array block: varint length followed by the bytes. public void WriteVarUint8Array(ReadOnlySpan bytes) { WriteVarUint((uint)bytes.Length); diff --git a/src/Weft.Server/Protocol/SyncProtocol.cs b/src/Weft.Server/Protocol/SyncProtocol.cs index ebff67e..f726f8d 100644 --- a/src/Weft.Server/Protocol/SyncProtocol.cs +++ b/src/Weft.Server/Protocol/SyncProtocol.cs @@ -1,34 +1,34 @@ namespace Weft.Server.Protocol; -/// Tipo de mensaje de wire de primer nivel (y-protocols). +/// Top-level wire message type (y-protocols). public enum MessageType { - /// Sincronización de documento (sub-tipado por ). + /// Document synchronization (sub-typed by ). Sync = 0, - /// Estado efímero por cliente (y-awareness); nunca se persiste. + /// Ephemeral per-client state (y-awareness); never persisted. Awareness = 1, } -/// Sub-tipo de un mensaje (protocolo y-sync). +/// Sub-type of a message (y-sync protocol). public enum SyncMessageType { - /// SyncStep1: "esto conozco" — lleva el state vector del emisor. + /// SyncStep1: "here's what I know" — carries the sender's state vector. Step1 = 0, - /// SyncStep2: respuesta con el delta (update) que le falta al emisor del state vector. + /// SyncStep2: reply with the delta (update) the state-vector sender is missing. Step2 = 1, - /// Update incremental en vivo. + /// Live incremental update. Update = 2, } /// -/// Un mensaje y-sync decodificado. ref struct: es una vista zero-copy del frame -/// de origen (el state vector para ; el update para -/// /; el update de awareness para -/// ). Los bytes de payload son opacos para esta capa: se entregan -/// tal cual al broker/decoder de yrs aguas arriba (CHARTER-05). +/// A decoded y-sync message. ref struct: is a zero-copy view of the source +/// frame (the state vector for ; the update for +/// /; the awareness update for +/// ). The payload bytes are opaque to this layer: they are handed +/// as-is to the broker/yrs decoder upstream (CHARTER-05). /// public readonly ref struct SyncMessage { @@ -39,45 +39,45 @@ internal SyncMessage(MessageType type, SyncMessageType syncType, ReadOnlySpanTipo de primer nivel. + /// Top-level type. public MessageType Type { get; } - /// Sub-tipo; solo significativo cuando es . + /// Sub-type; only meaningful when is . public SyncMessageType SyncType { get; } - /// Payload opaco (state vector / update / awareness). + /// Opaque payload (state vector / update / awareness). public ReadOnlySpan Payload { get; } } /// -/// Framing y-sync sobre el encoding lib0 (), compatible con clientes Yjs -/// estándar. Estructura del wire (todo entero es varint): +/// y-sync framing over the lib0 encoding (), compatible with standard Yjs +/// clients. Wire structure (every integer is a varint): /// /// SYNC := 0 <syncType> <VarUint8Array payload> /// AWARENESS := 1 <VarUint8Array payload> /// /// /// -/// El decoder aplica el cap de tamaño de frame (FU-002 parte a) antes de parsear y rechaza tipos -/// desconocidos, varints truncados/sobredimensionados y bytes sobrantes con -/// . No interpreta el payload: eso es responsabilidad del relay/decoder -/// yrs (CHARTER-05). Esta capa es puro transporte. +/// The decoder applies the frame-size cap (FU-002 part a) before parsing and rejects unknown types, +/// truncated/oversized varints and trailing bytes with . It does not +/// interpret the payload: that is the responsibility of the relay/yrs decoder (CHARTER-05). This layer is +/// pure transport. /// public static class SyncProtocol { - /// Codifica SyncStep1(stateVector) — lo envía quien se conecta. + /// Encodes SyncStep1(stateVector) — sent by whoever connects. public static byte[] EncodeSyncStep1(ReadOnlySpan stateVector) => EncodeSync(SyncMessageType.Step1, stateVector); - /// Codifica SyncStep2(update) — el delta que responde a un SyncStep1. + /// Encodes SyncStep2(update) — the delta that answers a SyncStep1. public static byte[] EncodeSyncStep2(ReadOnlySpan update) => EncodeSync(SyncMessageType.Step2, update); - /// Codifica Update(update) — un update incremental en vivo. + /// Encodes Update(update) — a live incremental update. public static byte[] EncodeUpdate(ReadOnlySpan update) => EncodeSync(SyncMessageType.Update, update); - /// Codifica un mensaje AWARENESS(update) (estado efímero por cliente). + /// Encodes an AWARENESS(update) message (ephemeral per-client state). public static byte[] EncodeAwareness(ReadOnlySpan awarenessUpdate) { var w = new Lib0Encoding.Lib0Writer(); @@ -96,24 +96,24 @@ private static byte[] EncodeSync(SyncMessageType syncType, ReadOnlySpan pa } /// - /// Decodifica un frame WebSocket binario. Rechaza (con ) el frame - /// que exceda antes de parsear, cualquier tipo/sub-tipo desconocido, - /// varints inválidos y bytes sobrantes tras el mensaje. + /// Decodes a binary WebSocket frame. Rejects (with ) a frame that + /// exceeds before parsing, any unknown type/sub-type, invalid varints + /// and trailing bytes after the message. /// - /// Bytes crudos del frame WebSocket. + /// Raw bytes of the WebSocket frame. /// - /// Cap de tamaño del frame (FU-002 parte a). Por defecto . + /// Frame-size cap (FU-002 part a). Defaults to . /// - /// El mensaje decodificado; es una vista de . + /// The decoded message; is a view over . public static SyncMessage Decode( ReadOnlySpan frame, int maxMessageBytes = Lib0Encoding.DefaultMaxMessageBytes) { - // Guarda de tamaño (FU-002 parte a): rechazar el frame sobredimensionado ANTES de tocar el decoder. + // Size guard (FU-002 part a): reject the oversized frame BEFORE touching the decoder. if (frame.Length > maxMessageBytes) { throw new MalformedMessageException( - $"El frame ({frame.Length} bytes) excede el cap de {maxMessageBytes} bytes."); + $"The frame ({frame.Length} bytes) exceeds the cap of {maxMessageBytes} bytes."); } var r = new Lib0Encoding.Lib0Reader(frame); @@ -127,7 +127,7 @@ public static SyncMessage Decode( uint rawSync = r.ReadVarUint(); if (rawSync > (uint)SyncMessageType.Update) { - throw new MalformedMessageException($"Sub-tipo SYNC desconocido: {rawSync}."); + throw new MalformedMessageException($"Unknown SYNC sub-type: {rawSync}."); } ReadOnlySpan payload = r.ReadVarUint8Array(); @@ -143,13 +143,13 @@ public static SyncMessage Decode( } default: - throw new MalformedMessageException($"Tipo de mensaje desconocido: {rawType}."); + throw new MalformedMessageException($"Unknown message type: {rawType}."); } if (!r.AtEnd) { throw new MalformedMessageException( - $"Bytes sobrantes tras el mensaje: {r.Remaining} byte(s) sin consumir."); + $"Trailing bytes after the message: {r.Remaining} byte(s) left unconsumed."); } return message; diff --git a/src/Weft.Server/WeftConnection.cs b/src/Weft.Server/WeftConnection.cs index 2fc6005..de03bdb 100644 --- a/src/Weft.Server/WeftConnection.cs +++ b/src/Weft.Server/WeftConnection.cs @@ -7,10 +7,10 @@ namespace Weft.Server; /// -/// Una conexión WebSocket de un cliente a un documento. Corre un send pump (drena una cola de envío -/// acotada → el socket) y un receive loop (decodifica frames y-sync, aplica el enforcement de -/// autorización y los límites por conexión, y despacha sync/awareness). Aislada: un fallo de esta conexión no -/// afecta a los pares (el broadcast del hub aísla cada envío). +/// A client's WebSocket connection to a document. Runs a send pump (drains a bounded send queue +/// → the socket) and a receive loop (decodes y-sync frames, applies the authorization enforcement +/// and the per-connection limits, and dispatches sync/awareness). Isolated: a failure of this connection does not +/// affect the peers (the hub's broadcast isolates each send). /// internal sealed class WeftConnection { @@ -29,22 +29,22 @@ public WeftConnection(WebSocket ws, WeftAccess access, WeftServerOptions options _sendQueue = Channel.CreateBounded(new BoundedChannelOptions(options.MaxSendQueuePerConnection) { SingleReader = true, - FullMode = BoundedChannelFullMode.Wait, // usamos TryWrite: 'lleno' ⇒ se cierra la conexión (backpressure) + FullMode = BoundedChannelFullMode.Wait, // we use TryWrite: 'full' ⇒ the connection is closed (backpressure) }); } - /// Nivel de acceso concedido en el handshake. + /// Access level granted in the handshake. public WeftAccess Access { get; } - /// clientIDs de awareness anunciados por esta conexión (para la retirada al cerrar, FR-015). + /// Awareness clientIDs announced by this connection (for the removal on close, FR-015). public IReadOnlyDictionary AwarenessClients => _awarenessClients; - /// Pide el cierre de la conexión (p. ej. desde DisconnectAllAsync). No bloquea. + /// Requests the close of the connection (e.g. from DisconnectAllAsync). Non-blocking. public void RequestClose() => _cts.Cancel(); /// - /// Encola un frame para envío. No bloquea (se llama desde el turno del actor durante el broadcast). Si la - /// cola está llena (consumidor lento, FU-002 parte b), devuelve false y la conexión se cierra. + /// Enqueues a frame for sending. Non-blocking (called from the actor turn during the broadcast). If the + /// queue is full (slow consumer, FU-002 part b), returns false and the connection is closed. /// public bool TryEnqueue(byte[] frame) { @@ -53,14 +53,14 @@ public bool TryEnqueue(byte[] frame) return true; } - // Backpressure: descartar el consumidor lento en vez de crecer memoria; reconectará y re-sincronizará. + // Backpressure: drop the slow consumer instead of growing memory; it will reconnect and re-sync. _cts.Cancel(); return false; } /// - /// Corre la conexión hasta que cierra: arranca el send pump, envía el SyncStep1 inicial y drena el - /// receive loop. Al terminar, completa la cola de envío. + /// Runs the connection until it closes: starts the send pump, sends the initial SyncStep1 and drains the + /// receive loop. On finishing, completes the send queue. /// public async Task RunAsync(DocumentHub hub, CancellationToken ct) { @@ -69,19 +69,19 @@ public async Task RunAsync(DocumentHub hub, CancellationToken ct) try { - // Sync inicial (servidor→cliente): "esto conozco". El cliente responde su SyncStep1, que el receive - // loop contesta con SyncStep2 (delta) — sync incremental en ambas direcciones. + // Initial sync (server→client): "here's what I know". The client responds with its SyncStep1, which the + // receive loop answers with SyncStep2 (delta) — incremental sync in both directions. byte[] serverSv = await hub.Session.ExportStateVectorAsync(_cts.Token).ConfigureAwait(false); TryEnqueue(SyncProtocol.EncodeSyncStep1(serverSv)); await ReceiveLoopAsync(hub, _cts.Token).ConfigureAwait(false); } - catch (OperationCanceledException) { /* cierre normal */ } - catch (WebSocketException) { /* socket cortado por el par: cierre normal */ } + catch (OperationCanceledException) { /* normal close */ } + catch (WebSocketException) { /* socket cut by the peer: normal close */ } finally { _sendQueue.Writer.TryComplete(); - try { await pump.ConfigureAwait(false); } catch { /* pump ya en cierre */ } + try { await pump.ConfigureAwait(false); } catch { /* pump already closing */ } } } @@ -97,20 +97,20 @@ private async Task ReceiveLoopAsync(DocumentHub hub, CancellationToken ct) if (type != WebSocketMessageType.Binary) { - continue; // el protocolo y-sync es binario; se ignora texto/ping + continue; // the y-sync protocol is binary; text/ping is ignored } if (!await DispatchAsync(hub, data, ct).ConfigureAwait(false)) { - return; // dispatch pidió cerrar (malformado 1002 / read-only 1008) + return; // dispatch requested close (malformed 1002 / read-only 1008) } } } - // Aplica+persiste+difunde un update; si la PERSISTENCIA falla, cierra esta conexión con 1011. En - // persist-before-broadcast el hub ya cerró todas las conexiones del documento (DisconnectAll) para - // forzar el re-sync autoritativo; aquí solo se traduce el fallo a un cierre limpio. Cancelación y - // socket cortado se dejan propagar (los maneja RunAsync como cierre normal). + // Applies+persists+broadcasts an update; if PERSISTENCE fails, closes this connection with 1011. In + // persist-before-broadcast the hub already closed all the document's connections (DisconnectAll) to + // force the authoritative re-sync; here the failure is only translated into a clean close. Cancellation and + // a cut socket are allowed to propagate (RunAsync handles them as a normal close). private async Task ApplyOrCloseAsync(DocumentHub hub, byte[] payload, CancellationToken ct) { try @@ -136,7 +136,7 @@ await CloseAsync(WebSocketCloseStatus.InternalServerError, "persist failed", ct) private async Task DispatchAsync(DocumentHub hub, byte[] frame, CancellationToken ct) { - // SyncMessage es un ref struct (span sobre el frame): se extraen tipo+payload a locales ANTES de await. + // SyncMessage is a ref struct (span over the frame): type+payload are extracted to locals BEFORE await. MessageType type; SyncMessageType syncType; byte[] payload; @@ -156,17 +156,17 @@ private async Task DispatchAsync(DocumentHub hub, byte[] frame, Cancellati switch (type) { case MessageType.Sync when syncType == SyncMessageType.Step1: - // El cliente anuncia su state vector → responder el delta que le falta. + // The client announces its state vector → respond with the delta it is missing. byte[] delta = await hub.Session.ExportUpdateSinceAsync(payload, ct).ConfigureAwait(false); TryEnqueue(SyncProtocol.EncodeSyncStep2(delta)); return true; case MessageType.Sync when syncType == SyncMessageType.Step2: - // SyncStep2 = respuesta del handshake al SyncStep1 del servidor (parte del protocolo y-sync, no - // una edición deliberada). Una conexión ReadWrite lo aplica (aporta su estado inicial); una - // ReadOnly lo IGNORA (no contribuye estado, pero NO se cierra: cerrar aquí rompería el handshake - // y-websocket estándar y dejaría ReadOnly inusable con clientes Yjs — el enforcement de escritura - // es solo para Update en vivo, FR-019). + // SyncStep2 = the handshake's response to the server's SyncStep1 (part of the y-sync protocol, not + // a deliberate edit). A ReadWrite connection applies it (contributes its initial state); a + // ReadOnly one IGNORES it (contributes no state, but is NOT closed: closing here would break the + // standard y-websocket handshake and leave ReadOnly unusable with Yjs clients — the write + // enforcement is only for live Update, FR-019). if (Access == WeftAccess.ReadWrite) { return await ApplyOrCloseAsync(hub, payload, ct).ConfigureAwait(false); @@ -174,7 +174,7 @@ private async Task DispatchAsync(DocumentHub hub, byte[] frame, Cancellati return true; - case MessageType.Sync: // Update (subtipo 2): edición de documento en vivo. + case MessageType.Sync: // Update (sub-type 2): live document edit. if (Access != WeftAccess.ReadWrite) { await CloseAsync(WebSocketCloseStatus.PolicyViolation, "read-only connection", ct) // 1008 @@ -186,7 +186,7 @@ await CloseAsync(WebSocketCloseStatus.PolicyViolation, "read-only connection", c case MessageType.Awareness: AwarenessProtocol.TrackClients(payload, _awarenessClients); - hub.Broadcast(SyncProtocol.EncodeAwareness(payload), exclude: this); // efímero, a los pares, sin persistir + hub.Broadcast(SyncProtocol.EncodeAwareness(payload), exclude: this); // ephemeral, to the peers, not persisted return true; default: @@ -203,7 +203,7 @@ private async Task SendPumpAsync(CancellationToken ct) await _ws.SendAsync(frame, WebSocketMessageType.Binary, endOfMessage: true, ct).ConfigureAwait(false); } } - catch (OperationCanceledException) { /* cierre */ } + catch (OperationCanceledException) { /* close */ } catch (WebSocketException) { _cts.Cancel(); } } @@ -233,7 +233,7 @@ private async Task SendPumpAsync(CancellationToken ct) acc.Write(rent.AsSpan(0, r.Count)); if (acc.WrittenCount > _options.MaxMessageBytes) { - // Frame sobredimensionado (FU-002 parte a): cerrar antes de acumular más. + // Oversized frame (FU-002 part a): close before accumulating more. await CloseAsync(WebSocketCloseStatus.MessageTooBig, "message too large", ct).ConfigureAwait(false); // 1009 return (WebSocketMessageType.Close, null); } @@ -259,8 +259,8 @@ private async Task CloseAsync(WebSocketCloseStatus status, string description, C await _ws.CloseAsync(status, description, ct).ConfigureAwait(false); } } - catch (WebSocketException) { /* ya cerrado */ } - catch (OperationCanceledException) { /* apagando */ } + catch (WebSocketException) { /* already closed */ } + catch (OperationCanceledException) { /* shutting down */ } finally { _cts.Cancel(); diff --git a/src/Weft.Server/WeftServer.cs b/src/Weft.Server/WeftServer.cs index b1a7674..0148126 100644 --- a/src/Weft.Server/WeftServer.cs +++ b/src/Weft.Server/WeftServer.cs @@ -10,27 +10,27 @@ namespace Weft.Server; -/// Servicio de operación del relay (FR-018): publicar, contar conexiones, desconectar. +/// Relay operation service (FR-018): publish, count connections, disconnect. public interface IWeftServer { /// - /// Snapshot content-addressed de un documento activo. Ejecuta dentro del turno del actor del documento → - /// mismo que produciría VersionStore en local para el mismo contenido - /// (paridad, P-III). Requiere un registrado. + /// Content-addressed snapshot of an active document. Runs inside the document's actor turn → + /// same that VersionStore would produce locally for the same content + /// (parity, P-III). Requires a registered . /// ValueTask PublishAsync(string docId, CancellationToken ct = default); - /// Número de conexiones activas de un documento (0 si no hay ninguna). + /// Number of active connections of a document (0 if there are none). ValueTask GetConnectionCountAsync(string docId, CancellationToken ct = default); - /// Cierra todas las conexiones de un documento (p. ej. tras revocación de acceso). + /// Closes all connections of a document (e.g. after access revocation). ValueTask DisconnectAllAsync(string docId, CancellationToken ct = default); } /// -/// Implementación del relay: registro de por documento sobre un -/// de M1. Singleton en el contenedor del consumidor. El broker se configura para -/// consolidar un snapshot en el store al desalojar (compaction), y la carga reconstruye el documento desde el +/// Relay implementation: a registry of per document over an M1 +/// . Singleton in the consumer's container. The broker is configured to +/// consolidate a snapshot into the store on eviction (compaction), and loading reconstructs the document from the /// . /// public sealed class WeftServer : IWeftServer, IAsyncDisposable @@ -44,7 +44,7 @@ public sealed class WeftServer : IWeftServer, IAsyncDisposable private readonly SemaphoreSlim _hubGate = new(1, 1); private bool _disposed; - /// Crea el relay. es opcional: solo lo necesita . + /// Creates the relay. is optional: only needs it. public WeftServer(WeftServerOptions options, IDocumentStore store, IBlobStore? blobStore = null) { ArgumentNullException.ThrowIfNull(options); @@ -54,7 +54,7 @@ public WeftServer(WeftServerOptions options, IDocumentStore store, IBlobStore? b _store = store; _blobStore = blobStore; - // El desalojo del broker consolida un snapshot en el store (compaction), encadenando el hook del usuario. + // The broker's eviction consolidates a snapshot into the store (compaction), chaining the user's hook. Func? userOnEvicting = options.Broker.OnEvicting; var brokerOptions = new DocumentBrokerOptions { @@ -74,7 +74,7 @@ public WeftServer(WeftServerOptions options, IDocumentStore store, IBlobStore? b _broker = new DocumentBroker(_engine, brokerOptions); } - // -- Endpoint: ciclo de vida de una conexión -- + // -- Endpoint: lifecycle of a connection -- internal async Task HandleConnectionAsync(string docId, WeftAccess access, WebSocket ws, CancellationToken ct) { @@ -114,7 +114,7 @@ private async Task JoinAsync(string docId, WeftConnection connectio private async Task LeaveAsync(DocumentHub hub, WeftConnection connection) { - // Retirada de awareness (FR-015): marcar offline los clientIDs que anunció esta conexión, a los pares. + // Awareness removal (FR-015): mark offline, to the peers, the clientIDs this connection announced. byte[]? removal = AwarenessProtocol.EncodeRemoval(connection.AwarenessClients); if (removal is not null) { @@ -127,7 +127,7 @@ private async Task LeaveAsync(DocumentHub hub, WeftConnection connection) } catch (ObjectDisposedException) { - return; // el servidor se está cerrando: DisposeAsync liberó el gate y ya desecha los hubs + return; // the server is shutting down: DisposeAsync released the gate and already disposes the hubs } try @@ -144,13 +144,13 @@ private async Task LeaveAsync(DocumentHub hub, WeftConnection connection) } } - // Reconstruye el blob de estado del documento desde el store (snapshot + updates enmarcados) para el broker. + // Reconstructs the document's state blob from the store (snapshot + framed updates) for the broker. private async ValueTask LoadDocStateAsync(string docId, CancellationToken ct) { byte[]? framed = await _store.LoadAsync(docId, ct).ConfigureAwait(false); if (framed is null) { - return null; // documento nuevo + return null; // new document } IReadOnlyList records = DocumentStateFraming.ReadRecords(framed); @@ -173,14 +173,14 @@ public async ValueTask PublishAsync(string docId, CancellationToken c if (_blobStore is null) { throw new InvalidOperationException( - "PublishAsync requiere un IBlobStore registrado en el contenedor del consumidor."); + "PublishAsync requires an IBlobStore registered in the consumer's container."); } await using DocumentSession session = await _broker.OpenAsync(docId, LoadDocStateAsync, ct) .ConfigureAwait(false); - // Snapshot dentro del turno del actor: ExportState es la MISMA operación determinista (P-III) que usa - // VersionStore.PublishAsync en local; FromBlob(ExportState) reproduce el VersionId local byte a byte. + // Snapshot within the actor turn: ExportState is the SAME deterministic operation (P-III) that + // VersionStore.PublishAsync uses locally; FromBlob(ExportState) reproduces the local VersionId byte for byte. byte[] snapshot = await session.ExecuteAsync(static doc => doc.ExportState(), ct).ConfigureAwait(false); var id = VersionId.FromBlob(snapshot); await _blobStore.PutAsync(id, snapshot, ct).ConfigureAwait(false); @@ -209,7 +209,7 @@ public ValueTask DisconnectAllAsync(string docId, CancellationToken ct = default return ValueTask.CompletedTask; } - /// Cierra el relay: descarta los hubs vivos y drena el broker. + /// Closes the relay: disposes the live hubs and drains the broker. public async ValueTask DisposeAsync() { if (_disposed) diff --git a/src/Weft.Server/WeftServerExtensions.cs b/src/Weft.Server/WeftServerExtensions.cs index 294aadb..ca7c2fa 100644 --- a/src/Weft.Server/WeftServerExtensions.cs +++ b/src/Weft.Server/WeftServerExtensions.cs @@ -9,13 +9,13 @@ namespace Weft.Server; -/// Registro (DI) y mapeo del endpoint del relay . +/// Registration (DI) and endpoint mapping of the relay. public static class WeftServerExtensions { /// - /// Registra el relay en el contenedor. El consumidor DEBE registrar también un - /// (obligatorio, se valida en al arrancar) y un - /// ; un es opcional (solo para + /// Registers the relay in the container. The consumer MUST also register an + /// (mandatory, validated in at startup) and an + /// ; an is optional (only for /// ). /// public static IServiceCollection AddWeftServer(this IServiceCollection services, Action? configure = null) @@ -36,10 +36,10 @@ public static IServiceCollection AddWeftServer(this IServiceCollection services, } /// - /// Mapea el endpoint WebSocket del relay en {pattern}/{docId}. Falla al arrancar si no hay un - /// registrado (la autorización nunca es opcional; SC-010). Semántica del - /// handshake: → 403 antes del upgrade (0 bytes de contenido); en otro caso, - /// upgrade y relay con el nivel de acceso concedido. + /// Maps the relay's WebSocket endpoint at {pattern}/{docId}. Fails at startup if there is no + /// registered (authorization is never optional; SC-010). Handshake + /// semantics: → 403 before the upgrade (0 bytes of content); otherwise, + /// upgrade and relay with the granted access level. /// public static IEndpointConventionBuilder MapWeft(this IEndpointRouteBuilder endpoints, string pattern) { @@ -52,15 +52,15 @@ public static IEndpointConventionBuilder MapWeft(this IEndpointRouteBuilder endp if (!probe.IsService(typeof(IWeftAuthorizer))) { throw new InvalidOperationException( - "AddWeftServer requiere registrar un IWeftAuthorizer antes de MapWeft: la autorización nunca es " + - "opcional ni por-defecto-permisiva (SC-010)."); + "AddWeftServer requires registering an IWeftAuthorizer before MapWeft: authorization is never " + + "optional nor permissive-by-default (SC-010)."); } if (!probe.IsService(typeof(IDocumentStore))) { throw new InvalidOperationException( - "AddWeftServer requiere registrar un IDocumentStore antes de MapWeft " + - "(InMemoryDocumentStore/FileSystemDocumentStore o un adaptador EFCore/Redis)."); + "AddWeftServer requires registering an IDocumentStore before MapWeft " + + "(InMemoryDocumentStore/FileSystemDocumentStore or an EFCore/Redis adapter)."); } } @@ -79,7 +79,7 @@ public static IEndpointConventionBuilder MapWeft(this IEndpointRouteBuilder endp WeftAccess access = await authorizer.AuthorizeAsync(context, docId, context.RequestAborted); if (access == WeftAccess.Deny) { - context.Response.StatusCode = StatusCodes.Status403Forbidden; // antes del upgrade: 0 bytes de contenido + context.Response.StatusCode = StatusCodes.Status403Forbidden; // before the upgrade: 0 bytes of content return; } diff --git a/src/Weft.Server/WeftServerOptions.cs b/src/Weft.Server/WeftServerOptions.cs index 8c4e601..7dfaef1 100644 --- a/src/Weft.Server/WeftServerOptions.cs +++ b/src/Weft.Server/WeftServerOptions.cs @@ -6,56 +6,56 @@ namespace Weft.Server; /// -/// Configuración del relay (). El motor y el ciclo de vida del -/// broker tienen defaults sensatos; los límites por conexión completan la mitigación FU-002 (parte b) sobre el -/// cap de tamaño de mensaje del framing (parte a). +/// Relay configuration (). The engine and the broker lifecycle +/// have sensible defaults; the per-connection limits complete the FU-002 mitigation (part b) on top of the +/// framing's message-size cap (part a). /// public sealed class WeftServerOptions { - /// Motor CRDT que respalda los documentos del relay. Por defecto . + /// CRDT engine backing the relay's documents. Defaults to . public ICrdtEngine Engine { get; set; } = YrsEngine.Instance; - /// Ciclo de vida del (idle eviction, LRU, cadencia del barrido). + /// Lifecycle of the (idle eviction, LRU, sweep cadence). public DocumentBrokerOptions Broker { get; set; } = new(); /// - /// Cap de tamaño de un frame WebSocket entrante (FU-002 parte a). Frames por encima se rechazan antes del - /// decoder. Por defecto (16 MiB). + /// Size cap of an incoming WebSocket frame (FU-002 part a). Frames above it are rejected before the + /// decoder. Defaults to (16 MiB). /// public int MaxMessageBytes { get; set; } = Lib0Encoding.DefaultMaxMessageBytes; /// - /// Cota de la cola de envío por conexión (FU-002 parte b, backpressure). Si un consumidor lento no drena su - /// cola y esta se llena, la conexión se cierra (se descarta el consumidor lento en vez de crecer memoria sin - /// límite); el cliente reconecta y re-sincroniza. Por defecto 256 mensajes. + /// Bound on the per-connection send queue (FU-002 part b, backpressure). If a slow consumer does not drain + /// its queue and it fills up, the connection is closed (the slow consumer is dropped instead of letting + /// memory grow unbounded); the client reconnects and re-syncs. Defaults to 256 messages. /// public int MaxSendQueuePerConnection { get; set; } = 256; /// - /// Orden entre persistir un update y difundirlo a los pares (FU-010). Por defecto - /// : ningún par observa un update que el store no haya - /// aceptado. Ver para el trade-off con la latencia de broadcast. + /// Ordering between persisting an update and broadcasting it to peers (FU-010). Defaults to + /// : no peer observes an update that the store has not + /// accepted. See for the trade-off with broadcast latency. /// public DurabilityMode Durability { get; set; } = DurabilityMode.PersistThenBroadcast; } -/// Orden entre persistir un update en el y difundirlo (FU-010). +/// Ordering between persisting an update in the and broadcasting it (FU-010). public enum DurabilityMode { /// - /// Persistir ANTES de difundir (default). Garantiza que ningún par observa estado que el servidor no haya - /// aceptado de forma durable. Un fallo del append cierra las conexiones del documento (1011): reconectan y el - /// servidor autoritativo reenvía el estado. El coste es que el broadcast se retrasa la latencia del append (que - /// el emisor ya paga hoy en el receive loop). Nota: y-protocols no tiene ack de aplicación para Update, - /// así que el emisor nunca sabe si su update se persistió — la garantía es sobre lo que OBSERVAN los pares. + /// Persist BEFORE broadcasting (default). Guarantees no peer observes state the server has not durably + /// accepted. An append failure closes the document's connections (1011): they reconnect and the authoritative + /// server resends the state. The cost is that the broadcast is delayed by the append latency (which the sender + /// already pays today in the receive loop). Note: y-protocols has no application-level ack for Update, + /// so the sender never knows whether its update was persisted — the guarantee is about what peers OBSERVE. /// PersistThenBroadcast, /// - /// Difundir ANTES de persistir (comportamiento heredado, CHARTER-05). Menor latencia de broadcast a costa de - /// una ventana en la que los pares tienen un update que el store aún no aceptó; en single-node la auto-sanación - /// CRDT lo recupera en la reconexión. Válvula de escape para deployments sensibles a la latencia sobre un store - /// rápido o en memoria. + /// Broadcast BEFORE persisting (legacy behavior, CHARTER-05). Lower broadcast latency at the cost of a + /// window in which peers hold an update the store has not yet accepted; on single-node the CRDT + /// self-healing recovers it on reconnection. Escape valve for latency-sensitive deployments over a fast or + /// in-memory store. /// BroadcastThenPersist, } diff --git a/src/Weft.Versioning/Blobs/FileSystemBlobStore.cs b/src/Weft.Versioning/Blobs/FileSystemBlobStore.cs index ff6c0a5..e55058c 100644 --- a/src/Weft.Versioning/Blobs/FileSystemBlobStore.cs +++ b/src/Weft.Versioning/Blobs/FileSystemBlobStore.cs @@ -1,15 +1,15 @@ namespace Weft.Versioning.Blobs; /// -/// Almacén content-addressed sobre el sistema de archivos (v1). Sharding aa/bb/hash para -/// evitar directorios enormes; escritura atómica (temp + rename) para no dejar blobs a medias. -/// Thread-safe: el content-addressing hace que dos escritores del mismo hash escriban lo mismo. +/// Content-addressed store over the file system (v1). Sharding aa/bb/hash to +/// avoid huge directories; atomic write (temp + rename) so blobs are never left half-written. +/// Thread-safe: content-addressing means two writers of the same hash write the same thing. /// public sealed class FileSystemBlobStore : IBlobStore { private readonly string _root; - /// Crea el almacén enraizado en (lo crea si no existe). + /// Creates the store rooted at (creates it if it does not exist). public FileSystemBlobStore(string rootDirectory) { ArgumentException.ThrowIfNullOrEmpty(rootDirectory); @@ -29,7 +29,7 @@ public async ValueTask PutAsync(VersionId id, ReadOnlyMemory blob, Cancell string path = PathFor(id); if (File.Exists(path)) { - return; // idempotente (dedup por hash) + return; // idempotent (dedup by hash) } Directory.CreateDirectory(Path.GetDirectoryName(path)!); @@ -37,8 +37,8 @@ public async ValueTask PutAsync(VersionId id, ReadOnlyMemory blob, Cancell await File.WriteAllBytesAsync(tmp, blob.ToArray(), ct).ConfigureAwait(false); try { - // Rename atómico dentro del mismo directorio; overwrite:false → si otro escritor ganó - // la carrera, ambos escribieron el MISMO contenido (content-addressed), así que da igual. + // Atomic rename within the same directory; overwrite:false → if another writer won + // the race, both wrote the SAME content (content-addressed), so it does not matter. File.Move(tmp, path, overwrite: false); } catch (IOException) when (File.Exists(path)) diff --git a/src/Weft.Versioning/Blobs/IBlobStore.cs b/src/Weft.Versioning/Blobs/IBlobStore.cs index f92207d..c149771 100644 --- a/src/Weft.Versioning/Blobs/IBlobStore.cs +++ b/src/Weft.Versioning/Blobs/IBlobStore.cs @@ -1,19 +1,19 @@ namespace Weft.Versioning.Blobs; /// -/// Almacén content-addressed (hash → blob). Las implementaciones deben ser thread-safe. -/// Sin delete en v1: las versiones publicadas son inmutables (FR-012); la retención es -/// dominio del consumidor. +/// Content-addressed store (hash → blob). Implementations must be thread-safe. +/// No delete in v1: published versions are immutable (FR-012); retention is +/// the consumer's domain. /// public interface IBlobStore { - /// Persiste un blob bajo su identidad. Idempotente: put del mismo contenido es no-op - /// (dedup natural por hash). + /// Persists a blob under its identity. Idempotent: putting the same content is a no-op + /// (natural dedup by hash). ValueTask PutAsync(VersionId id, ReadOnlyMemory blob, CancellationToken ct = default); - /// Devuelve el blob, o null si no existe. + /// Returns the blob, or null if it does not exist. ValueTask GetAsync(VersionId id, CancellationToken ct = default); - /// Indica si existe un blob para la identidad dada. + /// Indicates whether a blob exists for the given identity. ValueTask ExistsAsync(VersionId id, CancellationToken ct = default); } diff --git a/src/Weft.Versioning/Blobs/InMemoryBlobStore.cs b/src/Weft.Versioning/Blobs/InMemoryBlobStore.cs index 5af853d..65f7cf1 100644 --- a/src/Weft.Versioning/Blobs/InMemoryBlobStore.cs +++ b/src/Weft.Versioning/Blobs/InMemoryBlobStore.cs @@ -2,18 +2,18 @@ namespace Weft.Versioning.Blobs; -/// Almacén content-addressed en memoria (tests/dev). Thread-safe. +/// In-memory content-addressed store (tests/dev). Thread-safe. public sealed class InMemoryBlobStore : IBlobStore { private readonly ConcurrentDictionary _blobs = new(); - /// Número de blobs distintos almacenados (útil para verificar dedup/compactación). + /// Number of distinct blobs stored (useful to verify dedup/compaction). public int Count => _blobs.Count; /// public ValueTask PutAsync(VersionId id, ReadOnlyMemory blob, CancellationToken ct = default) { - // Idempotente: si ya existe, no se re-copia (dedup por hash). + // Idempotent: if it already exists, it is not re-copied (dedup by hash). _blobs.TryAdd(id, blob.ToArray()); return ValueTask.CompletedTask; } diff --git a/src/Weft.Versioning/TextDiff.cs b/src/Weft.Versioning/TextDiff.cs index ca3ea87..b2fdf8c 100644 --- a/src/Weft.Versioning/TextDiff.cs +++ b/src/Weft.Versioning/TextDiff.cs @@ -2,32 +2,32 @@ namespace Weft.Versioning; -/// Operación de un segmento de diff. +/// Operation of a diff segment. public enum DiffOp { - /// Texto sin cambios entre ambas versiones. + /// Text unchanged between the two versions. Equal, - /// Texto presente solo en la versión nueva. + /// Text present only in the new version. Inserted, - /// Texto presente solo en la versión antigua. + /// Text present only in the old version. Deleted, } -/// Un segmento contiguo de un diff de texto. +/// A contiguous segment of a text diff. public readonly record struct TextDiffSegment(DiffOp Op, string Text); /// -/// Diff de texto por palabras (research R9): LCS sobre tokens palabra/espacio, determinista -/// (mismas entradas → mismos segmentos). Alcance v1: texto plano por campo. +/// Word-level text diff (research R9): LCS over word/space tokens, deterministic +/// (same inputs → same segments). v1 scope: plain text per field. /// public sealed record TextDiff(IReadOnlyList Segments) { - /// Indica si hay al menos un segmento insertado o borrado. + /// Indicates whether there is at least one inserted or deleted segment. public bool HasChanges => Segments.Any(s => s.Op != DiffOp.Equal); - /// Computa el diff por palabras entre y . + /// Computes the word-level diff between and . public static TextDiff Compute(string oldText, string newText) { ArgumentNullException.ThrowIfNull(oldText); @@ -38,7 +38,7 @@ public static TextDiff Compute(string oldText, string newText) int[,] lcs = BuildLcsTable(a, b); var segments = new List(); - // Reconstrucción desde el final de la tabla LCS hacia el inicio. + // Reconstruction from the end of the LCS table back to the start. int i = a.Length, j = b.Length; var rev = new List(); while (i > 0 || j > 0) @@ -62,7 +62,7 @@ public static TextDiff Compute(string oldText, string newText) } rev.Reverse(); - // Fusiona tokens contiguos de la misma operación en un solo segmento legible. + // Merge contiguous tokens of the same operation into a single readable segment. foreach (TextDiffSegment token in rev) { if (segments.Count > 0 && segments[^1].Op == token.Op) @@ -77,7 +77,7 @@ public static TextDiff Compute(string oldText, string newText) return new TextDiff(segments); } - /// Tokeniza en unidades palabra y espacio (cada run de whitespace o no-whitespace). + /// Tokenizes into word and space units (each run of whitespace or non-whitespace). private static string[] Tokenize(string text) { if (text.Length == 0) diff --git a/src/Weft.Versioning/VersionId.cs b/src/Weft.Versioning/VersionId.cs index 1c99095..42554d5 100644 --- a/src/Weft.Versioning/VersionId.cs +++ b/src/Weft.Versioning/VersionId.cs @@ -4,30 +4,30 @@ namespace Weft.Versioning; /// -/// Identidad content-addressed de una versión: SHA-256 del export determinista (constitución P-III). -/// Value type inmutable; igualdad por valor; representación hex lowercase de 64 caracteres. +/// Content-addressed identity of a version: SHA-256 of the deterministic export (constitution P-III). +/// Immutable value type; equality by value; 64-character lowercase hex representation. /// public readonly struct VersionId : IEquatable { - private readonly byte[]? _bytes; // 32 bytes; null solo en el valor `default`. + private readonly byte[]? _bytes; // 32 bytes; null only in the `default` value. private VersionId(byte[] bytes) => _bytes = bytes; - /// Calcula la identidad de un blob (SHA-256). + /// Computes the identity of a blob (SHA-256). public static VersionId FromBlob(ReadOnlySpan blob) => new(SHA256.HashData(blob)); - /// Parsea una representación hex de 64 caracteres. - /// La cadena no es un hash SHA-256 hex válido. + /// Parses a 64-character hex representation. + /// The string is not a valid SHA-256 hex hash. public static VersionId Parse(string hex) { if (!TryParse(hex, out VersionId id)) { - throw new FormatException("Un VersionId debe ser 64 caracteres hexadecimales (SHA-256)."); + throw new FormatException("A VersionId must be 64 hexadecimal characters (SHA-256)."); } return id; } - /// Intenta parsear una representación hex de 64 caracteres. + /// Attempts to parse a 64-character hex representation. public static bool TryParse([NotNullWhen(true)] string? hex, out VersionId id) { id = default; @@ -46,10 +46,10 @@ public static bool TryParse([NotNullWhen(true)] string? hex, out VersionId id) return true; } - /// Los 32 bytes del hash. + /// The 32 bytes of the hash. public ReadOnlySpan AsSpan() => _bytes ?? []; - /// Representación hex lowercase (64 caracteres). + /// Lowercase hex representation (64 characters). public override string ToString() => _bytes is null ? "" : Convert.ToHexStringLower(_bytes); /// @@ -62,13 +62,13 @@ public static bool TryParse([NotNullWhen(true)] string? hex, out VersionId id) public override int GetHashCode() { var span = AsSpan(); - // Los bytes de un SHA-256 ya están uniformemente distribuidos: los primeros 4 bastan. + // The bytes of a SHA-256 are already uniformly distributed: the first 4 suffice. return span.Length >= 4 ? BitConverter.ToInt32(span[..4]) : 0; } - /// Igualdad por valor. + /// Equality by value. public static bool operator ==(VersionId left, VersionId right) => left.Equals(right); - /// Desigualdad por valor. + /// Inequality by value. public static bool operator !=(VersionId left, VersionId right) => !left.Equals(right); } diff --git a/src/Weft.Versioning/VersionStore.cs b/src/Weft.Versioning/VersionStore.cs index e2165a8..9eaa11f 100644 --- a/src/Weft.Versioning/VersionStore.cs +++ b/src/Weft.Versioning/VersionStore.cs @@ -4,17 +4,17 @@ namespace Weft.Versioning; /// -/// Publicar/cargar/comparar/ramificar/mezclar versiones de documentos, content-addressed y -/// engine-agnóstico (constitución P-IV: depende solo de las abstracciones de Weft.Core). -/// Thread-safe (sin estado mutable propio; la serialización del doc vivo es responsabilidad del -/// llamador o del DocumentBroker). +/// Publish/load/compare/branch/merge document versions, content-addressed and +/// engine-agnostic (constitution P-IV: depends only on the Weft.Core abstractions). +/// Thread-safe (no mutable state of its own; serializing the live doc is the responsibility of the +/// caller or of the DocumentBroker). /// public sealed class VersionStore { private readonly ICrdtEngine _engine; private readonly IBlobStore _blobs; - /// Crea el almacén de versiones sobre un motor y un almacén de blobs. + /// Creates the version store over an engine and a blob store. public VersionStore(ICrdtEngine engine, IBlobStore blobs) { ArgumentNullException.ThrowIfNull(engine); @@ -23,7 +23,7 @@ public VersionStore(ICrdtEngine engine, IBlobStore blobs) _blobs = blobs; } - /// Exporta, hashea y persiste el documento. Devuelve la identidad citable. + /// Exports, hashes and persists the document. Returns the citable identity. public async ValueTask PublishAsync(ICrdtDoc doc, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(doc); @@ -33,16 +33,16 @@ public async ValueTask PublishAsync(ICrdtDoc doc, CancellationToken c return id; } - /// Reconstruye un documento vivo desde una versión publicada (verifica integridad). - /// La versión no existe en el almacén. - /// El blob almacenado no verifica contra su hash. + /// Rebuilds a live document from a published version (verifies integrity). + /// The version does not exist in the store. + /// The stored blob does not verify against its hash. public async ValueTask CheckoutAsync(VersionId version, CancellationToken ct = default) { byte[] blob = await LoadVerifiedAsync(version, ct).ConfigureAwait(false); return _engine.LoadDoc(blob); } - /// Diff de texto por palabras entre dos versiones publicadas, en un campo dado. + /// Word-level text diff between two published versions, in a given field. public async ValueTask DiffAsync(VersionId a, VersionId b, string field, CancellationToken ct = default) { ArgumentException.ThrowIfNullOrEmpty(field); @@ -51,12 +51,12 @@ public async ValueTask DiffAsync(VersionId a, VersionId b, string fiel return TextDiff.Compute(da.GetText(field), db.GetText(field)); } - /// Rama: documento vivo independiente partiendo de la versión base (alias de Checkout). + /// Branch: independent live document starting from the base version (alias of Checkout). public ValueTask BranchAsync(VersionId from, CancellationToken ct = default) => CheckoutAsync(from, ct); - /// Merge CRDT: importa el estado de la rama en el destino (convergente, sin conflictos). - /// Los documentos pertenecen a motores distintos (yrs↔Loro). + /// CRDT merge: imports the branch state into the target (convergent, conflict-free). + /// The documents belong to different engines (yrs↔Loro). public void Merge(ICrdtDoc target, ICrdtDoc branch) { ArgumentNullException.ThrowIfNull(target); @@ -64,23 +64,23 @@ public void Merge(ICrdtDoc target, ICrdtDoc branch) if (target.EngineName != branch.EngineName) { throw new ArgumentException( - $"No se pueden mezclar documentos de motores distintos: destino '{target.EngineName}', " + - $"rama '{branch.EngineName}'. El formato de update no es intercambiable entre motores.", + $"Cannot merge documents from different engines: target '{target.EngineName}', " + + $"branch '{branch.EngineName}'. The update format is not interchangeable between engines.", nameof(branch)); } target.ApplyUpdate(branch.ExportState()); } - /// Merge CRDT desde una versión publicada hacia un documento vivo destino. - /// El destino pertenece a un motor distinto al del almacén. + /// CRDT merge from a published version into a target live document. + /// The target belongs to a different engine than the store. public async ValueTask MergeAsync(ICrdtDoc target, VersionId branchVersion, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(target); if (target.EngineName != _engine.Name) { throw new ArgumentException( - $"El documento destino pertenece al motor '{target.EngineName}', pero el almacén decodifica " + - $"con '{_engine.Name}'. El formato de update no es intercambiable entre motores.", + $"The target document belongs to engine '{target.EngineName}', but the store decodes " + + $"with '{_engine.Name}'. The update format is not interchangeable between engines.", nameof(target)); } byte[] blob = await LoadVerifiedAsync(branchVersion, ct).ConfigureAwait(false); @@ -92,12 +92,12 @@ private async ValueTask LoadVerifiedAsync(VersionId version, Cancellatio byte[]? blob = await _blobs.GetAsync(version, ct).ConfigureAwait(false); if (blob is null) { - throw new KeyNotFoundException($"No existe una versión publicada con id {version}."); + throw new KeyNotFoundException($"No published version exists with id {version}."); } if (VersionId.FromBlob(blob) != version) { throw new BlobIntegrityException( - $"El blob almacenado para {version} no verifica contra su hash (corrupción)."); + $"The stored blob for {version} does not verify against its hash (corruption)."); } return blob; } diff --git a/tests/Weft.Core.Tests/ConvergenceTests.cs b/tests/Weft.Core.Tests/ConvergenceTests.cs index 7e27c90..c89283d 100644 --- a/tests/Weft.Core.Tests/ConvergenceTests.cs +++ b/tests/Weft.Core.Tests/ConvergenceTests.cs @@ -5,14 +5,14 @@ namespace Weft.Core.Tests; /// -/// Property-based (T019, SC-001): secuencias aleatorias de ediciones concurrentes sobre N réplicas -/// que, tras intercambiar deltas, convergen a un estado byte-idéntico (mismo VersionId cross-réplica). +/// Property-based (T019, SC-001): random sequences of concurrent edits over N replicas +/// that, after exchanging deltas, converge to a byte-identical state (same VersionId across replicas). /// public sealed class ConvergenceTests { private static readonly ICrdtEngine Engine = YrsEngine.Instance; - // Una edición: a qué réplica (0/1/2) va, y qué texto corto inserta al inicio del campo. + // One edit: which replica (0/1/2) it goes to, and what short text it inserts at the start of the field. private static readonly Gen<(int replica, string text)> GenEdit = Gen.Select(Gen.Int[0, 2], Gen.String[Gen.Char['a', 'z'], 1, 6], (r, s) => (r, s)); @@ -24,14 +24,14 @@ public void Concurrent_edits_converge_byte_identical() ICrdtDoc[] replicas = [Engine.CreateDoc(), Engine.CreateDoc(), Engine.CreateDoc()]; try { - // Ediciones concurrentes: cada réplica edita su copia sin sincronizar aún. + // Concurrent edits: each replica edits its copy without syncing yet. foreach ((int replica, string text) in edits) { replicas[replica].InsertText("body", 0, text); } - // Sincronización todos-contra-todos por deltas incrementales (state-vector → since). - // Dos pasadas garantizan que los cambios propagados en la 1ª lleguen a todos. + // All-against-all synchronization via incremental deltas (state-vector → since). + // Two passes guarantee that changes propagated in the 1st reach everyone. for (int pass = 0; pass < 2; pass++) { foreach (ICrdtDoc target in replicas) @@ -47,7 +47,7 @@ public void Concurrent_edits_converge_byte_identical() } } - // Convergencia byte-idéntica: todas exportan el mismo estado (mismo VersionId). + // Byte-identical convergence: all export the same state (same VersionId). byte[] reference = replicas[0].ExportState(); return replicas.All(r => r.ExportState().AsSpan().SequenceEqual(reference)); } diff --git a/tests/Weft.Core.Tests/DeltaSizeBenchmark.cs b/tests/Weft.Core.Tests/DeltaSizeBenchmark.cs index da71ebd..be2f5a1 100644 --- a/tests/Weft.Core.Tests/DeltaSizeBenchmark.cs +++ b/tests/Weft.Core.Tests/DeltaSizeBenchmark.cs @@ -5,42 +5,43 @@ namespace Weft.Core.Tests; /// -/// Benchmark de tamaño de delta (T062, SC-004): en el escenario de referencia de reconexión, el -/// sync incremental transfiere ≥ 90 % menos bytes que reenviar el estado completo. +/// Delta size benchmark (T062, SC-004): in the reference reconnection scenario, incremental +/// sync transfers ≥ 90 % fewer bytes than resending the full state. /// /// /// -/// El escenario de referencia se define aquí porque la spec no lo definía. SC-004 -/// (spec.md:175) cita «523 B → 29 B», pero ese dato sale de una celda etiquetada -/// «Rough perf» en docs/spikes/spike03/hallazgos-spike-03.md:38, de un spike cuyo código -/// es desechable por diseño (docs/spikes/README.md:4-5) y no vive en este repo: no -/// documenta tamaño de documento, ni número de ediciones, ni qué se exportó exactamente. Se cita -/// como contexto histórico —523→29 es un 94,5 %, consistente con el umbral— y NO como expectativa -/// byte a byte: asertar esos absolutos ataría la suite a un spike irreproducible y la rompería -/// cualquier bump de yrs sin que nada estuviera mal. Lo vinculante de SC-004 es el ratio. +/// The reference scenario is defined here because the spec did not define it. SC-004 +/// (spec.md:175) cites «523 B → 29 B», but that figure comes from a cell labeled +/// «Rough perf» in docs/spikes/spike03/hallazgos-spike-03.md:38, from a spike whose code +/// is throwaway by design (docs/spikes/README.md:4-5) and does not live in this repo: it +/// documents neither document size, nor number of edits, nor exactly what was exported. It is cited +/// as historical context —523→29 is 94.5 %, consistent with the threshold— and NOT as a +/// byte-for-byte expectation: asserting those absolutes would tie the suite to an irreproducible +/// spike and would break it on any yrs bump with nothing actually wrong. What is binding in SC-004 +/// is the ratio. /// /// -/// La forma del escenario sale de la prosa de SC-004 («reconexión») y de la postcondición del -/// relay en contracts/server-api.md:116 («una reconexión con SV previo recibe solo el -/// delta»): +/// The shape of the scenario comes from the prose of SC-004 («reconnection») and from the +/// postcondition of the relay in contracts/server-api.md:116 («a reconnection with a prior +/// SV receives only the delta»): /// /// -/// Un autor tiene un documento de referencia: un párrafo de prosa, el orden de magnitud -/// (~500 B de estado) de la referencia del spike. -/// Un par se pone al día y captura su state vector — el «qué conozco» que enviará al -/// reconectar. -/// Mientras el par está desconectado, el autor recibe UNA edición pequeña. -/// Al reconectar, el par pide solo lo que le falta. +/// An author has a reference document: a paragraph of prose, the order of magnitude +/// (~500 B of state) of the spike reference. +/// A peer catches up and captures its state vector — the «what I know» that it will send +/// when reconnecting. +/// While the peer is disconnected, the author receives ONE small edit. +/// On reconnecting, the peer requests only what it is missing. /// /// -/// Se mide ExportUpdateSince(sv).Length (lo que viaja) contra ExportState().Length -/// (lo que viajaría sin sync incremental). El escenario se fijó antes de medir y el assert es el -/// umbral de la spec, no un número calibrado a posteriori. +/// We measure ExportUpdateSince(sv).Length (what travels) against ExportState().Length +/// (what would travel without incremental sync). The scenario was fixed before measuring and the +/// assert is the spec threshold, not a number calibrated after the fact. /// /// -/// Los client-ids son fijos (capacidad de YrsEngine, CHARTER-09/FU-012) para que el tamaño -/// medido no dependa del varint de un id aleatorio: un id de 53 bits ocupa varios bytes más que -/// uno pequeño, y eso es ruido que no pertenece a la medición. +/// The client-ids are fixed (a YrsEngine capability, CHARTER-09/FU-012) so that the measured +/// size does not depend on the varint of a random id: a 53-bit id takes several more bytes than a +/// small one, and that is noise that does not belong in the measurement. /// /// public sealed class DeltaSizeBenchmark @@ -52,7 +53,7 @@ public sealed class DeltaSizeBenchmark private const ulong AutorClientId = 1; private const ulong ParClientId = 2; - /// Documento de referencia: prosa, ~500 B de estado exportado. + /// Reference document: prose, ~500 B of exported state. private const string ParrafoReferencia = "El telar levanta la urdimbre y la trama cruza entre los hilos tensados; cada pasada fija " + "el dibujo que ya no podrá deshacerse sin destejer lo anterior. Quien mira la tela " + @@ -61,7 +62,7 @@ public sealed class DeltaSizeBenchmark "orden final no lo dicta quien llegó primero, sino la regla que todos aceptaron de " + "antemano."; - /// La edición que ocurre mientras el par está desconectado. + /// The edit that occurs while the peer is disconnected. private const string EdicionDuranteDesconexion = "Nota al margen: "; [Fact] @@ -70,35 +71,35 @@ public void Reconnect_delta_is_at_least_90_pct_smaller_than_full_state() using ICrdtDoc autor = YrsEngine.Instance.CreateDoc(AutorClientId); autor.InsertText("body", 0, ParrafoReferencia); - // El par se pone al día y captura su SV justo antes de desconectarse. + // The peer catches up and captures its SV just before disconnecting. using ICrdtDoc par = YrsEngine.Instance.CreateDoc(ParClientId); par.ApplyUpdate(autor.ExportState()); byte[] svAlDesconectar = par.ExportStateVector(); - // Mientras el par no está, el documento avanza. + // While the peer is away, the document moves forward. autor.InsertText("body", 0, EdicionDuranteDesconexion); - // Reconexión: lo que viaja vs lo que viajaría reenviando el estado completo. + // Reconnection: what travels vs what would travel by resending the full state. byte[] delta = autor.ExportUpdateSince(svAlDesconectar); byte[] estadoCompleto = autor.ExportState(); - // Un delta que no converge no es un delta barato, es un delta roto: medir su tamaño sin - // comprobar que sincroniza dejaría pasar un buffer vacío con una reducción del 100 %. + // A delta that does not converge is not a cheap delta, it is a broken delta: measuring its + // size without checking that it syncs would let an empty buffer with a 100 % reduction pass. par.ApplyUpdate(delta); Assert.Equal(autor.ExportState(), par.ExportState()); Assert.Equal(EdicionDuranteDesconexion + ParrafoReferencia, par.GetText("body")); double reduccion = 1.0 - ((double)delta.Length / estadoCompleto.Length); - // Un benchmark reporta su medición: el número es el entregable, no solo el verde/rojo. + // A benchmark reports its measurement: the number is the deliverable, not just the green/red. _output.WriteLine( - $"SC-004 · escenario de reconexión: estado completo={estadoCompleto.Length} B, " + - $"delta={delta.Length} B, reducción={reduccion:P1} (umbral ≥ 90 %). " + - $"Referencia histórica del spike03: 523 B → 29 B (94,5 %)."); + $"SC-004 · reconnection scenario: full state={estadoCompleto.Length} B, " + + $"delta={delta.Length} B, reduction={reduccion:P1} (threshold ≥ 90 %). " + + $"Historical reference from spike03: 523 B → 29 B (94.5 %)."); Assert.True( reduccion >= 0.90, - $"SC-004: delta={delta.Length} B vs estado completo={estadoCompleto.Length} B → " + - $"reducción medida {reduccion:P1}, se exige ≥ 90 %."); + $"SC-004: delta={delta.Length} B vs full state={estadoCompleto.Length} B → " + + $"measured reduction {reduccion:P1}, ≥ 90 % is required."); } } diff --git a/tests/Weft.Core.Tests/DocumentBrokerTests.cs b/tests/Weft.Core.Tests/DocumentBrokerTests.cs index 8ece6fb..e020c07 100644 --- a/tests/Weft.Core.Tests/DocumentBrokerTests.cs +++ b/tests/Weft.Core.Tests/DocumentBrokerTests.cs @@ -5,20 +5,20 @@ namespace Weft.Core.Tests; /// -/// Contrato de concurrencia del (T040, US2, constitución P-V): serialización -/// estricta por documento, orden FIFO por sesión, desalojo→persistencia→reapertura, propagación de fallo y -/// semántica de dispose. Los casos de serialización/fallo usan un motor de prueba que instrumenta la -/// concurrencia; los de ciclo de vida usan el motor yrs real. +/// Concurrency contract of the (T040, US2, constitution P-V): strict +/// per-document serialization, FIFO ordering per session, eviction→persistence→reopen, fault +/// propagation and dispose semantics. The serialization/fault cases use a test engine that +/// instruments concurrency; the lifecycle ones use the real yrs engine. /// /// -/// El Category=Concurrency es el que selecciona el comando de US2 en quickstart.md; -/// sin él ese filtro no casaba con ningún test y el paso del runbook pasaba en verde ejecutando -/// cero tests (detectado en el pase de validación de T063, CHARTER-11). +/// The Category=Concurrency is what the US2 command in quickstart.md selects; +/// without it that filter matched no test and the runbook step passed green while running +/// zero tests (detected in the T063 validation pass, CHARTER-11). /// [Trait("Category", "Concurrency")] public sealed class DocumentBrokerTests { - // -- Serialización (Acceptance Scenario 1): nunca dos operaciones simultáneas del mismo documento -- + // -- Serialization (Acceptance Scenario 1): never two simultaneous operations on the same document -- [Fact] public async Task Operations_on_same_document_never_run_concurrently() @@ -37,11 +37,11 @@ public async Task Operations_on_same_document_never_run_concurrently() await Task.WhenAll(writers); TrackingDoc doc = Assert.Single(engine.Docs); - Assert.Equal(1, doc.PeakConcurrency); // el actor serializó todo el acceso + Assert.Equal(1, doc.PeakConcurrency); // the actor serialized all access Assert.Equal(50 * 20, (await session.GetTextAsync("body")).Length); } - // -- FIFO por sesión: las operaciones encoladas se aplican en el orden de encolado -- + // -- FIFO per session: enqueued operations are applied in enqueue order -- [Fact] public async Task Operations_from_a_session_apply_in_FIFO_order() @@ -52,7 +52,7 @@ public async Task Operations_from_a_session_apply_in_FIFO_order() var pending = new List(); for (int i = 0; i < 10; i++) { - // encolado síncrono en orden; inserta el dígito i en la posición i + // synchronous enqueue in order; inserts digit i at position i pending.Add(session.InsertTextAsync("body", i, i.ToString()).AsTask()); } await Task.WhenAll(pending); @@ -60,7 +60,7 @@ public async Task Operations_from_a_session_apply_in_FIFO_order() Assert.Equal("0123456789", await session.GetTextAsync("body")); } - // -- Acceptance Scenario 2: desalojo por inactividad → OnEvicting → reapertura desde lo persistido -- + // -- Acceptance Scenario 2: idle eviction → OnEvicting → reopen from what was persisted -- [Fact] public async Task Idle_document_is_evicted_persisted_and_can_be_reopened() @@ -70,7 +70,7 @@ public async Task Idle_document_is_evicted_persisted_and_can_be_reopened() var options = new DocumentBrokerOptions { IdleEviction = TimeSpan.FromMilliseconds(20), - IdleSweepInterval = TimeSpan.FromHours(1), // barrido automático desactivado: lo disparamos manual + IdleSweepInterval = TimeSpan.FromHours(1), // automatic sweep disabled: we trigger it manually OnEvicting = (id, state, ct) => { persisted = state; @@ -82,21 +82,21 @@ public async Task Idle_document_is_evicted_persisted_and_can_be_reopened() DocumentSession session = await broker.OpenAsync("doc-1"); await session.InsertTextAsync("body", 0, "hola"); - await session.DisposeAsync(); // sin sesiones vivas → candidato a desalojo - await Task.Delay(60); // superar IdleEviction (20ms) - await broker.SweepOnceAsync(); // fuerza el barrido (no esperar al timer) + await session.DisposeAsync(); // no live sessions → eviction candidate + await Task.Delay(60); // exceed IdleEviction (20ms) + await broker.SweepOnceAsync(); // force the sweep (don't wait for the timer) Assert.Equal(1, evictions); Assert.NotNull(persisted); Assert.Equal(0, broker.ActiveDocumentCount); - // Reabrir con un loader que devuelve el estado persistido → contenido restaurado. + // Reopen with a loader that returns the persisted state → content restored. await using DocumentSession reopened = await broker.OpenAsync( "doc-1", (id, ct) => ValueTask.FromResult(persisted)); Assert.Equal("hola", await reopened.GetTextAsync("body")); } - // -- LRU: al superar el máximo, se desaloja el menos recientemente usado (sin sesiones vivas) -- + // -- LRU: when the maximum is exceeded, the least recently used is evicted (with no live sessions) -- [Fact] public async Task Over_capacity_evicts_least_recently_used_without_sessions() @@ -104,12 +104,12 @@ public async Task Over_capacity_evicts_least_recently_used_without_sessions() var options = new DocumentBrokerOptions { MaxActiveDocuments = 2, - IdleEviction = TimeSpan.FromHours(1), // aislar: solo queremos ver el desalojo por LRU + IdleEviction = TimeSpan.FromHours(1), // isolate: we only want to see LRU eviction }; await using var broker = new DocumentBroker(YrsEngine.Instance, options); - // Tres documentos, cerrando cada sesión (sin sesiones vivas → elegibles para LRU). El delay - // separa los timestamps de inactividad, así 'a' es el menos recientemente usado. + // Three documents, closing each session (no live sessions → eligible for LRU). The delay + // separates the idle timestamps, so 'a' is the least recently used. foreach (string id in new[] { "a", "b", "c" }) { DocumentSession s = await broker.OpenAsync(id); @@ -118,13 +118,13 @@ public async Task Over_capacity_evicts_least_recently_used_without_sessions() await Task.Delay(EvictionGrace); } - Assert.Equal(3, broker.ActiveDocumentCount); // el límite es suave hasta el barrido + Assert.Equal(3, broker.ActiveDocumentCount); // the limit is soft until the sweep await broker.SweepOnceAsync(); - Assert.Equal(2, broker.ActiveDocumentCount); // 'a' (LRU) desalojado + Assert.Equal(2, broker.ActiveDocumentCount); // 'a' (LRU) evicted - // Verificar la IDENTIDAD del desalojado, no solo el conteo: 'a' (LRU, sin OnEvicting/loader) se - // perdió → reabrir da documento vacío; 'b' y 'c' siguen activos con su contenido. Un LRU que - // hubiera desalojado el MRU pasaría el assert de conteo pero fallaría estos. + // Verify the IDENTITY of the evicted one, not just the count: 'a' (LRU, without OnEvicting/loader) + // was lost → reopening gives an empty document; 'b' and 'c' remain active with their content. An + // LRU that had evicted the MRU would pass the count assert but fail these. await using (DocumentSession ra = await broker.OpenAsync("a")) { Assert.Equal("", await ra.GetTextAsync("body")); @@ -139,7 +139,7 @@ public async Task Over_capacity_evicts_least_recently_used_without_sessions() } } - // -- Un handler de UpdateApplied que lanza NO debe faultear el actor (finding G, para M2) -- + // -- An UpdateApplied handler that throws must NOT fault the actor (finding G, for M2) -- [Fact] public async Task Throwing_UpdateApplied_handler_does_not_fault_the_actor() @@ -148,23 +148,23 @@ public async Task Throwing_UpdateApplied_handler_does_not_fault_the_actor() await using DocumentSession session = await broker.OpenAsync("doc"); session.UpdateApplied += (_, _) => throw new InvalidOperationException("handler boom"); - // La inserción dispara UpdateApplied → el handler lanza, pero el actor debe seguir sano. + // The insertion fires UpdateApplied → the handler throws, but the actor must stay healthy. await session.InsertTextAsync("body", 0, "hola"); Assert.Equal("hola", await session.GetTextAsync("body")); - // Operaciones subsiguientes siguen funcionando (el actor no faulteó). + // Subsequent operations keep working (the actor did not fault). await session.InsertTextAsync("body", 0, "! "); Assert.Equal("! hola", await session.GetTextAsync("body")); } - // -- UpdateApplied se dispara con un delta aplicable, para otras sesiones del mismo doc (finding J) -- + // -- UpdateApplied fires with an applicable delta, for other sessions of the same doc (finding J) -- [Fact] public async Task UpdateApplied_fires_with_applicable_delta_for_other_sessions() { await using var broker = new DocumentBroker(YrsEngine.Instance); await using DocumentSession writer = await broker.OpenAsync("doc"); - await using DocumentSession observer = await broker.OpenAsync("doc"); // misma doc, otra sesión + await using DocumentSession observer = await broker.OpenAsync("doc"); // same doc, another session var gotDelta = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); observer.UpdateApplied += (_, delta) => gotDelta.TrySetResult(delta.ToArray()); @@ -174,13 +174,13 @@ public async Task UpdateApplied_fires_with_applicable_delta_for_other_sessions() byte[] applied = await gotDelta.Task.WaitAsync(TimeSpan.FromSeconds(5)); Assert.NotEmpty(applied); - // El delta es aplicable a un documento fresco y reproduce el texto (superficie de relay para M2). + // The delta is applicable to a fresh document and reproduces the text (relay surface for M2). using ICrdtDoc fresh = YrsEngine.Instance.CreateDoc(); fresh.ApplyUpdate(applied); Assert.Equal("hola", fresh.GetText("body")); } - // -- Actor en fallo irrecuperable: propaga la excepción causal a las operaciones pendientes/futuras -- + // -- Actor in unrecoverable fault: propagates the causal exception to pending/future operations -- [Fact] public async Task Faulted_actor_propagates_causal_exception() @@ -191,8 +191,8 @@ public async Task Faulted_actor_propagates_causal_exception() var boom = new InvalidOperationException("boom"); var gate = new TaskCompletionSource(); - // Turno que bloquea el actor hasta 'gate' y luego lanza: garantiza que 'pending' quede encolada - // DETRÁS antes de que el fallo ocurra (test determinista). + // A turn that blocks the actor until 'gate' and then throws: guarantees that 'pending' is enqueued + // BEHIND before the fault occurs (deterministic test). Task faulting = session.ExecuteAsync(_ => { gate.Task.Wait(); throw boom; }).AsTask(); Task pending = session.GetTextAsync("body").AsTask(); gate.SetResult(); @@ -204,13 +204,13 @@ public async Task Faulted_actor_propagates_causal_exception() Assert.Same(boom, fromFaulting); Assert.Same(boom, fromPending); - // Operaciones futuras también fallan con la misma causal. + // Future operations also fail with the same causal exception. InvalidOperationException fromFuture = await Assert.ThrowsAsync(async () => await session.GetTextAsync("body")); Assert.Same(boom, fromFuture); } - // -- Dispose semantics: error predecible de la plataforma, nunca crash (Acceptance Scenario 3) -- + // -- Dispose semantics: predictable platform error, never a crash (Acceptance Scenario 3) -- [Fact] public async Task Using_a_disposed_session_throws_ObjectDisposedException() @@ -238,7 +238,7 @@ await Assert.ThrowsAsync( private static readonly TimeSpan EvictionGrace = TimeSpan.FromMilliseconds(300); - // -- Motor de prueba que instrumenta la concurrencia observada por documento -- + // -- Test engine that instruments the concurrency observed per document -- private sealed class TrackingEngine : ICrdtEngine { @@ -306,7 +306,7 @@ private T Guarded(Func body) InterlockedMax(ref _peak, now); try { - Thread.SpinWait(100); // ensanchar la ventana para delatar cualquier solape + Thread.SpinWait(100); // widen the window to expose any overlap return body(); } finally diff --git a/tests/Weft.Core.Tests/PanicSafetyTests.cs b/tests/Weft.Core.Tests/PanicSafetyTests.cs index dedc2d8..c89d2ca 100644 --- a/tests/Weft.Core.Tests/PanicSafetyTests.cs +++ b/tests/Weft.Core.Tests/PanicSafetyTests.cs @@ -5,9 +5,9 @@ namespace Weft.Core.Tests; /// -/// Panic-safety en la frontera (T020, SC-009): un panic del motor se captura como -/// WEFT_ERR_PANIC (nunca cruza la frontera C, que sería UB), el proceso sigue estable, y el -/// binding lo mapea a con . +/// Panic-safety at the boundary (T020, SC-009): an engine panic is caught as +/// WEFT_ERR_PANIC (it never crosses the C boundary, which would be UB), the process stays +/// stable, and the binding maps it to with . /// public sealed class PanicSafetyTests { @@ -31,8 +31,8 @@ public void Test_panic_is_caught_and_process_stays_alive() { Assert.True( File.Exists(NativeLibraryPath), - $"El cdylib con feature test-hooks no se encontró en {NativeLibraryPath}. " + - "Compila: cargo build --release --features test-hooks en native/."); + $"The cdylib with the test-hooks feature was not found at {NativeLibraryPath}. " + + "Build it: cargo build --release --features test-hooks in native/."); nint handle = NativeLibrary.Load(NativeLibraryPath); try @@ -40,7 +40,7 @@ public void Test_panic_is_caught_and_process_stays_alive() nint fnPtr = NativeLibrary.GetExport(handle, "weft_test_panic"); var testPanic = Marshal.GetDelegateForFunctionPointer(fnPtr); - // Invocarlo muchas veces: el panic se captura cada vez y el proceso no crashea. + // Invoke it many times: the panic is caught each time and the process does not crash. for (int i = 0; i < 100; i++) { Assert.Equal(-127, testPanic()); // WEFT_ERR_PANIC @@ -50,7 +50,7 @@ public void Test_panic_is_caught_and_process_stays_alive() { NativeLibrary.Free(handle); } - // Llegar aquí sin crash es la evidencia de estabilidad del proceso (SC-009). + // Reaching here without a crash is the evidence of process stability (SC-009). } [Fact] diff --git a/tests/Weft.Core.Tests/Utf16IndexingTests.cs b/tests/Weft.Core.Tests/Utf16IndexingTests.cs index 0baea2c..adb4244 100644 --- a/tests/Weft.Core.Tests/Utf16IndexingTests.cs +++ b/tests/Weft.Core.Tests/Utf16IndexingTests.cs @@ -4,9 +4,9 @@ namespace Weft.Core.Tests; /// -/// Regresión: los índices de insert/delete son UTF-16 code units (consistentes con string de .NET -/// y con Yjs), no bytes UTF-8 (el default de yrs). Un bug latente de CHARTER-01 sobre texto -/// no-ASCII, corregido con OffsetKind::Utf16 en el shim. +/// Regression: insert/delete indices are UTF-16 code units (consistent with .NET string +/// and with Yjs), not UTF-8 bytes (the yrs default). A latent CHARTER-01 bug on non-ASCII +/// text, fixed with OffsetKind::Utf16 in the shim. /// public sealed class Utf16IndexingTests { @@ -16,7 +16,7 @@ public void Delete_spans_correct_utf16_units_with_accents() using ICrdtDoc doc = YrsEngine.Instance.CreateDoc(); doc.InsertText("f", 0, "El veloz murciélago"); Assert.Equal(19, doc.GetText("f").Length); // UTF-16 code units - doc.DeleteText("f", 9, 10); // borra "murciélago" + doc.DeleteText("f", 9, 10); // deletes "murciélago" Assert.Equal("El veloz ", doc.GetText("f")); doc.InsertText("f", 9, "colibrí"); Assert.Equal("El veloz colibrí", doc.GetText("f")); diff --git a/tests/Weft.Core.Tests/YrsDocTests.cs b/tests/Weft.Core.Tests/YrsDocTests.cs index 1bcd3d1..3d3acf0 100644 --- a/tests/Weft.Core.Tests/YrsDocTests.cs +++ b/tests/Weft.Core.Tests/YrsDocTests.cs @@ -4,8 +4,8 @@ namespace Weft.Core.Tests; /// -/// Unit tests del binding yrs (T018): round-trip byte-idéntico, mapeo de errores tipificados, -/// semántica de dispose y buffers vacíos. +/// Unit tests for the yrs binding (T018): byte-identical round-trip, typed error mapping, +/// dispose semantics and empty buffers. /// public sealed class YrsDocTests { @@ -31,7 +31,7 @@ public void Delete_removes_range() { using ICrdtDoc doc = Engine.CreateDoc(); doc.InsertText("body", 0, "Hola mundo"); - doc.DeleteText("body", 4, 6); // borra " mundo" + doc.DeleteText("body", 4, 6); // deletes " mundo" Assert.Equal("Hola", doc.GetText("body")); } @@ -43,7 +43,7 @@ public void Roundtrip_export_load_is_byte_identical() byte[] blob = doc.ExportState(); using ICrdtDoc reloaded = Engine.LoadDoc(blob); - Assert.Equal(blob, reloaded.ExportState()); // byte-idéntico (P-III) + Assert.Equal(blob, reloaded.ExportState()); // byte-identical (P-III) Assert.Equal("contenido áéí", reloaded.GetText("body")); } @@ -86,7 +86,7 @@ public void Corrupt_apply_throws_corrupt_update() public void Out_of_range_index_throws_argument_out_of_range() { using ICrdtDoc doc = Engine.CreateDoc(); - // Índice válido para C# (>= 0) pero fuera de rango en el motor → OUT_OF_BOUNDS. + // Index valid for C# (>= 0) but out of range in the engine → OUT_OF_BOUNDS. Assert.Throws(() => doc.InsertText("body", 5, "x")); } diff --git a/tests/Weft.Determinism.Tests/DeterminismTests.cs b/tests/Weft.Determinism.Tests/DeterminismTests.cs index 90a84ef..0c29fc9 100644 --- a/tests/Weft.Determinism.Tests/DeterminismTests.cs +++ b/tests/Weft.Determinism.Tests/DeterminismTests.cs @@ -9,16 +9,16 @@ namespace Weft.Determinism.Tests; /// -/// Gate de determinismo del encoding (T029, constitución P-III): la identidad de una versión es -/// reproducible. Base del job cross-RID — el determinismo cross-implementación absoluto vs Yjs JS -/// (mismo hash en TODOS los RIDs) se completa en T058 (US4) con el corpus compartido; aquí se fijan -/// las propiedades reproducibles del encoding que ese job compara. +/// Encoding determinism gate (T029, constitution P-III): a version's identity is +/// reproducible. Foundation for the cross-RID job — absolute cross-implementation determinism vs Yjs JS +/// (same hash on ALL RIDs) is completed in T058 (US4) with the shared corpus; here we fix +/// the reproducible encoding properties that job compares. /// public sealed class DeterminismTests { private static readonly ICrdtEngine Engine = YrsEngine.Instance; - // Corpus determinista: secuencia fija de ediciones repartidas entre 3 réplicas. + // Deterministic corpus: a fixed sequence of edits spread across 3 replicas. private static readonly (int replica, string op, string text, int index, int len)[] Corpus = [ (0, "ins", "El veloz ", 0, 0), @@ -39,7 +39,7 @@ private static void ApplyCorpus(ICrdtDoc[] replicas) } else { - // Aplica el borrado solo si hay suficiente contenido (robustez del corpus). + // Apply the deletion only if there is enough content (corpus robustness). if (replicas[r].GetText("body").Length >= index + len) { replicas[r].DeleteText("body", index, len); @@ -50,7 +50,7 @@ private static void ApplyCorpus(ICrdtDoc[] replicas) private static void SyncAll(ICrdtDoc[] replicas) { - // Dos pasadas todos-contra-todos: garantiza convergencia con 3 réplicas. + // Two all-against-all passes: guarantees convergence with 3 replicas. for (int pass = 0; pass < 2; pass++) { foreach (ICrdtDoc target in replicas) @@ -67,7 +67,7 @@ private static void SyncAll(ICrdtDoc[] replicas) } } - // Réplicas convergidas tras el mismo corpus → VersionId idéntico (SC-002, P-III). + // Replicas converged after the same corpus → identical VersionId (SC-002, P-III). [Fact] public async Task Converged_replicas_share_version_id() { @@ -84,7 +84,7 @@ public async Task Converged_replicas_share_version_id() { Assert.Equal(reference, await store.PublishAsync(r)); } - // Un solo blob: todas las réplicas convergieron al mismo estado (dedup). + // A single blob: all replicas converged to the same state (dedup). Assert.Equal(1, blobs.Count); } finally @@ -96,12 +96,12 @@ public async Task Converged_replicas_share_version_id() } } - // ── Paridad cross-implementación vs Yjs JS (FU-012/CHARTER-09, research R13, P-III) ────────── - // El export v1 de yrs sobre el corpus compartido debe ser BYTE-IDÉNTICO al de Yjs JS — es decir, - // el determinismo de Weft es "por formato" (encoding v1 de Yjs/yrs), no un accidente de esta - // versión de yrs. Requiere client-ids deterministas (YrsEngine.CreateDoc(clientId), FU-012). El - // hash golden de Yjs vive en tests/determinism-yjs/golden.json; el harness Node lo regenera y - // self-checkea en release.yml (caza drift de Yjs). Esta aserción es el gate BLOQUEANTE per-PR. + // ── Cross-implementation parity vs Yjs JS (FU-012/CHARTER-09, research R13, P-III) ────────── + // The yrs v1 export over the shared corpus must be BYTE-IDENTICAL to that of Yjs JS — that is, + // Weft's determinism is "by format" (Yjs/yrs v1 encoding), not an accident of this + // version of yrs. Requires deterministic client-ids (YrsEngine.CreateDoc(clientId), FU-012). The + // Yjs golden hash lives in tests/determinism-yjs/golden.json; the Node harness regenerates and + // self-checks it in release.yml (catches Yjs drift). This assertion is the per-PR BLOCKING gate. [Theory] [InlineData("corpus.json", "ascii")] @@ -115,7 +115,7 @@ public void Yrs_export_matches_yjs_golden(string corpusFile, string goldenKey) ICrdtDoc[] replicas = [.. corpus.ClientIds.Select(id => YrsEngine.Instance.CreateDoc((ulong)id))]; try { - // Aplicar cada op a su réplica (sin guard de longitud: paridad exacta con apply.mjs). + // Apply each op to its replica (no length guard: exact parity with apply.mjs). foreach (CorpusOp step in corpus.Ops) { ICrdtDoc doc = replicas[step.Replica]; @@ -133,7 +133,7 @@ public void Yrs_export_matches_yjs_golden(string corpusFile, string goldenKey) } } - // Sincronizar todos-contra-todos hasta converger (mismo esquema que apply.mjs). + // Sync all-against-all until convergence (same scheme as apply.mjs). for (int pass = 0; pass < corpus.SyncPasses; pass++) { foreach (ICrdtDoc target in replicas) @@ -163,14 +163,14 @@ public void Yrs_export_matches_yjs_golden(string corpusFile, string goldenKey) } } - // ── Auto-determinismo de Loro con peer_id sembrado (FU-016/CHARTER-13, research R13(a), P-III) ── - // A diferencia del gate de yrs (paridad vs Yjs, una implementación INDEPENDIENTE), Loro NO tiene - // contraparte independiente: loro-crdt de npm es un build wasm del MISMO core Rust, así que un - // "gate Loro↔referencia" sería tautológico. Lo que este gate fija es más modesto y honesto: con un - // peer_id sembrado, el export de Loro sobre el corpus compartido es ESTABLE cross-run y cross-RID. - // El golden es un TESTIGO DE REGRESIÓN, no una prueba de paridad: su valor es cazar un cambio de - // encoding al bumpear `loro` (R16). Lo habilita que record_timestamp sea false por defecto en loro - // 1.13.6 (verificado); si eso cambiara, este gate lo detectaría al regenerar el golden. + // ── Loro self-determinism with a seeded peer_id (FU-016/CHARTER-13, research R13(a), P-III) ── + // Unlike the yrs gate (parity vs Yjs, an INDEPENDENT implementation), Loro has NO + // independent counterpart: npm's loro-crdt is a wasm build of the SAME Rust core, so a + // "Loro↔reference gate" would be tautological. What this gate fixes is more modest and honest: with a + // seeded peer_id, Loro's export over the shared corpus is STABLE cross-run and cross-RID. + // The golden is a REGRESSION WITNESS, not a parity proof: its value is catching an encoding + // change when bumping `loro` (R16). It is enabled by record_timestamp defaulting to false in loro + // 1.13.6 (verified); if that changed, this gate would detect it when regenerating the golden. [Theory] [InlineData("corpus.json", "ascii")] [InlineData("corpus-unicode.json", "unicode")] @@ -189,14 +189,14 @@ public void Loro_seeded_export_matches_golden(string corpusFile, string goldenKe [InlineData("corpus-unicode.json")] public void Loro_seeded_export_is_stable_across_runs(string corpusFile) { - // El auto-determinismo es la premisa del golden: sin él, fijar un hash no tendría sentido. + // Self-determinism is the premise of the golden: without it, pinning a hash would be meaningless. CorpusSpec corpus = LoadCorpus(Path.Combine(DeterminismCorpusDir(), corpusFile)); Assert.Equal(LoroSeededExportHash(corpus), LoroSeededExportHash(corpus)); } - // Aplica el corpus compartido a réplicas de Loro sembradas con los ClientIds como peer_ids (los - // ClientIds del corpus son int pequeños, válidos para ambos motores), sincroniza a convergencia y - // devuelve el SHA-256 del export de la réplica 0 — mismo esquema que el gate de yrs. + // Applies the shared corpus to Loro replicas seeded with the ClientIds as peer_ids (the + // corpus ClientIds are small ints, valid for both engines), syncs to convergence and + // returns the SHA-256 of replica 0's export — same scheme as the yrs gate. private static string LoroSeededExportHash(CorpusSpec corpus) { IDeterministicSeeding seeding = LoroEngine.Instance.DeterministicSeeding @@ -248,7 +248,7 @@ private static string LoroSeededExportHash(CorpusSpec corpus) } } - // Localiza tests/determinism-yjs/ subiendo desde el binario del test hasta la raíz del repo. + // Locates tests/determinism-yjs/ by walking up from the test binary to the repo root. private static string DeterminismCorpusDir() { for (DirectoryInfo? d = new(AppContext.BaseDirectory); d is not null; d = d.Parent) @@ -260,12 +260,12 @@ private static string DeterminismCorpusDir() } } - throw new DirectoryNotFoundException("No se encontró tests/determinism-yjs/ desde el binario del test."); + throw new DirectoryNotFoundException("tests/determinism-yjs/ not found from the test binary."); } private static CorpusSpec LoadCorpus(string path) => JsonSerializer.Deserialize(File.ReadAllText(path), CorpusJson) - ?? throw new InvalidOperationException($"corpus vacío: {path}"); + ?? throw new InvalidOperationException($"empty corpus: {path}"); private static string GoldenHash(string path, string key) { @@ -289,8 +289,8 @@ private sealed record CorpusOp( string? Text, int Len); - // El encoding es estable: cargar un blob y re-exportar es byte-idéntico, indefinidamente - // (la propiedad que hace que un VersionId sea citable cross-plataforma). + // The encoding is stable: loading a blob and re-exporting is byte-identical, indefinitely + // (the property that makes a VersionId citable cross-platform). [Fact] public void Reload_and_reexport_is_byte_stable() { @@ -304,8 +304,8 @@ public void Reload_and_reexport_is_byte_stable() { using ICrdtDoc reloaded = Engine.LoadDoc(current); byte[] next = reloaded.ExportState(); - Assert.Equal(original, next); // byte-idéntico en cada recarga - Assert.Equal(id, VersionId.FromBlob(next)); // mismo hash + Assert.Equal(original, next); // byte-identical on every reload + Assert.Equal(id, VersionId.FromBlob(next)); // same hash current = next; } } diff --git a/tests/Weft.LoadTest/Program.cs b/tests/Weft.LoadTest/Program.cs index 4ea319a..f58326b 100644 --- a/tests/Weft.LoadTest/Program.cs +++ b/tests/Weft.LoadTest/Program.cs @@ -4,17 +4,17 @@ using Weft.LoadTest; using Weft.Yrs; -// Modo relay (FU-010/CHARTER-14): mide la latencia de broadcast del relay real en ambos modos de -// durabilidad. Distinto de la carga de US2 de abajo (que conduce el broker directamente, ciega al relay). +// Relay mode (FU-010/CHARTER-14): measures the broadcast latency of the real relay in both durability +// modes. Distinct from the US2 load below (which drives the broker directly, blind to the relay). if (Array.IndexOf(args, "--relay") >= 0) { return await RelayLoad.RunAsync(ArgInt(args, "--edits", 200)); } -// Prueba de carga de US2/M1 (SC-006): cientos de documentos y muchas tareas concurrentes editando al -// azar durante un período sostenido. Verifica (a) consistencia final de cada documento y (b) memoria -// acotada — el número de documentos activos se mantiene bajo el límite pese a que el total supera el pool -// (desalojo idle+LRU con persistencia y reapertura). Salida distinta de cero si algo falla (gate CI). +// US2/M1 load test (SC-006): hundreds of documents and many concurrent tasks editing at +// random over a sustained period. Verifies (a) final consistency of each document and (b) bounded +// memory — the number of active documents stays under the limit even though the total exceeds the pool +// (idle+LRU eviction with persistence and reopening). Non-zero exit if anything fails (CI gate). int docs = ArgInt(args, "--docs", 300); int tasks = ArgInt(args, "--tasks", 8); @@ -24,7 +24,7 @@ Console.WriteLine($"[load-test] docs={docs} tasks={tasks} seconds={seconds} max-active={maxActive} " + $"gc-server={System.Runtime.GCSettings.IsServerGC}"); -// "Persistencia" en memoria: el hook OnEvicting guarda aquí el estado; el loader lo relee al reabrir. +// In-memory "persistence": the OnEvicting hook saves the state here; the loader re-reads it on reopen. var store = new ConcurrentDictionary(StringComparer.Ordinal); long evictions = 0; long confirmedOps = 0; @@ -33,8 +33,8 @@ var options = new DocumentBrokerOptions { MaxActiveDocuments = maxActive, - // Idle agresivo: fuerza desalojo/reapertura constantes bajo carga → ejercita la carrera - // desalojo-vs-reapertura (persistencia + recarga) que SC-006 exige sin pérdida de updates. + // Aggressive idle: forces constant eviction/reopen under load → exercises the + // eviction-vs-reopen race (persistence + reload) that SC-006 requires without losing updates. IdleEviction = TimeSpan.FromMilliseconds(30), IdleSweepInterval = TimeSpan.FromMilliseconds(10), OnEvicting = (id, state, ct) => @@ -45,7 +45,7 @@ }, }; -// Contador de inserciones confirmadas por documento: la longitud final del texto debe igualarlo. +// Count of confirmed inserts per document: the final text length must equal it. long[] inserts = new long[docs]; await using var broker = new DocumentBroker(YrsEngine.Instance, options); @@ -53,7 +53,7 @@ Func> loader = (id, ct) => ValueTask.FromResult(store.TryGetValue(id, out byte[]? blob) ? blob : null); -// Muestreo del pico de documentos activos durante la carga (evidencia de memoria acotada). +// Sampling of the peak of active documents during the load (evidence of bounded memory). int peakActive = 0; using var samplerStop = new CancellationTokenSource(); Task sampler = Task.Run(async () => @@ -72,8 +72,8 @@ var sw = Stopwatch.StartNew(); long deadlineTicks = sw.ElapsedMilliseconds + (seconds * 1000L); -// Cada documento crece hasta un tope y luego solo se lee: acota el TAMAÑO (memoria por doc), -// mientras el pooling acota el NÚMERO de documentos activos. Ambos → memoria acotada (SC-006). +// Each document grows up to a cap and is then read-only: bounds the SIZE (memory per doc), +// while pooling bounds the NUMBER of active documents. Both → bounded memory (SC-006). const int perDocCap = 150; Task[] workers = Enumerable.Range(0, tasks).Select(workerId => Task.Run(async () => @@ -98,17 +98,17 @@ } else { - await session.GetTextAsync("body"); // mantiene el churn de apertura/desalojo sin crecer + await session.GetTextAsync("body"); // keeps the open/evict churn without growing } } catch (Exception ex) { - // En operación correcta no debería ocurrir (una sesión viva protege su documento del - // desalojo). Cualquier fallo aquí es una regresión de la capa de concurrencia. + // Under correct operation this should not happen (a live session protects its document from + // eviction). Any failure here is a regression in the concurrency layer. Interlocked.Increment(ref errors); if (Interlocked.Read(ref errors) <= 5) { - Console.WriteLine($"[load-test] error en '{docId}': {ex.GetType().Name}: {ex.Message}"); + Console.WriteLine($"[load-test] error on '{docId}': {ex.GetType().Name}: {ex.Message}"); } } } @@ -119,10 +119,10 @@ samplerStop.Cancel(); await sampler; -Console.WriteLine($"[load-test] carga completa en {sw.Elapsed.TotalSeconds:F1}s: " + +Console.WriteLine($"[load-test] load complete in {sw.Elapsed.TotalSeconds:F1}s: " + $"ops={confirmedOps} evictions={evictions} peak-active={peakActive} errors={errors}"); -// -- Verificación de consistencia: reabrir cada documento y comparar longitud con las inserciones -- +// -- Consistency check: reopen each document and compare its length against the inserts -- int inconsistencias = 0; for (int idx = 0; idx < docs; idx++) { @@ -133,40 +133,40 @@ { if (inconsistencias < 10) { - Console.WriteLine($"[load-test] INCONSISTENTE doc-{idx}: len={text.Length} esperado={expected}"); + Console.WriteLine($"[load-test] INCONSISTENT doc-{idx}: len={text.Length} expected={expected}"); } inconsistencias++; } } -// -- Memoria (informativo): managed heap tras GC forzado + working set. La memoria se acota por dos vías: -// el TAMAÑO por documento (cap de inserciones) y el NÚMERO de documentos activos (pool + LRU). El pico -// de activos es una cota SUAVE (se reafirma en cada barrido, no en OpenAsync), así que puede exceder -// MaxActiveDocuments transitoriamente; el criterio duro de PASS es el working set absoluto, abajo. -- +// -- Memory (informational): managed heap after a forced GC + working set. Memory is bounded two ways: +// the SIZE per document (insert cap) and the NUMBER of active documents (pool + LRU). The peak +// of active docs is a SOFT bound (reasserted on each sweep, not in OpenAsync), so it may exceed +// MaxActiveDocuments transiently; the hard PASS criterion is the absolute working set, below. -- long managed = GC.GetTotalMemory(forceFullCollection: true); long workingSet = Process.GetCurrentProcess().WorkingSet64; -Console.WriteLine($"[load-test] memoria: managed-heap={managed / (1024 * 1024)}MB " + +Console.WriteLine($"[load-test] memory: managed-heap={managed / (1024 * 1024)}MB " + $"working-set={workingSet / (1024 * 1024)}MB"); -// Memoria acotada (SC-006): con tamaño por doc y número de docs activos ambos acotados, el working -// set se estabiliza. Límite absoluto generoso; un proceso que crece sin cota lo supera holgadamente. +// Bounded memory (SC-006): with per-doc size and number of active docs both bounded, the working +// set stabilizes. Generous absolute limit; a process growing without bound exceeds it comfortably. const long workingSetLimitMb = 1536; long workingSetMb = workingSet / (1024 * 1024); bool memoryBounded = workingSetMb < workingSetLimitMb; bool consistent = inconsistencias == 0; bool noErrors = Interlocked.Read(ref errors) == 0; -Console.WriteLine($"[load-test] consistencia={(consistent ? "OK" : $"FAIL ({inconsistencias})")} " + - $"memoria-acotada={(memoryBounded ? "OK" : $"FAIL (working-set {workingSetMb}MB >= {workingSetLimitMb}MB)")} " + - $"sin-errores={(noErrors ? "OK" : "FAIL")}"); +Console.WriteLine($"[load-test] consistency={(consistent ? "OK" : $"FAIL ({inconsistencias})")} " + + $"memory-bounded={(memoryBounded ? "OK" : $"FAIL (working-set {workingSetMb}MB >= {workingSetLimitMb}MB)")} " + + $"no-errors={(noErrors ? "OK" : "FAIL")}"); if (consistent && memoryBounded && noErrors) { - Console.WriteLine("[load-test] RESULTADO: PASS"); + Console.WriteLine("[load-test] RESULT: PASS"); return 0; } -Console.WriteLine("[load-test] RESULTADO: FAIL"); +Console.WriteLine("[load-test] RESULT: FAIL"); return 1; static int ArgInt(string[] args, string name, int fallback) diff --git a/tests/Weft.LoadTest/RelayLoad.cs b/tests/Weft.LoadTest/RelayLoad.cs index 4037d0d..943dc0d 100644 --- a/tests/Weft.LoadTest/RelayLoad.cs +++ b/tests/Weft.LoadTest/RelayLoad.cs @@ -15,17 +15,17 @@ namespace Weft.LoadTest; /// -/// Carga del relay real (FU-010/CHARTER-14): mide la latencia editor→observador de un update a través -/// del relay completo (TestServer + WebSocket + con fsync), en -/// AMBOS modos de durabilidad. Es la evidencia que respalda el default PersistThenBroadcast: sin -/// ella, la elección del default sería una afirmación no medida. La de US2 conduce -/// el broker directamente y es ciega al path de persistencia del relay. +/// Real relay load (FU-010/CHARTER-14): measures the editor→observer latency of an update through +/// the full relay (TestServer + WebSocket + with fsync), in +/// BOTH durability modes. It is the evidence backing the PersistThenBroadcast default: without +/// it, the choice of default would be an unmeasured claim. The US2 drives +/// the broker directly and is blind to the relay's persistence path. /// internal static class RelayLoad { public static async Task RunAsync(int edits) { - Console.WriteLine($"[relay-load] editor→observador vía relay real, {edits} ediciones/modo, FileSystemDocumentStore + fsync"); + Console.WriteLine($"[relay-load] editor→observer via real relay, {edits} edits/mode, FileSystemDocumentStore + fsync"); LatencyStats persist = await MeasureAsync(DurabilityMode.PersistThenBroadcast, edits); LatencyStats broadcast = await MeasureAsync(DurabilityMode.BroadcastThenPersist, edits); @@ -36,8 +36,8 @@ public static async Task RunAsync(int edits) $"[relay-load] coste del orden seguro (p50): +{persist.P50 - broadcast.P50:F1}ms, " + $"(p99): +{persist.P99 - broadcast.P99:F1}ms"); - // PASS = ambos modos convergieron en todas las ediciones (sin pérdidas ni timeouts). El número de - // latencia es informativo (depende del disco del runner); lo que se gatea es la corrección. + // PASS = both modes converged on all edits (no losses or timeouts). The latency + // number is informational (depends on the runner's disk); what is gated is correctness. bool ok = persist.Count == edits && broadcast.Count == edits; Console.WriteLine($"[relay-load] convergencia: persist={persist.Count}/{edits} broadcast={broadcast.Count}/{edits} " + $"→ {(ok ? "PASS" : "FAIL")}"); @@ -154,7 +154,7 @@ private static double Percentile(List sorted, double p) public override string ToString() => $"p50={P50:F1}ms p99={P99:F1}ms max={Max:F1}ms (n={Count})"; } - /// Cliente WebSocket mínimo con un doc yrs real, hablando y-sync contra el relay. + /// Minimal WebSocket client with a real yrs doc, speaking y-sync against the relay. private sealed class RelayClient : IAsyncDisposable { private const string Field = "body"; diff --git a/tests/Weft.Server.Tests/DocumentStoreContractSuite.cs b/tests/Weft.Server.Tests/DocumentStoreContractSuite.cs index a57b0b7..0eaa672 100644 --- a/tests/Weft.Server.Tests/DocumentStoreContractSuite.cs +++ b/tests/Weft.Server.Tests/DocumentStoreContractSuite.cs @@ -9,24 +9,24 @@ namespace Weft.Server.Tests; /// -/// Contract suite compartida de (T050). Se ejecuta idéntica contra cada -/// adaptador (, y, desde CHARTER-06, -/// y ). Es la base de la intercambiabilidad -/// de stores que exige el escenario de aceptación de US3 y contracts/server-api.md. +/// Shared contract suite (T050). It runs identically against each +/// adapter (, and, since CHARTER-06, +/// and ). It is the basis of the store +/// interchangeability required by the US3 acceptance scenario and contracts/server-api.md. /// /// -/// Los tests usan [SkippableFact] (no [Fact]) para que un adaptador cuyo backend externo no esté -/// disponible pueda omitirse en vez de fallar: la subclase Redis llama Skip.IfNot(...) en -/// (la primera línea de cada test), así que sin Redis/Valkey el test se salta. -/// Para los adaptadores in-proceso (InMemory, FileSystem, EFCore/SQLite) el comportamiento es idéntico a -/// [Fact] — nunca se salta. +/// The tests use [SkippableFact] (not [Fact]) so that an adapter whose external backend is not +/// available can be skipped instead of failing: the Redis subclass calls Skip.IfNot(...) in +/// (the first line of each test), so without Redis/Valkey the test is skipped. +/// For the in-process adapters (InMemory, FileSystem, EFCore/SQLite) the behavior is identical to +/// [Fact] — never skipped. /// public abstract class DocumentStoreContractSuite { - /// Crea una instancia fresca y aislada del store bajo prueba. + /// Creates a fresh, isolated instance of the store under test. protected abstract IDocumentStore CreateStore(); - private const string DocId = "doc-α/β:1"; // opaco: incluye no-ASCII y '/' para tensar el mapeo a filename. + private const string DocId = "doc-α/β:1"; // opaque: includes non-ASCII and '/' to stress the filename mapping. private static ReadOnlyMemory Mem(params byte[] bytes) => bytes; @@ -84,11 +84,11 @@ public async Task Snapshot_replaces_accumulated_updates_then_new_updates_append( byte[] snapshot = [0x53]; await store.SaveSnapshotAsync(DocId, snapshot); - // Compaction: tras el snapshot solo queda el snapshot (los updates acumulados se descartaron). + // Compaction: after the snapshot only the snapshot remains (the accumulated updates were discarded). IReadOnlyList afterSnapshot = Records(await store.LoadAsync(DocId)); Assert.Equal(snapshot, Assert.Single(afterSnapshot)); - // Updates posteriores se acumulan sobre el snapshot, en orden. + // Subsequent updates accumulate on top of the snapshot, in order. byte[] c = [0xC0]; await store.AppendUpdateAsync(DocId, c); IReadOnlyList afterAppend = Records(await store.LoadAsync(DocId)); @@ -129,8 +129,8 @@ public async Task Concurrent_appends_are_all_persisted() IDocumentStore store = CreateStore(); const int n = 250; - // Cada update es su índice en 4 bytes → distinguible; el orden entre appends concurrentes no se - // garantiza, así que verificamos el conjunto, no la secuencia. + // Each update is its index in 4 bytes → distinguishable; the order among concurrent appends is not + // guaranteed, so we verify the set, not the sequence. await Task.WhenAll(Enumerable.Range(0, n).Select(i => store.AppendUpdateAsync(DocId, BitConverter.GetBytes(i)).AsTask())); @@ -178,13 +178,13 @@ public async Task Concurrent_loads_and_writes_do_not_fault() } } -/// La contract suite contra . +/// The contract suite against . public sealed class InMemoryDocumentStoreContractTests : DocumentStoreContractSuite { protected override IDocumentStore CreateStore() => new InMemoryDocumentStore(); } -/// La contract suite contra (directorio temporal aislado). +/// The contract suite against (isolated temp directory). public sealed class FileSystemDocumentStoreContractTests : DocumentStoreContractSuite, IDisposable { private readonly string _root = @@ -202,8 +202,8 @@ public void Dispose() } /// -/// La contract suite contra (CHARTER-06/T053) sobre SQLite en un archivo -/// temporal aislado — provider real, relacional, cross-plataforma, sin infraestructura externa. +/// The contract suite against (CHARTER-06/T053) over SQLite in an +/// isolated temp file — a real, relational, cross-platform provider, with no external infrastructure. /// public sealed class EFCoreDocumentStoreContractTests : DocumentStoreContractSuite, IDisposable { @@ -226,7 +226,7 @@ protected override IDocumentStore CreateStore() return new EFCoreDocumentStore(factory); } - /// Factory mínima de contextos SQLite para los tests (una unidad de trabajo fresca por operación). + /// Minimal SQLite context factory for the tests (a fresh unit of work per operation). private sealed class SqliteContextFactory(DbContextOptions options) : IDbContextFactory { @@ -235,7 +235,7 @@ private sealed class SqliteContextFactory(DbContextOptions -/// Conexión Redis/Valkey compartida para la suite (una por clase). Si el servidor no está disponible en -/// WEFT_TEST_REDIS (o localhost:6379), es false y los tests se -/// omiten. Usa la base de datos (aislada de la 0 por defecto) y la limpia al cerrar. +/// Shared Redis/Valkey connection for the suite (one per class). If the server is not available at +/// WEFT_TEST_REDIS (or localhost:6379), is false and the tests are +/// skipped. Uses the database (isolated from the default db 0) and flushes it on close. /// public sealed class RedisConnectionFixture : IDisposable { - /// Índice de base de datos dedicado a los tests (no toca la db 0 por defecto del servidor). + /// Database index dedicated to the tests (does not touch the server's default db 0). public const int TestDb = 15; - /// La conexión, o null si el servidor no respondió. + /// The connection, or null if the server did not respond. public IConnectionMultiplexer? Connection { get; } - /// true si hay una conexión viva contra Redis/Valkey. + /// true if there is a live connection to Redis/Valkey. public bool Available => Connection is { IsConnected: true }; - /// Intenta conectar una sola vez (sin abortar si falla) para decidir si la suite corre o se omite. + /// Tries to connect once (without aborting on failure) to decide whether the suite runs or is skipped. public RedisConnectionFixture() { string config = Environment.GetEnvironmentVariable("WEFT_TEST_REDIS") ?? "localhost:6379"; var options = ConfigurationOptions.Parse(config); - options.AbortOnConnectFail = false; // no lanza si el servidor no está: devuelve un mux desconectado. + options.AbortOnConnectFail = false; // does not throw if the server is absent: returns a disconnected mux. options.ConnectTimeout = 1000; options.ConnectRetry = 1; - options.AllowAdmin = true; // habilita FLUSHDB en el cleanup. + options.AllowAdmin = true; // enables FLUSHDB in the cleanup. ConnectionMultiplexer mux = ConnectionMultiplexer.Connect(options); if (mux.IsConnected) @@ -304,7 +304,7 @@ public void Dispose() } catch (RedisException) { - // best-effort: si el servidor deshabilita comandos admin, dejamos las claves (prefijo único por test). + // best-effort: if the server disables admin commands, we leave the keys (unique prefix per test). } Connection.Dispose(); @@ -312,22 +312,22 @@ public void Dispose() } /// -/// La contract suite contra (CHARTER-06/T054). Corre contra el Redis/Valkey -/// real de ; se omite (no falla) cuando no hay servidor. Cada -/// usa un prefijo de claves único → aislamiento entre tests sobre la misma conexión. +/// The contract suite against (CHARTER-06/T054). Runs against the real +/// Redis/Valkey from ; it is skipped (not failed) when there is no server. Each +/// uses a unique key prefix → isolation between tests over the same connection. /// public sealed class RedisDocumentStoreContractTests : DocumentStoreContractSuite, IClassFixture { private readonly RedisConnectionFixture _fixture; - /// Recibe la conexión compartida vía el class fixture de xUnit. + /// Receives the shared connection via the xUnit class fixture. public RedisDocumentStoreContractTests(RedisConnectionFixture fixture) => _fixture = fixture; protected override IDocumentStore CreateStore() { Skip.IfNot( _fixture.Available, - "Redis/Valkey no disponible en WEFT_TEST_REDIS/localhost:6379 — test omitido (corre local con el servidor levantado)."); + "Redis/Valkey not available at WEFT_TEST_REDIS/localhost:6379 — test skipped (run locally with the server up)."); string prefix = "weft-test:" + Guid.NewGuid().ToString("N") + ":"; return new RedisDocumentStore(_fixture.Connection!, prefix, RedisConnectionFixture.TestDb); diff --git a/tests/Weft.Server.Tests/Lib0EncodingTests.cs b/tests/Weft.Server.Tests/Lib0EncodingTests.cs index e1b38a2..6834132 100644 --- a/tests/Weft.Server.Tests/Lib0EncodingTests.cs +++ b/tests/Weft.Server.Tests/Lib0EncodingTests.cs @@ -3,13 +3,13 @@ namespace Weft.Server.Tests; /// -/// Vectores del encoding lib0 y del framing y-sync (T043), más el test del cap de tamaño de mensaje -/// (FU-002 parte a). Los vectores de bytes son el formato de wire de y-protocols/y-websocket: -/// se afirman byte a byte para blindar la compatibilidad con clientes Yjs. +/// Vectors for the lib0 encoding and the y-sync framing (T043), plus the message size cap test +/// (FU-002 part a). The byte vectors are the wire format of y-protocols/y-websocket: +/// they are asserted byte by byte to harden compatibility with Yjs clients. /// public sealed class Lib0EncodingTests { - // --- Varint: vectores conocidos (little-endian, bit 0x80 de continuación) --- + // --- Varint: known vectors (little-endian, 0x80 continuation bit) --- [Theory] [InlineData(0u, new byte[] { 0x00 })] @@ -59,7 +59,7 @@ public void VarUint8Array_round_trips() Assert.True(r.AtEnd); } - // --- Framing y-sync: vectores conocidos --- + // --- y-sync framing: known vectors --- [Fact] public void EncodeSyncStep1_produces_known_frame() @@ -123,14 +123,14 @@ public void Decode_accepts_empty_payload() Assert.True(decoded.Payload.IsEmpty); } - // --- Cap de tamaño de mensaje (FU-002 parte a) --- + // --- Message size cap (FU-002 part a) --- [Fact] public void Decode_rejects_frame_exceeding_size_cap_before_parsing() { byte[] frame = SyncProtocol.EncodeUpdate(new byte[100]); - // El mismo frame pasa con el cap por defecto y se rechaza con un cap por debajo de su tamaño. + // The same frame passes with the default cap and is rejected with a cap below its size. _ = SyncProtocol.Decode(frame); var ex = Assert.Throws( () => SyncProtocol.Decode(frame, maxMessageBytes: 10)); @@ -140,13 +140,13 @@ public void Decode_rejects_frame_exceeding_size_cap_before_parsing() [Fact] public void Decode_rejects_lying_length_prefix_without_allocating() { - // SYNC · Update · longitud declarada = uint.MaxValue (≈4 GiB) pero SIN payload: la guarda estructural - // rechaza antes de asignar nada (el DoS de amplificación de memoria que describe FU-002). + // SYNC · Update · declared length = uint.MaxValue (≈4 GiB) but WITHOUT payload: the structural guard + // rejects before allocating anything (the memory-amplification DoS that FU-002 describes). byte[] frame = [0x00, 0x02, 0xFF, 0xFF, 0xFF, 0xFF, 0x0F]; Assert.Throws(() => SyncProtocol.Decode(frame)); } - // --- Frames malformados → MalformedMessageException (relay: cierre 1002) --- + // --- Malformed frames → MalformedMessageException (relay: close 1002) --- [Fact] public void Decode_rejects_unknown_message_type() @@ -158,7 +158,7 @@ public void Decode_rejects_unknown_message_type() [Fact] public void Decode_rejects_unknown_sync_subtype() { - // SYNC(0) · sub-tipo 9 (desconocido) + // SYNC(0) · sub-type 9 (unknown) byte[] frame = [0x00, 0x09, 0x00]; Assert.Throws(() => SyncProtocol.Decode(frame)); } @@ -166,21 +166,21 @@ public void Decode_rejects_unknown_sync_subtype() [Fact] public void Decode_rejects_trailing_bytes() { - byte[] frame = [0x00, 0x02, 0x01, 0xAA, 0xFF]; // un byte de más tras el payload + byte[] frame = [0x00, 0x02, 0x01, 0xAA, 0xFF]; // one byte too many after the payload Assert.Throws(() => SyncProtocol.Decode(frame)); } [Fact] public void Decode_rejects_truncated_varint() { - byte[] frame = [0x80]; // continuación sin byte final + byte[] frame = [0x80]; // continuation without a final byte Assert.Throws(() => SyncProtocol.Decode(frame)); } [Fact] public void ReadVarUint_rejects_overflow_beyond_32_bits() { - // 5.º byte aporta bits por encima de 32 → sobredimensionado. + // 5th byte contributes bits above 32 → oversized. byte[] bytes = [0x80, 0x80, 0x80, 0x80, 0x10]; Assert.Throws(() => { diff --git a/tests/Weft.Server.Tests/RelayTests.cs b/tests/Weft.Server.Tests/RelayTests.cs index 0d20a30..dd8938d 100644 --- a/tests/Weft.Server.Tests/RelayTests.cs +++ b/tests/Weft.Server.Tests/RelayTests.cs @@ -20,9 +20,9 @@ namespace Weft.Server.Tests; /// -/// Tests de integración del relay end-to-end (T051): un hospeda el relay y clientes -/// Yjs simulados (motor yrs real en ambos lados, hablando el wire y-sync vía ) -/// se conectan por WebSocket. Cubre los criterios del Independent Test de US3. +/// End-to-end relay integration tests (T051): a hosts the relay and simulated +/// Yjs clients (real yrs engine on both sides, speaking the y-sync wire via ) +/// connect over WebSocket. Covers the US3 Independent Test criteria. /// public sealed class RelayTests { @@ -30,8 +30,8 @@ public sealed class RelayTests // ---------- Harness ---------- - // Concede el acceso `fallback`, salvo que la conexión pida uno explícito por query (?access=ro|rw|deny) — - // esto permite mezclar un escritor ReadWrite y un lector ReadOnly sobre el mismo documento en un test. + // Grants the `fallback` access, unless the connection requests an explicit one via query (?access=ro|rw|deny) — + // this allows mixing a ReadWrite writer and a ReadOnly reader over the same document in one test. private sealed class FixedAuthorizer(WeftAccess fallback) : IWeftAuthorizer { public ValueTask AuthorizeAsync(HttpContext context, string docId, CancellationToken ct) @@ -81,7 +81,7 @@ private static async Task BuildHostAsync( return await builder.StartAsync(); } - /// Host del relay con async-dispose (el IHost concreto es IAsyncDisposable; el estático no). + /// Relay host with async-dispose (the concrete IHost is IAsyncDisposable; the static one is not). private sealed class RelayHost(IHost host) : IAsyncDisposable { public TestServer Server { get; } = host.GetTestServer(); @@ -107,9 +107,9 @@ private static async Task StartRelayAsync( DurabilityMode durability = DurabilityMode.PersistThenBroadcast) => new RelayHost(await BuildHostAsync(access, store, blobs, durability)); - // El timeout es una cota SUPERIOR generosa para absorber la contención del runner de CI (p. ej. macOS - // lento), NO el objetivo de latencia de convergencia de US3 (SC-005 <1 s); la convergencia real es - // sub-segundo (verificada por el check headless y las corridas locales). + // The timeout is a generous UPPER bound to absorb CI runner contention (e.g. slow macOS), + // NOT the US3 convergence latency target (SC-005 <1 s); actual convergence is + // sub-second (verified by the headless check and local runs). private static async Task WaitUntilAsync(Func condition, TimeSpan timeout) { var sw = Stopwatch.StartNew(); @@ -136,7 +136,7 @@ private static byte[] AwarenessUpdate(uint clientId, uint clock, string stateJso return SyncProtocol.EncodeAwareness(inner.WrittenSpan); } - /// Cliente Yjs simulado: WebSocket + un doc yrs real, hablando y-sync. + /// Simulated Yjs client: WebSocket + a real yrs doc, speaking y-sync. private sealed class YClient : IAsyncDisposable { private readonly WebSocket _ws; @@ -163,7 +163,7 @@ public static async Task ConnectAsync( string query = access is null ? "" : $"?access={access}"; WebSocket ws = await wsc.ConnectAsync(new Uri(server.BaseAddress, $"collab/{docId}{query}"), ct); var client = new YClient(ws); - // Sync inicial: anunciamos nuestro state vector (tras sembrar el estado previo, si lo hay). + // Initial sync: we announce our state vector (after seeding the prior state, if any). byte[] sv; lock (client._docLock) { @@ -189,7 +189,7 @@ public byte[] ExportState() lock (_docLock) { return _doc.ExportState(); } } - /// Edita localmente y difunde el delta al servidor. + /// Edits locally and broadcasts the delta to the server. public async Task EditAsync(int index, string text, CancellationToken ct = default) { byte[] delta; @@ -203,7 +203,7 @@ public async Task EditAsync(int index, string text, CancellationToken ct = defau await SendAsync(SyncProtocol.EncodeUpdate(delta), ct); } - /// Envía un estado/update crudo como mensaje Update (sin aplicarlo localmente). + /// Sends a raw state/update as an Update message (without applying it locally). public Task SendUpdateAsync(byte[] update, CancellationToken ct = default) => SendAsync(SyncProtocol.EncodeUpdate(update), ct); @@ -350,22 +350,22 @@ public async Task Reconnecting_client_receives_only_a_small_delta() await using RelayHost relay = await StartRelayAsync(WeftAccess.ReadWrite, new InMemoryDocumentStore()); TestServer server = relay.Server; - // Un cliente construye un documento grande y capturamos su estado. + // A client builds a large document and we capture its state. await using YClient writer = await YClient.ConnectAsync(server, "doc"); await writer.EditAsync(0, new string('x', 20_000)); byte[] fullState = writer.ExportState(); - // Cliente FRESCO (SV vacío): el servidor le envía el estado completo (≫20 KB). + // FRESH client (empty SV): the server sends it the full state (≫20 KB). await using YClient fresh = await YClient.ConnectAsync(server, "doc"); Assert.True(await WaitUntilAsync(() => fresh.Text().Length == 20_000, TimeSpan.FromSeconds(5)), - $"fresh no sincronizó: len={fresh.Text().Length}"); + $"fresh did not sync: len={fresh.Text().Length}"); Assert.True(fresh.BytesReceived > 20_000, $"fresh={fresh.BytesReceived}"); - // Cliente AL DÍA (sembrado con el estado → SV completo): el servidor no tiene nada nuevo que enviarle. + // UP-TO-DATE client (seeded with the state → full SV): the server has nothing new to send it. await using YClient upToDate = await YClient.ConnectAsync(server, "doc", seedState: fullState); - await Task.Delay(150); // deja llegar el sync inicial + await Task.Delay(150); // let the initial sync arrive Assert.True(upToDate.BytesReceived * 4 < fresh.BytesReceived, - $"upToDate={upToDate.BytesReceived} fresh={fresh.BytesReceived} (delta en reconexión ≪ estado completo)"); + $"upToDate={upToDate.BytesReceived} fresh={fresh.BytesReceived} (reconnection delta ≪ full state)"); } [Fact] @@ -373,7 +373,7 @@ public async Task Denied_connection_exchanges_no_content() { await using RelayHost relay = await StartRelayAsync(WeftAccess.Deny, new InMemoryDocumentStore()); TestServer server = relay.Server; - // 403 antes del upgrade → la conexión WebSocket se rechaza (0 bytes de contenido). + // 403 before the upgrade → the WebSocket connection is rejected (0 bytes of content). await Assert.ThrowsAnyAsync(async () => { await using YClient _ = await YClient.ConnectAsync(server, "doc"); @@ -383,21 +383,21 @@ await Assert.ThrowsAnyAsync(async () => [Fact] public async Task ReadOnly_client_receives_updates_survives_handshake_but_closes_on_write() { - // Regresión de F1 (auditoría CHARTER-05): el ReadOnly no debe cerrarse por el SyncStep2 del handshake, - // solo por un Update en vivo. Sin el fix, el lector se cierra durante el handshake y este test falla. + // F1 regression (CHARTER-05 audit): the ReadOnly client must not close due to the handshake SyncStep2, + // only due to a live Update. Without the fix, the reader closes during the handshake and this test fails. await using RelayHost relay = await StartRelayAsync(WeftAccess.ReadWrite, new InMemoryDocumentStore()); TestServer server = relay.Server; await using YClient writer = await YClient.ConnectAsync(server, "doc"); // ReadWrite (default) await using YClient reader = await YClient.ConnectAsync(server, "doc", access: "ro"); // ReadOnly - // El lector sobrevive el handshake (su SyncStep2 se ignora, no lo cierra) y recibe el update del escritor. + // The reader survives the handshake (its SyncStep2 is ignored, does not close it) and receives the writer's update. await writer.EditAsync(0, "shared"); Assert.True(await WaitUntilAsync(() => reader.Text() == "shared", TimeSpan.FromSeconds(5)), - $"el lector ReadOnly no recibió el update: text='{reader.Text()}' close={reader.CloseStatus}"); - Assert.Null(reader.CloseStatus); // sigue conectado tras recibir updates + $"the ReadOnly reader did not receive the update: text='{reader.Text()}' close={reader.CloseStatus}"); + Assert.Null(reader.CloseStatus); // still connected after receiving updates - // Pero si el lector intenta escribir (Update en vivo), se cierra con 1008. + // But if the reader tries to write (live Update), it closes with 1008. await reader.EditAsync(0, "nope"); Assert.True(await WaitUntilAsync( () => reader.CloseStatus == WebSocketCloseStatus.PolicyViolation, TimeSpan.FromSeconds(5)), @@ -412,20 +412,20 @@ public async Task Awareness_is_relayed_and_withdrawn_on_disconnect() await using YClient observer = await YClient.ConnectAsync(server, "doc"); YClient presence = await YClient.ConnectAsync(server, "doc"); - // Liveness: una edición converge en observer → ambas conexiones están unidas al hub antes de difundir - // awareness (evita la carrera "observer aún no está en el hub" al hacer el broadcast). + // Liveness: one edit converges on observer → both connections are joined to the hub before broadcasting + // awareness (avoids the "observer not yet in the hub" race when broadcasting). await presence.EditAsync(0, "x"); Assert.True(await WaitUntilAsync(() => observer.Text() == "x", TimeSpan.FromSeconds(5))); const uint clientId = 4242; await presence.SendAwarenessAsync(AwarenessUpdate(clientId, 1, "{\"user\":\"A\"}")); - // El observador ve el estado de awareness del par. + // The observer sees the peer's awareness state. Assert.True(await WaitUntilAsync( () => observer.AwarenessReceived.Any(p => AwarenessHasClient(p, clientId, requireNull: false)), TimeSpan.FromSeconds(5))); - // Al desconectar el par, el observador recibe la RETIRADA (estado "null" para su clientID). + // When the peer disconnects, the observer receives the WITHDRAWAL ("null" state for its clientID). await presence.DisposeAsync(); Assert.True(await WaitUntilAsync( () => observer.AwarenessReceived.Any(p => AwarenessHasClient(p, clientId, requireNull: true)), @@ -435,35 +435,35 @@ public async Task Awareness_is_relayed_and_withdrawn_on_disconnect() [Fact] public async Task Awareness_with_zero_clock_for_new_client_does_not_crash() { - // Regresión de F2 (auditoría CHARTER-05): un awareness con clock 0 para un clientID nuevo no debe - // lanzar KeyNotFoundException en TrackClients (que faultearía la conexión). Se prueba por SUPERVIVENCIA - // (una edición posterior al awareness aún se relaya), evitando la carrera de "observer aún no está en - // el hub" con una barrera de liveness previa. + // F2 regression (CHARTER-05 audit): an awareness with clock 0 for a new clientID must not + // throw KeyNotFoundException in TrackClients (which would fault the connection). It is tested by SURVIVAL + // (an edit after the awareness is still relayed), avoiding the "observer not yet in the + // hub" race with a prior liveness barrier. await using RelayHost relay = await StartRelayAsync(WeftAccess.ReadWrite, new InMemoryDocumentStore()); TestServer server = relay.Server; await using YClient observer = await YClient.ConnectAsync(server, "doc"); await using YClient presence = await YClient.ConnectAsync(server, "doc"); - // Liveness: una edición de presence converge en observer → ambas conexiones vivas y unidas al hub. + // Liveness: an edit from presence converges on observer → both connections alive and joined to the hub. await presence.EditAsync(0, "live"); Assert.True(await WaitUntilAsync(() => observer.Text() == "live", TimeSpan.FromSeconds(5)), - $"no se estableció liveness: observer='{observer.Text()}'"); + $"liveness was not established: observer='{observer.Text()}'"); - // Awareness con clock 0 para un clientID nuevo (común en el primer awareness de un cliente Yjs). + // Awareness with clock 0 for a new clientID (common in a Yjs client's first awareness). await presence.SendAwarenessAsync(AwarenessUpdate(777, 0, "{\"user\":\"Z\"}")); - // La conexión de presence SIGUE viva tras el awareness clock-0: una edición posterior aún se relaya - // (sin el fix, TrackClients habría lanzado y faulteado la conexión → esta edición nunca llegaría). + // The presence connection is STILL alive after the clock-0 awareness: a later edit is still relayed + // (without the fix, TrackClients would have thrown and faulted the connection → this edit would never arrive). await presence.EditAsync(4, " more"); Assert.True(await WaitUntilAsync(() => observer.Text() == "live more", TimeSpan.FromSeconds(5)), - $"presence dejó de relayar tras el awareness clock-0 (¿crasheó?): observer='{observer.Text()}' close={presence.CloseStatus}"); + $"presence stopped relaying after the clock-0 awareness (did it crash?): observer='{observer.Text()}' close={presence.CloseStatus}"); Assert.Null(presence.CloseStatus); } [Fact] public async Task State_survives_a_server_restart() { - var store = new InMemoryDocumentStore(); // el store durable sobrevive al "reinicio" del proceso + var store = new InMemoryDocumentStore(); // the durable store survives the process "restart" await using (RelayHost r1 = await StartRelayAsync(WeftAccess.ReadWrite, store)) { @@ -472,7 +472,7 @@ public async Task State_survives_a_server_restart() await c1.EditAsync(0, "durable"); await using YClient c2 = await YClient.ConnectAsync(s1, "doc"); Assert.True(await WaitUntilAsync(() => c2.Text() == "durable", TimeSpan.FromSeconds(5))); - } // r1.DisposeAsync consolida el snapshot en el store (WeftServer.DisposeAsync) + } // r1.DisposeAsync consolidates the snapshot into the store (WeftServer.DisposeAsync) await using RelayHost r2 = await StartRelayAsync(WeftAccess.ReadWrite, store); TestServer s2 = r2.Server; @@ -486,20 +486,20 @@ public async Task Server_publish_matches_local_publish_version_id() { var blobs = new InMemoryBlobStore(); - // Publicación local del mismo contenido. + // Local publish of the same content. using ICrdtDoc local = YrsEngine.Instance.CreateDoc(); local.InsertText(Field, 0, "content-addressed parity"); byte[] localState = local.ExportState(); var versionStore = new VersionStore(YrsEngine.Instance, blobs); VersionId localId = await versionStore.PublishAsync(local); - // El servidor recibe exactamente el mismo estado y publica. + // The server receives exactly the same state and publishes. await using RelayHost relay = await StartRelayAsync(WeftAccess.ReadWrite, new InMemoryDocumentStore(), blobs); TestServer server = relay.Server; await using YClient c = await YClient.ConnectAsync(server, "doc"); await c.SendUpdateAsync(localState); - // Esperar a que el servidor aplique el estado (un 2.º cliente converge → el actor ya lo procesó). + // Wait for the server to apply the state (a 2nd client converges → the actor already processed it). await using YClient probe = await YClient.ConnectAsync(server, "doc"); Assert.True(await WaitUntilAsync( () => probe.Text() == "content-addressed parity", TimeSpan.FromSeconds(5))); @@ -510,7 +510,7 @@ public async Task Server_publish_matches_local_publish_version_id() Assert.Equal(localId, serverId); } - // awarenessPayload = el payload interno de un mensaje Awareness (lo que el cliente guarda en Dispatch). + // awarenessPayload = the inner payload of an Awareness message (what the client stores in Dispatch). private static bool AwarenessHasClient(byte[] awarenessPayload, uint clientId, bool requireNull) { try @@ -534,13 +534,13 @@ private static bool AwarenessHasClient(byte[] awarenessPayload, uint clientId, b return false; } - // ---------- Durabilidad del relay (FU-010, CHARTER-14) ---------- + // ---------- Relay durability (FU-010, CHARTER-14) ---------- /// - /// Store de control para los tests de durabilidad. Cada conexión dispara un append en el handshake - /// (SyncStep2), así que fallar/bloquear por número de llamada es frágil; en su lugar se «arma» el - /// próximo append cuando el test lo decide (tras conectar los clientes), para que solo la edición de - /// interés falle o se bloquee. + /// Control store for the durability tests. Each connection triggers an append in the handshake + /// (SyncStep2), so failing/blocking by call number is fragile; instead the next append is "armed" + /// when the test decides (after connecting the clients), so that only the edit of + /// interest fails or blocks. /// private sealed class ControllableAppendStore(IDocumentStore inner) : IDocumentStore { @@ -550,10 +550,10 @@ private sealed class ControllableAppendStore(IDocumentStore inner) : IDocumentSt public int AppendCount => Volatile.Read(ref _appends); - /// El PRÓXIMO append lanzará una vez. + /// The NEXT append will throw once. public void ArmFailure() => Interlocked.Exchange(ref _failNext, 1); - /// Los appends a partir de ahora se bloquean hasta . + /// Appends from now on block until . public void ArmGate() => _gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); public void Release() => _gate?.TrySetResult(); @@ -567,7 +567,7 @@ public async ValueTask AppendUpdateAsync(string docId, ReadOnlyMemory upda if (Interlocked.Exchange(ref _failNext, 0) == 1) { - throw new IOException("fallo de append inyectado (test)"); + throw new IOException("injected append failure (test)"); } TaskCompletionSource? gate = _gate; @@ -583,7 +583,7 @@ public ValueTask SaveSnapshotAsync(string docId, ReadOnlyMemory state, Can => inner.SaveSnapshotAsync(docId, state, ct); } - // Espera a que los appends del handshake de las conexiones se asienten (dejen de crecer) antes de armar. + // Waits for the connections' handshake appends to settle (stop growing) before arming. private static async Task SettleAsync(ControllableAppendStore store) { int last = -1; @@ -604,11 +604,11 @@ public async Task PersistThenBroadcast_un_append_fallido_no_lo_observan_los_pare await using YClient b = await YClient.ConnectAsync(server, "doc"); await SettleAsync(store); - store.ArmFailure(); // el próximo append (la edición de a) falla + store.ArmFailure(); // the next append (a's edit) fails await a.EditAsync(0, "no debe verse"); - // El par b NUNCA recibe el delta (persist-before-broadcast: el broadcast no ocurrió), y la conexión - // emisora se cierra con 1011 InternalError. + // Peer b NEVER receives the delta (persist-before-broadcast: the broadcast did not happen), and the + // sending connection closes with 1011 InternalError. bool aClosed = await WaitUntilAsync( () => a.CloseStatus == WebSocketCloseStatus.InternalServerError, TimeSpan.FromSeconds(5)); Assert.True(aClosed, $"a.CloseStatus={a.CloseStatus}"); @@ -622,8 +622,8 @@ public async Task PersistThenBroadcast_reconexion_tras_fallo_resincroniza_el_est await using RelayHost relay = await StartRelayAsync(WeftAccess.ReadWrite, store); TestServer server = relay.Server; - // El primer cliente edita; su append falla → la edición queda en el doc vivo del servidor pero no - // se difundió, y su conexión se cerró. + // The first client edits; its append fails → the edit stays in the server's live doc but was not + // broadcast, and its connection closed. await using (YClient a = await YClient.ConnectAsync(server, "doc")) { await SettleAsync(store); @@ -632,7 +632,7 @@ public async Task PersistThenBroadcast_reconexion_tras_fallo_resincroniza_el_est await WaitUntilAsync(() => a.CloseStatus is not null, TimeSpan.FromSeconds(5)); } - // Un cliente que reconecta recibe el estado vivo del servidor (autoritativo) vía el handshake. + // A reconnecting client receives the server's live (authoritative) state via the handshake. await using YClient c = await YClient.ConnectAsync(server, "doc"); bool resynced = await WaitUntilAsync(() => c.Text() == "vivo", TimeSpan.FromSeconds(5)); Assert.True(resynced, $"c='{c.Text()}'"); @@ -641,8 +641,8 @@ public async Task PersistThenBroadcast_reconexion_tras_fallo_resincroniza_el_est [Fact] public async Task BroadcastThenPersist_difunde_antes_de_que_el_append_confirme() { - // El modo heredado difunde SIN esperar al append: con el append bloqueado, el par recibe la edición - // antes de liberar la persistencia. + // The legacy mode broadcasts WITHOUT waiting for the append: with the append blocked, the peer receives the edit + // before releasing persistence. var store = new ControllableAppendStore(new InMemoryDocumentStore()); await using RelayHost relay = await StartRelayAsync( WeftAccess.ReadWrite, store, durability: DurabilityMode.BroadcastThenPersist); @@ -651,19 +651,19 @@ public async Task BroadcastThenPersist_difunde_antes_de_que_el_append_confirme() await using YClient b = await YClient.ConnectAsync(server, "doc"); await SettleAsync(store); - store.ArmGate(); // el próximo append (la edición) se bloquea + store.ArmGate(); // the next append (the edit) blocks await a.EditAsync(0, "rápido"); bool sawBeforePersist = await WaitUntilAsync(() => b.Text() == "rápido", TimeSpan.FromSeconds(5)); store.Release(); - Assert.True(sawBeforePersist, $"b='{b.Text()}' — el modo heredado difunde antes de persistir"); + Assert.True(sawBeforePersist, $"b='{b.Text()}' — the legacy mode broadcasts before persisting"); } [Fact] public async Task PersistThenBroadcast_no_difunde_hasta_que_el_append_confirme() { - // Testigo de orden del default: con el append bloqueado, el par NO ve la edición hasta liberarlo. + // Ordering witness for the default: with the append blocked, the peer does NOT see the edit until it is released. var store = new ControllableAppendStore(new InMemoryDocumentStore()); await using RelayHost relay = await StartRelayAsync(WeftAccess.ReadWrite, store); TestServer server = relay.Server; @@ -674,11 +674,11 @@ public async Task PersistThenBroadcast_no_difunde_hasta_que_el_append_confirme() store.ArmGate(); await a.EditAsync(0, "durable"); - // Mientras el append está bloqueado, el par no debe ver nada. + // While the append is blocked, the peer must not see anything. bool leakedEarly = await WaitUntilAsync(() => b.Text() == "durable", TimeSpan.FromMilliseconds(400)); - Assert.False(leakedEarly, "persist-before-broadcast NO debe difundir antes de que el append confirme"); + Assert.False(leakedEarly, "persist-before-broadcast must NOT broadcast before the append confirms"); - // Al liberar el append, converge. + // On releasing the append, it converges. store.Release(); bool converged = await WaitUntilAsync(() => b.Text() == "durable", TimeSpan.FromSeconds(5)); Assert.True(converged, $"b='{b.Text()}'"); diff --git a/tests/Weft.Versioning.Tests/CrossEngineMergeGuardTests.cs b/tests/Weft.Versioning.Tests/CrossEngineMergeGuardTests.cs index 055f578..23e484b 100644 --- a/tests/Weft.Versioning.Tests/CrossEngineMergeGuardTests.cs +++ b/tests/Weft.Versioning.Tests/CrossEngineMergeGuardTests.cs @@ -6,9 +6,9 @@ namespace Weft.Versioning.Tests; /// -/// Guard de compatibilidad de motor (FU-008 / auditoría G4): mezclar documentos de motores distintos -/// (yrs↔Loro) debe fallar con un claro ANTES de cruzar el FFI, no con -/// una CorruptUpdateException opaca del decoder nativo. +/// Engine compatibility guard (FU-008 / audit G4): merging documents from different engines +/// (yrs↔Loro) must fail with a clear BEFORE crossing the FFI, not with +/// an opaque CorruptUpdateException from the native decoder. /// public sealed class CrossEngineMergeGuardTests { @@ -29,14 +29,14 @@ public void Merge_across_engines_throws_ArgumentException() [Fact] public async Task MergeAsync_into_foreign_engine_target_throws_ArgumentException() { - // Almacén sobre yrs; publicamos una versión yrs válida... + // Store over yrs; we publish a valid yrs version... var blobs = new InMemoryBlobStore(); var store = new VersionStore(YrsEngine.Instance, blobs); using ICrdtDoc yrs = YrsEngine.Instance.CreateDoc(); yrs.InsertText("body", 0, "versión yrs"); VersionId version = await store.PublishAsync(yrs); - // ...pero el destino es un documento Loro: debe rechazarse por motor incompatible. + // ...but the target is a Loro document: it must be rejected as an incompatible engine. using ICrdtDoc loroTarget = LoroEngine.Instance.CreateDoc(); await Assert.ThrowsAsync( async () => await store.MergeAsync(loroTarget, version)); @@ -51,7 +51,7 @@ public void Merge_same_engine_still_works() target.InsertText("body", 0, "a "); branch.InsertText("body", 0, "b "); - store.Merge(target, branch); // no debe lanzar + store.Merge(target, branch); // must not throw Assert.Contains("b", target.GetText("body")); } diff --git a/tests/Weft.Versioning.Tests/DeterministicSeedingTests.cs b/tests/Weft.Versioning.Tests/DeterministicSeedingTests.cs index d725754..213753d 100644 --- a/tests/Weft.Versioning.Tests/DeterministicSeedingTests.cs +++ b/tests/Weft.Versioning.Tests/DeterministicSeedingTests.cs @@ -5,10 +5,10 @@ namespace Weft.Versioning.Tests; /// -/// Capacidad opcional (CHARTER-13/FU-016): sembrar la identidad de -/// réplica (client_id de yrs, peer_id de Loro) para exports reproducibles. Estos tests fijan la -/// asimetría del dominio válido entre motores — la razón por la que la capacidad es una interfaz -/// opcional y no un método de . +/// Optional capability (CHARTER-13/FU-016): seeding the replica +/// identity (yrs client_id, Loro peer_id) for reproducible exports. These tests pin down the +/// asymmetry of the valid domain between engines — the reason the capability is an optional +/// interface and not a method of . /// public sealed class DeterministicSeedingTests { @@ -30,14 +30,14 @@ public void Yrs_replica_id_domain_is_53_bits() [Fact] public void Loro_replica_id_domain_is_full_ulong_minus_reserved() { - // El u64::MAX reservado por Loro es el único valor fuera del dominio; la cota exclusiva es MaxValue. + // The u64::MAX reserved by Loro is the only value outside the domain; the exclusive bound is MaxValue. Assert.Equal(ulong.MaxValue, LoroEngine.Instance.DeterministicSeeding!.MaxReplicaIdExclusive); } [Fact] public void Loro_reserved_peer_id_throws_out_of_range() { - // u64::MAX está reservado por Loro; el shim lo rechaza en la frontera → ArgumentOutOfRangeException. + // u64::MAX is reserved by Loro; the shim rejects it at the boundary → ArgumentOutOfRangeException. Assert.Throws( () => LoroEngine.Instance.DeterministicSeeding!.CreateDoc(ulong.MaxValue)); } @@ -45,7 +45,7 @@ public void Loro_reserved_peer_id_throws_out_of_range() [Fact] public void Yrs_replica_id_beyond_53_bits_throws_out_of_range() { - // yrs solo acepta < 2^53; el shim rechaza el exceso en la frontera. + // yrs only accepts < 2^53; the shim rejects the excess at the boundary. Assert.Throws( () => YrsEngine.Instance.DeterministicSeeding!.CreateDoc(1UL << 53)); } @@ -54,7 +54,7 @@ public void Yrs_replica_id_beyond_53_bits_throws_out_of_range() [MemberData(nameof(Engines))] public void Seeded_doc_converges_with_an_unseeded_peer(ICrdtEngine engine) { - // Sembrar la identidad no debe romper la semántica CRDT: un doc sembrado y otro normal convergen. + // Seeding the identity must not break CRDT semantics: a seeded doc and a normal one converge. using ICrdtDoc seeded = engine.DeterministicSeeding!.CreateDoc(7); using ICrdtDoc other = engine.CreateDoc(); seeded.InsertText("body", 0, "hola"); @@ -66,14 +66,14 @@ public void Seeded_doc_converges_with_an_unseeded_peer(ICrdtEngine engine) [Fact] public void Loro_seeded_export_is_stable_across_two_docs_with_the_same_peer_id() { - // El valor de la siembra: dos docs con el MISMO peer_id y las MISMAS ediciones exportan igual. - // Sin siembra, el peer_id aleatorio de Loro haría divergir estos bytes. (yrs ya tiene su gate - // de paridad vs Yjs; aquí se fija el equivalente de Loro a nivel de unidad.) + // The value of seeding: two docs with the SAME peer_id and the SAME edits export identically. + // Without seeding, Loro's random peer_id would make these bytes diverge. (yrs already has its + // parity gate vs Yjs; here the Loro equivalent is pinned at the unit level.) byte[] a = SeededExport(LoroEngine.Instance, peerId: 99); byte[] b = SeededExport(LoroEngine.Instance, peerId: 99); Assert.Equal(a, b); - // Y un peer_id distinto produce bytes distintos (la siembra realmente influye en el encoding). + // And a different peer_id produces different bytes (seeding really influences the encoding). byte[] c = SeededExport(LoroEngine.Instance, peerId: 100); Assert.NotEqual(a, c); } diff --git a/tests/Weft.Versioning.Tests/FileSystemBlobStoreTests.cs b/tests/Weft.Versioning.Tests/FileSystemBlobStoreTests.cs index 1822bb9..f7ae77d 100644 --- a/tests/Weft.Versioning.Tests/FileSystemBlobStoreTests.cs +++ b/tests/Weft.Versioning.Tests/FileSystemBlobStoreTests.cs @@ -4,9 +4,9 @@ namespace Weft.Versioning.Tests; /// -/// Cobertura directa de (FU-009 / T024): round-trip, ausencia, -/// layout de sharding aa/bb/hash, idempotencia por content-addressing y limpieza de temporales. -/// El content-addressing es puro (no depende de ningún motor): se prueba con blobs arbitrarios. +/// Direct coverage of (FU-009 / T024): round-trip, absence, +/// aa/bb/hash sharding layout, idempotency via content-addressing and cleanup of temporaries. +/// Content-addressing is pure (it does not depend on any engine): it is tested with arbitrary blobs. /// public sealed class FileSystemBlobStoreTests : IDisposable { @@ -53,7 +53,7 @@ public async Task Put_lays_blob_under_two_level_shard() string hex = id.ToString(); string expected = Path.Combine(_root, hex[..2], hex[2..4], hex); - Assert.True(File.Exists(expected), $"Se esperaba el blob en {expected}"); + Assert.True(File.Exists(expected), $"Expected the blob at {expected}"); } [Fact] @@ -63,7 +63,7 @@ public async Task Put_twice_same_blob_is_idempotent() (VersionId id, byte[] blob) = Blob("dedup por hash"); await store.PutAsync(id, blob); - await store.PutAsync(id, blob); // no debe fallar (idempotente) + await store.PutAsync(id, blob); // must not fail (idempotent) string hex = id.ToString(); string shardDir = Path.Combine(_root, hex[..2], hex[2..4]); diff --git a/tests/Weft.Versioning.Tests/HeaderBindingParityTests.cs b/tests/Weft.Versioning.Tests/HeaderBindingParityTests.cs index 4c55ffc..efdcf83 100644 --- a/tests/Weft.Versioning.Tests/HeaderBindingParityTests.cs +++ b/tests/Weft.Versioning.Tests/HeaderBindingParityTests.cs @@ -7,57 +7,57 @@ namespace Weft.Versioning.Tests; /// -/// Paridad entre el header C (fuente de verdad del contrato) y las declaraciones -/// [LibraryImport] del binding, para ambos shims (FU-017, CHARTER-12). +/// Parity between the C header (source of truth for the contract) and the +/// [LibraryImport] declarations of the binding, for both shims (FU-017, CHARTER-12). /// /// /// -/// Por qué existe. Hasta CHARTER-12, `NativeMethods.cs` y `weft_ffi.h` afirmaban ambos que -/// «un test de CI valida que las declaraciones coinciden con este header». No existía: ni -/// para yrs ni para Loro. La afirmación llevaba meses en el árbol y llegó a engañar a quien redactó -/// FU-017, que pedía «replicar para Loro el test que yrs sí tiene». Este archivo es ese test, -/// creado por primera vez, y es lo que vuelve ciertos aquellos comentarios. +/// Why it exists. Until CHARTER-12, `NativeMethods.cs` and `weft_ffi.h` both claimed that +/// «a CI test validates that the declarations match this header». It did not exist: neither +/// for yrs nor for Loro. The claim had been in the tree for months and even misled whoever wrote +/// FU-017, which asked to «replicate for Loro the test that yrs does have». This file is that test, +/// created for the first time, and it is what makes those comments true. /// /// -/// Qué cubre. Que el conjunto de funciones declaradas en el header y el declarado en el -/// binding sea el mismo (ninguna sobra, ninguna falta), y que para cada una coincidan la -/// aridad, el orden y los tipos de los parámetros y el tipo de retorno, según el mapa C↔.NET de -/// — que es el contrato de marshalling que el repo ya usa de facto. +/// What it covers. That the set of functions declared in the header and the one declared in +/// the binding is the same (none extra, none missing), and that for each one the arity, the +/// order and the types of the parameters and the return type match, according to the C↔.NET map of +/// — which is the marshalling contract the repo already uses de facto. /// /// -/// Qué NO cubre, y conviene saberlo. Es paridad sintáctica. Que `size_t` case con -/// `nuint` no prueba que el marshalling sea correcto, ni valida la semántica, la convención de -/// llamada, ni el contrato de ownership. Esas garantías siguen viniendo de ASan/LSan y de los tests -/// de round-trip. Un test cuyo alcance se exagera es exactamente la clase de fallo que CHARTER-12 -/// cierra, así que este párrafo es parte del test. +/// What it does NOT cover, and is worth knowing. It is syntactic parity. That `size_t` +/// matches `nuint` does not prove the marshalling is correct, nor does it validate the semantics, the +/// calling convention, or the ownership contract. Those guarantees still come from ASan/LSan and the +/// round-trip tests. A test whose scope is overstated is exactly the class of failure that CHARTER-12 +/// closes, so this paragraph is part of the test. /// /// -/// Las funciones bajo #ifdef WEFT_TEST_HOOKS se excluyen a propósito: existen solo con la -/// feature de Cargo test-hooks y el binding no las declara (PanicSafetyTests las -/// resuelve con NativeLibrary.GetExport). El gate que verifica que no viajan en release vive -/// en release.yml. +/// The functions under #ifdef WEFT_TEST_HOOKS are excluded on purpose: they exist only with +/// the Cargo feature test-hooks and the binding does not declare them (PanicSafetyTests +/// resolves them via NativeLibrary.GetExport). The gate that verifies they don't ship in +/// release lives in release.yml. /// /// public sealed class HeaderBindingParityTests { - /// Mapa del subconjunto de C que estos headers usan → la forma .NET del binding. + /// Map of the subset of C that these headers use → the .NET shape of the binding. private static readonly Dictionary TypeMap = new(StringComparer.Ordinal) { - // Escalares + // Scalars ["void"] = "Void", ["int32_t"] = "Int32", ["uint32_t"] = "UInt32", ["uint64_t"] = "UInt64", ["size_t"] = "UIntPtr", - // Punteros de salida: `T**` / `size_t*` → `out nint` / `out nuint` (byref). + // Output pointers: `T**` / `size_t*` → `out nint` / `out nuint` (byref). ["uint8_t**"] = "IntPtr&", ["size_t*"] = "UIntPtr&", ["WeftDoc**"] = "IntPtr&", ["WeftLoroDoc**"] = "IntPtr&", - // Handles opacos prestados (HandleLease, research R2). + // Borrowed opaque handles (HandleLease, research R2). ["WeftDoc*"] = "IntPtr", ["WeftLoroDoc*"] = "IntPtr", - // Bytes de ENTRADA (const) → span con pin automático; bytes crudos (no const) → puntero. + // INPUT bytes (const) → span with automatic pinning; raw bytes (non-const) → pointer. ["const uint8_t*"] = "ReadOnlySpan", ["uint8_t*"] = "IntPtr", }; @@ -80,12 +80,12 @@ public void Header_y_binding_declaran_las_mismas_funciones(string shim, string h Assert.True( soloEnHeader.Length == 0, - $"[{shim}] el header declara funciones que el binding no tiene: {string.Join(", ", soloEnHeader)}. " + - $"Si se añadió al shim, decláralas en NativeMethods; si se quitaron, bórralas del header."); + $"[{shim}] the header declares functions the binding does not have: {string.Join(", ", soloEnHeader)}. " + + $"If they were added to the shim, declare them in NativeMethods; if they were removed, delete them from the header."); Assert.True( soloEnBinding.Length == 0, - $"[{shim}] el binding declara funciones que el header no tiene: {string.Join(", ", soloEnBinding)}. " + - $"El header es la fuente de verdad del contrato: actualízalo."); + $"[{shim}] the binding declares functions the header does not have: {string.Join(", ", soloEnBinding)}. " + + $"The header is the source of truth for the contract: update it."); } [Theory] @@ -100,19 +100,19 @@ public void Header_y_binding_coinciden_en_firma(string shim, string headerPath, { if (!binding.TryGetValue(name, out CFunction? net)) { - continue; // Lo cubre Header_y_binding_declaran_las_mismas_funciones. + continue; // Covered by Header_y_binding_declaran_las_mismas_funciones. } string esperado = MapReturn(shim, name, c.ReturnType); if (!string.Equals(esperado, net.ReturnType, StringComparison.Ordinal)) { - divergencias.Add($"{name}: retorno header={c.ReturnType}→{esperado} vs binding={net.ReturnType}"); + divergencias.Add($"{name}: return header={c.ReturnType}→{esperado} vs binding={net.ReturnType}"); } if (c.Parameters.Count != net.Parameters.Count) { divergencias.Add( - $"{name}: aridad header={c.Parameters.Count} vs binding={net.Parameters.Count}"); + $"{name}: arity header={c.Parameters.Count} vs binding={net.Parameters.Count}"); continue; } @@ -129,14 +129,14 @@ public void Header_y_binding_coinciden_en_firma(string shim, string headerPath, Assert.True( divergencias.Count == 0, - $"[{shim}] {divergencias.Count} divergencia(s) header↔binding:{Environment.NewLine}" + + $"[{shim}] {divergencias.Count} divergence(s) header↔binding:{Environment.NewLine}" + string.Join(Environment.NewLine, divergencias)); } /// - /// Caso negativo: prueba que el test de arriba puede FALLAR. Un test de paridad que no sabe - /// detectar una divergencia sería la enésima verificación fantasma — el fallo que CHARTER-12 - /// cierra. Sin este caso, los dos anteriores no valen nada. + /// Negative case: proves that the test above can FAIL. A parity test that cannot detect a + /// divergence would be yet another phantom verification — the failure that CHARTER-12 closes. + /// Without this case, the two previous ones are worth nothing. /// [Fact] public void El_parser_detecta_divergencias_sintéticas() @@ -149,17 +149,17 @@ public void Header_y_binding_coinciden_en_firma(string shim, string headerPath, """); Assert.Equal(3, h.Count); - // Función que el binding real no tiene → la detecta el test de conjuntos. + // Function the real binding does not have → detected by the set test. Assert.Contains("weft_fantasma", h.Keys); - // Aridad divergente (3 vs 2 del binding real) → la detecta el test de firma. + // Divergent arity (3 vs 2 in the real binding) → detected by the signature test. Assert.Equal(3, h["weft_buf_free"].Parameters.Count); - // Y el mapa traduce lo que sí entiende. + // And the map translates what it does understand. Assert.Equal("IntPtr&", TypeMap[h["weft_doc_new"].Parameters[0]]); } /// - /// El parser debe reventar ante una declaración que no entiende, nunca ignorarla en silencio: - /// ignorar es fabricar el fantasma que este archivo persigue (R1 del Charter). + /// The parser must blow up on a declaration it does not understand, never silently ignore it: + /// ignoring it is manufacturing the phantom this file is chasing (R1 of the Charter). /// [Fact] public void El_parser_falla_ruidosamente_ante_una_declaración_que_no_entiende() @@ -170,22 +170,22 @@ public void Header_y_binding_coinciden_en_firma(string shim, string headerPath, Assert.Contains("weft_", ex.Message, StringComparison.Ordinal); } - // ── Mapeo C → .NET ── + // ── C → .NET mapping ── private static string MapReturn(string shim, string fn, string cType) => TypeMap.TryGetValue(cType, out string? net) ? net : throw new InvalidOperationException( - $"[{shim}] tipo de retorno C sin mapear en {fn}: '{cType}'. Añádelo a TypeMap con su " + - "forma .NET, no lo ignores."); + $"[{shim}] unmapped C return type in {fn}: '{cType}'. Add it to TypeMap with its " + + ".NET shape, do not ignore it."); private static string MapParam(string shim, string fn, int index, string cType) => TypeMap.TryGetValue(cType, out string? net) ? net : throw new InvalidOperationException( - $"[{shim}] tipo de parámetro C sin mapear en {fn} #{index}: '{cType}'. Añádelo a TypeMap."); + $"[{shim}] unmapped C parameter type in {fn} #{index}: '{cType}'. Add it to TypeMap."); - // ── Reflexión sobre el binding ── + // ── Reflection over the binding ── private static IReadOnlyDictionary ReflectBinding(string typeName) { @@ -194,13 +194,13 @@ private static IReadOnlyDictionary ReflectBinding(string type : typeof(YrsEngine).Assembly; Type type = asm.GetType(typeName, throwOnError: true) - ?? throw new InvalidOperationException($"No se encontró el tipo {typeName}."); + ?? throw new InvalidOperationException($"Type {typeName} was not found."); Dictionary result = new(StringComparer.Ordinal); foreach (MethodInfo m in type.GetMethods(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.DeclaredOnly)) { - // Solo la superficie del shim: el generador de [LibraryImport] emite además helpers - // internos que no forman parte del contrato. + // Only the shim surface: the [LibraryImport] generator also emits internal helpers + // that are not part of the contract. if (!m.Name.StartsWith("weft_", StringComparison.Ordinal)) { continue; @@ -229,21 +229,21 @@ private static string NetTypeName(Type t) return t.Name; } - // ── Parser del header ── + // ── Header parser ── private sealed record CFunction(string ReturnType, IReadOnlyList Parameters); /// - /// Parser deliberadamente acotado al subconjunto de C que estos dos headers usan: declaraciones - /// TIPO nombre(args); de una superficie escrita a mano por nosotros. No es un parser de C - /// — no hay AST — y por eso su regla es fallar ante lo que no entiende (ver el test de arriba). + /// Parser deliberately scoped to the subset of C that these two headers use: declarations of the + /// form TYPE name(args); from a surface hand-written by us. It is not a C parser — there is + /// no AST — and that is why its rule is to fail on what it does not understand (see the test above). /// private static IReadOnlyDictionary ParseHeader(string text) { - // 1. Fuera comentarios de bloque (estos headers no usan `//`). + // 1. Strip block comments (these headers don't use `//`). text = Regex.Replace(text, @"/\*.*?\*/", " ", RegexOptions.Singleline); - // 2. Fuera las directivas de preprocesador, y fuera el bloque de test-hooks entero. + // 2. Strip the preprocessor directives, and strip the entire test-hooks block. StringBuilder sb = new(); bool enTestHooks = false; foreach (string raw in text.Split('\n')) @@ -269,17 +269,17 @@ private static IReadOnlyDictionary ParseHeader(string text) } } - // 3. Una declaración por `;`. + // 3. One declaration per `;`. Dictionary functions = new(StringComparer.Ordinal); foreach (string rawStmt in sb.ToString().Split(';')) { string stmt = Regex.Replace(rawStmt, @"\s+", " ").Trim(); - // Solo interesan las declaraciones del shim. Lo demás (typedef, `extern "C" {`, llaves - // sueltas) no menciona `weft_`. El filtro es a propósito más ancho que «una llamada a - // función»: cualquier declaración que mencione el prefijo tiene que ser entendida o - // reventar abajo — si exigiese `weft_…(` pegado, un puntero a función se colaría en - // silencio, que es justo el hueco que este archivo existe para impedir. + // Only the shim declarations matter. Everything else (typedef, `extern "C" {`, stray + // braces) does not mention `weft_`. The filter is deliberately wider than «a function + // call»: any declaration that mentions the prefix has to be understood or blow up below — + // if it required `weft_…(` glued together, a function pointer would slip through in + // silence, which is exactly the gap this file exists to prevent. if (!Regex.IsMatch(stmt, @"\bweft_[a-z0-9_]+")) { continue; @@ -291,9 +291,9 @@ private static IReadOnlyDictionary ParseHeader(string text) if (!m.Success) { throw new InvalidOperationException( - $"El parser no entiende esta declaración del header y NO la va a ignorar: '{stmt}'. " + - "Amplía el parser o simplifica el header — ignorarla en silencio crearía un hueco " + - "de verificación (R1 de CHARTER-12)."); + $"The parser does not understand this header declaration and will NOT ignore it: '{stmt}'. " + + "Extend the parser or simplify the header — silently ignoring it would create a " + + "verification gap (R1 of CHARTER-12)."); } string name = m.Groups["name"].Value; @@ -316,8 +316,8 @@ private static IReadOnlyList ParseArgs(string args) List types = []; foreach (string rawArg in trimmed.Split(',')) { - // `const uint8_t* field` → tipo `const uint8_t*`, nombre `field`. El nombre se descarta: - // el contrato es posicional, no nominal. + // `const uint8_t* field` → type `const uint8_t*`, name `field`. The name is discarded: + // the contract is positional, not nominal. string arg = Normalize(rawArg); int lastSpace = arg.LastIndexOf(' '); types.Add(lastSpace < 0 ? arg : Normalize(arg[..lastSpace])); @@ -326,11 +326,11 @@ private static IReadOnlyList ParseArgs(string args) return types; } - /// Colapsa espacios y pega los `*` al tipo: `uint8_t ** out` → `uint8_t** out`. + /// Collapses whitespace and glues the `*` to the type: `uint8_t ** out` → `uint8_t** out`. private static string Normalize(string s) => Regex.Replace(Regex.Replace(s, @"\s+", " "), @"\s*(\*+)\s*", "$1 ").Trim(); - // Localiza un archivo del repo subiendo desde el binario del test (mismo patrón que + // Locates a repo file by walking up from the test binary (same pattern as // DeterminismTests.DeterminismCorpusDir). private static string RepoPath(string relative) { @@ -343,6 +343,6 @@ private static string RepoPath(string relative) } } - throw new FileNotFoundException($"No se encontró '{relative}' desde el binario del test."); + throw new FileNotFoundException($"'{relative}' was not found from the test binary."); } } diff --git a/tests/Weft.Versioning.Tests/LoroNativeVersioningTests.cs b/tests/Weft.Versioning.Tests/LoroNativeVersioningTests.cs index d9756e6..3abae5d 100644 --- a/tests/Weft.Versioning.Tests/LoroNativeVersioningTests.cs +++ b/tests/Weft.Versioning.Tests/LoroNativeVersioningTests.cs @@ -6,10 +6,10 @@ namespace Weft.Versioning.Tests; /// -/// Superficie opcional de Loro (CHARTER-10/FU-006, hallazgo G1): -/// LoroEngine expone probes DEMOSTRATIVOS del versionado nativo (diff/fork/shallow snapshot). No son -/// content-addressing (no deterministas, no alimentan VersionId); estos tests asertan reachability, -/// round-trip, convergencia del merge nativo, y que los probes NO mutan el doc del caller. +/// Loro's optional surface (CHARTER-10/FU-006, finding G1): +/// LoroEngine exposes DEMONSTRATIVE probes of native versioning (diff/fork/shallow snapshot). They are not +/// content-addressing (non-deterministic, they don't feed VersionId); these tests assert reachability, +/// round-trip, native merge convergence, and that the probes do NOT mutate the caller's doc. /// public sealed class LoroNativeVersioningTests { @@ -20,7 +20,7 @@ public sealed class LoroNativeVersioningTests [Fact] public void Yrs_engine_has_no_native_versioning() { - // Contraste: yrs no tiene versionado nativo → la capacidad opcional es null (permanente). + // Contrast: yrs has no native versioning → the optional capability is null (permanently). Assert.Null(YrsEngine.Instance.NativeVersioning); } @@ -59,7 +59,7 @@ public void NativeBranchMergeProbe_converges_and_does_not_mutate_caller() using JsonDocument json = JsonDocument.Parse(Native.NativeBranchMergeProbe(doc, "body")); Assert.True(json.RootElement.GetProperty("converged").GetBoolean(), "el merge nativo debe converger"); - // El probe forkea aparte: el doc del caller no cambia. + // The probe forks separately: the caller's doc does not change. Assert.Equal("base", doc.GetText("body")); } diff --git a/tests/Weft.Versioning.Tests/LoroVersioningTests.cs b/tests/Weft.Versioning.Tests/LoroVersioningTests.cs index 33dc983..94094af 100644 --- a/tests/Weft.Versioning.Tests/LoroVersioningTests.cs +++ b/tests/Weft.Versioning.Tests/LoroVersioningTests.cs @@ -4,8 +4,8 @@ namespace Weft.Versioning.Tests; /// -/// Activa la teoría dual-engine (T034, SC-008): la MISMA suite de versionado (VersioningSuiteBase) -/// corre sobre Loro. Si pasa, la abstracción ICrdtEngine está viva sobre ambos motores (P-IV). +/// Activates the dual-engine theory (T034, SC-008): the SAME versioning suite (VersioningSuiteBase) +/// runs over Loro. If it passes, the ICrdtEngine abstraction is alive over both engines (P-IV). /// public sealed class LoroVersioningTests : VersioningSuiteBase { diff --git a/tests/Weft.Versioning.Tests/TextDiffTests.cs b/tests/Weft.Versioning.Tests/TextDiffTests.cs index 58ee9a9..08dedc4 100644 --- a/tests/Weft.Versioning.Tests/TextDiffTests.cs +++ b/tests/Weft.Versioning.Tests/TextDiffTests.cs @@ -1,6 +1,6 @@ namespace Weft.Versioning.Tests; -/// Unit tests del diff LCS por palabras (T028): operaciones, determinismo, casos límite. +/// Unit tests for the word-level LCS diff (T028): operations, determinism, edge cases. public sealed class TextDiffTests { [Fact] @@ -26,7 +26,7 @@ public void Insert_and_delete_words() Assert.True(diff.HasChanges); Assert.Contains(diff.Segments, s => s.Op == DiffOp.Deleted && s.Text.Contains("gato")); Assert.Contains(diff.Segments, s => s.Op == DiffOp.Inserted && s.Text.Contains("perro")); - // "el " y " duerme" permanecen iguales. + // "el " and " duerme" remain unchanged. Assert.Contains(diff.Segments, s => s.Op == DiffOp.Equal && s.Text.Contains("el")); Assert.Contains(diff.Segments, s => s.Op == DiffOp.Equal && s.Text.Contains("duerme")); } diff --git a/tests/Weft.Versioning.Tests/VersioningSuiteBase.cs b/tests/Weft.Versioning.Tests/VersioningSuiteBase.cs index fa1486f..cdeb463 100644 --- a/tests/Weft.Versioning.Tests/VersioningSuiteBase.cs +++ b/tests/Weft.Versioning.Tests/VersioningSuiteBase.cs @@ -4,9 +4,9 @@ namespace Weft.Versioning.Tests; /// -/// Suite parametrizada de versionado (T027): las 7 postcondiciones de contracts/versioning-api.md. -/// Es abstracta; cada subclase concreta fija el motor (P-IV: la misma suite corre idéntica sobre -/// YrsEngine Y LoroEngine — postcondición 6, por herencia). +/// Parameterized versioning suite (T027): the 7 postconditions of contracts/versioning-api.md. +/// It is abstract; each concrete subclass fixes the engine (P-IV: the same suite runs identically over +/// YrsEngine AND LoroEngine — postcondition 6, by inheritance). /// public abstract class VersioningSuiteBase { @@ -18,7 +18,7 @@ public abstract class VersioningSuiteBase return (new VersionStore(Engine, blobs), blobs); } - /// Sincroniza dos réplicas por deltas incrementales hasta converger. + /// Synchronizes two replicas via incremental deltas until they converge. private static void SyncBidirectional(ICrdtDoc a, ICrdtDoc b) { byte[] svA = a.ExportStateVector(); @@ -27,7 +27,7 @@ private static void SyncBidirectional(ICrdtDoc a, ICrdtDoc b) b.ApplyUpdate(a.ExportUpdateSince(svB)); } - // Postcondición 1: Publish x2 sin cambios → mismo VersionId, un solo blob (dedup). + // Postcondition 1: Publish x2 with no changes → same VersionId, a single blob (dedup). [Fact] public async Task Publish_twice_same_content_dedups() { @@ -42,7 +42,7 @@ public async Task Publish_twice_same_content_dedups() Assert.Equal(1, blobs.Count); } - // Postcondición 2: Checkout(Publish(doc)) → ExportState byte-idéntico al blob publicado. + // Postcondition 2: Checkout(Publish(doc)) → ExportState byte-identical to the published blob. [Fact] public async Task Checkout_roundtrip_is_byte_identical() { @@ -58,7 +58,7 @@ public async Task Checkout_roundtrip_is_byte_identical() Assert.Equal("hola áéí mundo", restored.GetText("body")); } - // Postcondición 3: réplicas convergidas publican el MISMO VersionId (SC-002). + // Postcondition 3: converged replicas publish the SAME VersionId (SC-002). [Fact] public async Task Converged_replicas_publish_same_version_id() { @@ -75,7 +75,7 @@ public async Task Converged_replicas_publish_same_version_id() Assert.Equal(ida, idb); } - // Postcondición 4: Diff(a,a) sin cambios; Diff(a,b) refleja las ediciones. + // Postcondition 4: Diff(a,a) with no changes; Diff(a,b) reflects the edits. [Fact] public async Task Diff_reflects_edits() { @@ -84,8 +84,8 @@ public async Task Diff_reflects_edits() doc.InsertText("body", 0, "el gato duerme"); VersionId v1 = await store.PublishAsync(doc); - doc.DeleteText("body", 3, 4); // borra "gato" - doc.InsertText("body", 3, "perro"); // inserta "perro" + doc.DeleteText("body", 3, 4); // deletes "gato" + doc.InsertText("body", 3, "perro"); // inserts "perro" VersionId v2 = await store.PublishAsync(doc); Assert.False((await store.DiffAsync(v1, v1, "body")).HasChanges); @@ -95,7 +95,7 @@ public async Task Diff_reflects_edits() Assert.Contains(diff.Segments, s => s.Op == DiffOp.Inserted && s.Text.Contains("perro")); } - // Postcondición 5: merge de ramas concurrentes conmutativo (mismo resultado, cualquier orden). + // Postcondition 5: commutative merge of concurrent branches (same result, any order). [Fact] public async Task Merge_is_commutative() { @@ -104,13 +104,13 @@ public async Task Merge_is_commutative() baseDoc.InsertText("body", 0, "base "); VersionId baseVersion = await store.PublishAsync(baseDoc); - // Dos ramas independientes desde la base, con ediciones concurrentes. + // Two independent branches from the base, with concurrent edits. using ICrdtDoc branch1 = await store.BranchAsync(baseVersion); using ICrdtDoc branch2 = await store.BranchAsync(baseVersion); branch1.InsertText("body", 0, "uno "); branch2.InsertText("body", 0, "dos "); - // Orden A: target = branch1, merge branch2. Orden B: target = branch2, merge branch1. + // Order A: target = branch1, merge branch2. Order B: target = branch2, merge branch1. using ICrdtDoc ordA = await store.BranchAsync(baseVersion); ordA.ApplyUpdate(branch1.ExportState()); store.Merge(ordA, branch2); @@ -125,8 +125,8 @@ public async Task Merge_is_commutative() Assert.Equal(idA, idB); } - // Postcondición 7: compactación por construcción (FR-012). Muchas versiones con ciclos - // insert+delete → todas recuperables byte-idéntico y tamaño acotado (GC del motor activo). + // Postcondition 7: compaction by construction (FR-012). Many versions with insert+delete + // cycles → all recoverable byte-identical and bounded in size (engine GC active). [Fact] public async Task Compaction_versions_recoverable_and_bounded() { @@ -138,7 +138,7 @@ public async Task Compaction_versions_recoverable_and_bounded() for (int i = 0; i < 25; i++) { doc.InsertText("body", 0, $"edición-{i} "); - // Cancela parte del contenido para generar historial que el GC puede recuperar. + // Cancel part of the content to generate history that the GC can reclaim. if (i % 2 == 1) { doc.DeleteText("body", 0, 4); @@ -148,19 +148,19 @@ public async Task Compaction_versions_recoverable_and_bounded() snapshots.Add(doc.ExportState()); } - // (a) Todas las versiones recuperables byte-idéntico por su hash. + // (a) All versions recoverable byte-identical by their hash. for (int i = 0; i < ids.Count; i++) { using ICrdtDoc restored = await store.CheckoutAsync(ids[i]); Assert.Equal(snapshots[i], restored.ExportState()); } - // (b) Dedup: no más blobs que versiones distintas publicadas. + // (b) Dedup: no more blobs than distinct published versions. Assert.True(blobs.Count <= ids.Count); - // (c) El blob final no crece de forma monótona con la longitud del historial: - // su tamaño se mantiene modesto pese a 25 ciclos de edición (GC activo, no tombstones). + // (c) The final blob does not grow monotonically with the length of the history: + // its size stays modest despite 25 edit cycles (GC active, not tombstones). Assert.True(snapshots[^1].Length < 4096, - $"El blob final ({snapshots[^1].Length} B) sugiere acumulación de historial (¿GC desactivado?)."); + $"The final blob ({snapshots[^1].Length} B) suggests history accumulation (GC disabled?)."); } } diff --git a/tests/Weft.Versioning.Tests/YrsVersioningTests.cs b/tests/Weft.Versioning.Tests/YrsVersioningTests.cs index 555c5b0..62bb81d 100644 --- a/tests/Weft.Versioning.Tests/YrsVersioningTests.cs +++ b/tests/Weft.Versioning.Tests/YrsVersioningTests.cs @@ -3,7 +3,7 @@ namespace Weft.Versioning.Tests; -/// Ejecuta la suite parametrizada de versionado sobre el motor yrs (T027). +/// Runs the parameterized versioning suite over the yrs engine (T027). public sealed class YrsVersioningTests : VersioningSuiteBase { protected override ICrdtEngine Engine => YrsEngine.Instance; diff --git a/tests/determinism-yjs/apply.mjs b/tests/determinism-yjs/apply.mjs index b125d79..696b0d5 100644 --- a/tests/determinism-yjs/apply.mjs +++ b/tests/determinism-yjs/apply.mjs @@ -1,14 +1,15 @@ -// Gate de determinismo cross-implementación (CHARTER-07/T058, research R13 (b)). +// Cross-implementation determinism gate (CHARTER-07/T058, research R13 (b)). // -// Aplica el corpus compartido (corpus.json) con Yjs JS usando client IDs FIJOS, sincroniza las -// réplicas hasta converger, y emite el SHA-256 del export v1 (encodeStateAsUpdate) de la réplica 0. -// El objetivo es comparar ese hash contra el que produce yrs con el MISMO corpus: si coinciden, el -// determinismo es "por formato" y no "por accidente de esta versión de yrs" (P-III). +// Applies the shared corpus (corpus.json) with Yjs JS using FIXED client IDs, syncs the replicas until +// they converge, and emits the SHA-256 of replica 0's v1 export (encodeStateAsUpdate). The goal is to +// compare that hash against the one yrs produces with the SAME corpus: if they match, determinism is +// "by format" and not "by accident of this version of yrs" (P-III). // -// NO-BLOQUEANTE / PROMOVIBLE: hoy el binding de yrs no expone fijar el client_id (CreateDoc() sin -// parámetro), así que la paridad byte-idéntica con yrs está gated en esa capacidad (follow-up). Este -// harness ya corre y emite el hash de Yjs; cuando yrs pueda fijar client_id, se compara y se promueve -// a gate. Si se pasa WEFT_GOLDEN_HASH (el hash de yrs), compara y reporta — sin fallar (informativo). +// NON-BLOCKING / PROMOTABLE: today the yrs binding does not expose fixing the client_id (CreateDoc() +// with no parameter), so byte-identical parity with yrs is gated on that capability (follow-up). This +// harness already runs and emits the Yjs hash; once yrs can fix the client_id, it is compared and +// promoted to a gate. If WEFT_GOLDEN_HASH (the yrs hash) is passed, it compares and reports — without +// failing (informative). import * as Y from 'yjs'; import { createHash } from 'node:crypto'; @@ -18,21 +19,21 @@ import { basename, dirname, join } from 'node:path'; const here = dirname(fileURLToPath(import.meta.url)); -// Corpus por argumento (`node apply.mjs [corpus-unicode.json]`), default corpus.json (FU-012). +// Corpus by argument (`node apply.mjs [corpus-unicode.json]`), default corpus.json (FU-012). const corpusFile = process.argv[2] ?? 'corpus.json'; const corpus = JSON.parse(readFileSync(join(here, corpusFile), 'utf8')); -// Clave del golden por corpus: corpus.json → "ascii", corpus-unicode.json → "unicode". +// Golden key by corpus: corpus.json → "ascii", corpus-unicode.json → "unicode". const goldenKey = basename(corpusFile) === 'corpus-unicode.json' ? 'unicode' : 'ascii'; -// Una réplica = un Y.Doc con clientID fijo del corpus. +// One replica = one Y.Doc with a fixed clientID from the corpus. const replicas = corpus.clientIds.map((id) => { const doc = new Y.Doc(); - doc.clientID = id; // fijar antes de cualquier operación + doc.clientID = id; // fix before any operation return doc; }); -// Aplicar cada operación a su réplica. +// Apply each operation to its replica. for (const step of corpus.ops) { const text = replicas[step.replica].getText(corpus.type); if (step.op === 'ins') { @@ -40,11 +41,11 @@ for (const step of corpus.ops) { } else if (step.op === 'del') { text.delete(step.index, step.len); } else { - throw new Error(`op desconocida: ${step.op}`); + throw new Error(`unknown op: ${step.op}`); } } -// Sincronizar todas-contra-todas hasta converger (mismo esquema que Weft.Determinism.Tests). +// Sync all-against-all until convergence (same scheme as Weft.Determinism.Tests). for (let pass = 0; pass < (corpus.syncPasses ?? 2); pass++) { for (const target of replicas) { const sv = Y.encodeStateVector(target); @@ -56,31 +57,31 @@ for (let pass = 0; pass < (corpus.syncPasses ?? 2); pass++) { } } -// Hash del export v1 de la réplica 0 (ya convergida). +// Hash of replica 0's v1 export (already converged). const update = Y.encodeStateAsUpdate(replicas[0]); const hash = createHash('sha256').update(Buffer.from(update)).digest('hex'); const finalText = replicas[0].getText(corpus.type).toString(); -console.log(`Yjs corpus: ${corpusFile}`); -console.log(`Yjs texto convergido: "${finalText}"`); -console.log(`Yjs export SHA-256: ${hash}`); +console.log(`Yjs corpus: ${corpusFile}`); +console.log(`Yjs converged text: "${finalText}"`); +console.log(`Yjs export SHA-256: ${hash}`); -// Self-check contra el golden comprometido (golden.json[goldenKey]). Caza drift de Yjs: si Yjs -// bumpea y cambia el encoding, el hash emitido deja de coincidir con el golden. La aserción -// BLOQUEANTE real de paridad yrs↔Yjs vive en Weft.Determinism.Tests (per-PR); este job es -// informativo (`continue-on-error` en release.yml). WEFT_GOLDEN_HASH sigue soportado como override. +// Self-check against the committed golden (golden.json[goldenKey]). Catches Yjs drift: if Yjs bumps +// and changes the encoding, the emitted hash stops matching the golden. The real BLOCKING yrs↔Yjs +// parity assertion lives in Weft.Determinism.Tests (per-PR); this job is informative +// (`continue-on-error` in release.yml). WEFT_GOLDEN_HASH is still supported as an override. const goldenPath = join(here, 'golden.json'); const override = process.env.WEFT_GOLDEN_HASH; const golden = override ?? (existsSync(goldenPath) ? JSON.parse(readFileSync(goldenPath, 'utf8'))[goldenKey] : undefined); if (golden) { if (golden === hash) { - console.log(`✓ El hash de Yjs coincide con el golden comprometido (${goldenKey}).`); + console.log(`✓ Yjs hash matches the committed golden (${goldenKey}).`); } else { - console.log(`⚠ DIVERGENCIA (no-bloqueante): Yjs difiere del golden (${goldenKey}).`); + console.log(`⚠ DIVERGENCE (non-blocking): Yjs differs from the golden (${goldenKey}).`); console.log(` golden: ${golden}`); - console.log(' Posible drift de Yjs (bump con impacto de encoding). Regenerar golden. Ver README.'); + console.log(' Possible Yjs drift (bump with encoding impact). Regenerate golden. See README.'); } } else { - console.log('ℹ Sin golden.json ni WEFT_GOLDEN_HASH: harness informativo (solo emite el hash de Yjs).'); + console.log('ℹ No golden.json nor WEFT_GOLDEN_HASH: informative harness (only emits the Yjs hash).'); } diff --git a/tests/pack-smoke/Program.cs b/tests/pack-smoke/Program.cs index 5c0ab01..3fe3210 100644 --- a/tests/pack-smoke/Program.cs +++ b/tests/pack-smoke/Program.cs @@ -3,14 +3,14 @@ using Weft.Versioning.Blobs; using Weft.Yrs; -// pack-smoke (CHARTER-07/T057, SC-007): "hello-Weft" consumido desde el .nupkg empaquetado. -// Prueba que el binario nativo resuelve desde runtimes//native/ en una máquina limpia y que -// un ciclo edit→publish corre. Sale con código != 0 ante cualquier fallo (gate de CI por RID). +// pack-smoke (CHARTER-07/T057, SC-007): "hello-Weft" consumed from the packaged .nupkg. +// Proves that the native binary resolves from runtimes//native/ on a clean machine and that +// an edit→publish cycle runs. Exits with a non-zero code on any failure (per-RID CI gate). Console.WriteLine($"Weft pack-smoke — RID: {System.Runtime.InteropServices.RuntimeInformation.RuntimeIdentifier}"); ICrdtEngine engine = YrsEngine.Instance; -Console.WriteLine($" motor nativo resuelto: {engine.Name}"); +Console.WriteLine($" native engine resolved: {engine.Name}"); var store = new VersionStore(engine, new InMemoryBlobStore()); using ICrdtDoc doc = engine.CreateDoc(); @@ -19,21 +19,21 @@ string text = doc.GetText("titulo"); string versionText = version.ToString(); -Console.WriteLine($" texto: \"{text}\""); -Console.WriteLine($" versión: {versionText}"); +Console.WriteLine($" text: \"{text}\""); +Console.WriteLine($" version: {versionText}"); if (text != "hola weft") { - Console.Error.WriteLine($"✗ FALLO: texto inesperado (\"{text}\")"); + Console.Error.WriteLine($"✗ FAILURE: unexpected text (\"{text}\")"); return 1; } -// El VersionId es el SHA-256 hex del export determinista (64 chars); un hash vacío/corto = ruptura. +// The VersionId is the hex SHA-256 of the deterministic export (64 chars); an empty/short hash = breakage. if (string.IsNullOrWhiteSpace(versionText) || versionText.Length < 32) { - Console.Error.WriteLine($"✗ FALLO: VersionId vacío o demasiado corto (\"{versionText}\")"); + Console.Error.WriteLine($"✗ FAILURE: VersionId empty or too short (\"{versionText}\")"); return 1; } -Console.WriteLine("✓ pack-smoke OK: nativo resuelto desde el paquete, edit→publish verde."); +Console.WriteLine("✓ pack-smoke OK: native resolved from the package, edit→publish green."); return 0;