From 2db56743db09b21302cad4bf47c27c97469d93eb Mon Sep 17 00:00:00 2001 From: John Vandenberg Date: Tue, 21 Jul 2026 12:09:59 +0800 Subject: [PATCH 1/2] Add C++ support --- .mise/config.rust.toml | 6 + .mise/config.zig.toml | 77 ++++++--- CLAUDE.md | 50 +++--- Cargo.lock | 15 ++ Cargo.toml | 1 + README.md | 2 +- .../rules/doc-summary-ends-with-period.yaml | 1 + config/clang-format.yaml | 8 +- config/clang-tidy.yaml | 2 +- config/ls-lint.yaml | 2 + config/semgrep/comment-summary-line.yaml | 1 + services/ws-modules/except1/Cargo.toml | 28 ++++ services/ws-modules/except1/src/lib.rs | 157 ++++++++++++++++++ services/ws-modules/zig-data1/build.zig | 6 + services/ws-modules/zig-data1/src/main.zig | 6 + services/ws-modules/zig-data1/src/util.cpp | 32 ++++ services/ws-modules/zig-except1/build.zig | 61 +++++++ services/ws-modules/zig-except1/build.zig.zon | 9 + .../ws-modules/zig-except1/pkg/.gitignore | 2 + .../zig-except1/pkg/et_ws_zig_except1.js | 132 +++++++++++++++ .../pkg/et_ws_zig_except1_worker.js | 56 +++++++ .../ws-modules/zig-except1/src/exceptions.cpp | 116 +++++++++++++ services/ws-modules/zig-except1/src/main.zig | 106 ++++++++++++ services/ws-web-runner/tests/modules.rs | 2 + 24 files changed, 822 insertions(+), 56 deletions(-) create mode 100644 services/ws-modules/except1/Cargo.toml create mode 100644 services/ws-modules/except1/src/lib.rs create mode 100644 services/ws-modules/zig-data1/src/util.cpp create mode 100644 services/ws-modules/zig-except1/build.zig create mode 100644 services/ws-modules/zig-except1/build.zig.zon create mode 100644 services/ws-modules/zig-except1/pkg/.gitignore create mode 100644 services/ws-modules/zig-except1/pkg/et_ws_zig_except1.js create mode 100644 services/ws-modules/zig-except1/pkg/et_ws_zig_except1_worker.js create mode 100644 services/ws-modules/zig-except1/src/exceptions.cpp create mode 100644 services/ws-modules/zig-except1/src/main.zig diff --git a/.mise/config.rust.toml b/.mise/config.rust.toml index c0680776..062a7598 100644 --- a/.mise/config.rust.toml +++ b/.mise/config.rust.toml @@ -30,6 +30,12 @@ description = "Build the comm1 workflow WASM module" dir = "services/ws-modules/comm1" run = "{{ vars.web_cov_wrapper }}wasm-pack build . --target web {{ vars.no_opt }}{{ vars.web_cov_feat }}" +[tasks.build-ws-except1-module] +depends = ["build-wasm-cov-wrapper"] +description = "Build the except1 exception-handling demo WASM module" +dir = "services/ws-modules/except1" +run = "{{ vars.web_cov_wrapper }}wasm-pack build . --target web {{ vars.no_opt }}{{ vars.web_cov_feat }}" + [tasks.build-ws-sensor1-module] depends = ["build-wasm-cov-wrapper"] description = "Build the sensor1 workflow WASM module" diff --git a/.mise/config.zig.toml b/.mise/config.zig.toml index 2a7ef2bd..447e2b21 100644 --- a/.mise/config.zig.toml +++ b/.mise/config.zig.toml @@ -7,8 +7,8 @@ # artifact; the Zig REST-client generation step in et-int-gen detects the missing binary at runtime and is a # no-op when it's not on PATH. "github:christianhelle/openapi2zig" = { version = "latest", os = ["linux/x64", "macos", "windows"] } -# C tooling for the zig-data1 module's hand-written C. -# The C sources live under services/ws-modules/zig-data1/src/*.c. Two conda packages because mise hands the +# C/C++ tooling for the zig-data1 module's hand-written C and C++. +# The sources live under services/ws-modules/zig-data1/src/*.{c,cpp}. Two conda packages because mise hands the # `clang-format` name to conda:clang-format, so conda:clang-tools omits it and only supplies clang-tidy. Both # are conda-forge's native LLVM binaries. cpplint (Google C++ style) and flawfinder (C/C++ security scanner) # have no prebuilt binary -- pipx is their only distribution. @@ -52,58 +52,76 @@ run = "zig fmt --check services/ws-modules/ generated/zig-rest/" run = "zig fmt services/ws-modules/ generated/zig-rest/" [tasks.clang-format] -description = "Format C sources (clang-format)" -run = "git ls-files '*.c' '*.h' | xargs clang-format -i --style=file:config/clang-format.yaml" +description = "Format C/C++ sources (clang-format)" +run = "git ls-files '*.c' '*.cpp' '*.h' | xargs clang-format -i --style=file:config/clang-format.yaml" [tasks.clang-format-check] -description = "Check C source formatting (clang-format)" -run = "git ls-files '*.c' '*.h' | xargs clang-format --dry-run --Werror --style=file:config/clang-format.yaml" +description = "Check C/C++ source formatting (clang-format)" +run = "git ls-files '*.c' '*.cpp' '*.h' | xargs clang-format --dry-run --Werror --style=file:config/clang-format.yaml" [tasks.clang-tidy-check] -description = "Lint C sources (clang-tidy)" -# Flags after `--` are the compile args. -# clang_resource_arg points clang at its builtin headers (set per host -- see config.linux.toml). {{ }} renders -# to empty where unset, leaving a bare `--`. The host clang-tidy can't parse the Windows-only mingw-shim -# (windows.h, MSVC CRT), so it gets a second pass with llvm-mingw's clang-tidy cross-targeting windows; -# `mise where` locates that install on any host (config.windows.toml on Windows, config.zig.toml on Linux/macOS). +description = "Lint C/C++ sources (clang-tidy)" +# Three passes, split by the compile target each bucket of C/C++ needs before clang-tidy can parse it. +# Flags after `--` are the compile args; clang_resource_arg points clang at its builtin headers (set per host +# -- see config.linux.toml), and {{ }} renders empty where unset, leaving a bare `--`. +# 1. host pass -- portable C/C++ built for the native target. This currently matches NOTHING: every tracked +# C/C++ file lives under services/ws-web-runner/mingw-shim or services/ws-modules/zig-* (both excluded +# just below), so the pass is a reserved slot for future portable C/C++ that is neither the Windows shim +# nor a wasm module. (On main it matched zig-data1/src/util.c; PR #93 moved the zig sources to pass 3.) +# 2. mingw-shim -- Windows-only (windows.h, MSVC CRT), so it goes through llvm-mingw's clang-tidy +# cross-targeting x86_64-w64-mingw32 (`mise where` locates that install on any host). +# 3. zig modules -- compiled to wasm, so the host clang-tidy targets wasm32 with the same exception-handling +# flags their build.zig files use, so wasm-only builtins (__builtin_wasm_throw) parse. +# `xargs -r` (--no-run-if-empty) guards every pass -- required because pass 1 matches nothing (above) and +# uutils findutils 0.8.0 xargs panics on empty input in -I mode: +# thread 'main' panicked at src/xargs/mod.rs:411:46: index out of bounds: the len is 0 but the index is 0 +# (observed on PR #93's docker-linux build + check lanes). -r makes empty input a clean no-op. run = """ -git ls-files '*.c' '*.h' ':!services/ws-web-runner/mingw-shim' | - xargs -I{} clang-tidy --config-file=config/clang-tidy.yaml {} -- {{ vars.clang_resource_arg }} +git ls-files '*.c' '*.cpp' '*.h' ':!services/ws-web-runner/mingw-shim' ':!services/ws-modules/zig-*' | + xargs -r -I{} clang-tidy --config-file=config/clang-tidy.yaml {} -- {{ vars.clang_resource_arg }} llvm_ct="$(mise where 'github:mstorsjo/llvm-mingw')/bin/clang-tidy" git ls-files 'services/ws-web-runner/mingw-shim/*.c' | - xargs -I{} "$llvm_ct" --config-file=config/clang-tidy.yaml {} -- --target=x86_64-w64-mingw32 -std=c11 + xargs -r -I{} "$llvm_ct" --config-file=config/clang-tidy.yaml {} -- --target=x86_64-w64-mingw32 -std=c11 +wasm_flags=({{ vars.clang_resource_arg }} --target=wasm32-unknown-unknown -fwasm-exceptions -mexception-handling) +git ls-files 'services/ws-modules/zig-*/src/*.c' 'services/ws-modules/zig-*/src/*.cpp' | + xargs -r -I{} clang-tidy --config-file=config/clang-tidy.yaml {} -- "${wasm_flags[@]}" -fno-rtti """ shell = "bash -euo pipefail -c" [tasks.clang-tidy-fix] -description = "Apply clang-tidy's machine-applicable fix-its to C sources in place" -# Mirrors clang-tidy-check's two passes: host clang-tidy for portable C, llvm-mingw's for the windows mingw-shim. +description = "Apply clang-tidy's machine-applicable fix-its to C/C++ sources in place" +# Mirrors clang-tidy-check's three passes. +# Host clang-tidy for portable C/C++, llvm-mingw's for the windows mingw-shim, and a wasm32 cross-target pass +# for the zig modules' C/C++. run = """ -git ls-files '*.c' '*.h' ':!services/ws-web-runner/mingw-shim' | - xargs -I{} clang-tidy --fix --config-file=config/clang-tidy.yaml {} -- {{ vars.clang_resource_arg }} +git ls-files '*.c' '*.cpp' '*.h' ':!services/ws-web-runner/mingw-shim' ':!services/ws-modules/zig-*' | + xargs -r -I{} clang-tidy --fix --config-file=config/clang-tidy.yaml {} -- {{ vars.clang_resource_arg }} llvm_ct="$(mise where 'github:mstorsjo/llvm-mingw')/bin/clang-tidy" git ls-files 'services/ws-web-runner/mingw-shim/*.c' | - xargs -I{} "$llvm_ct" --fix --config-file=config/clang-tidy.yaml {} -- --target=x86_64-w64-mingw32 -std=c11 + xargs -r -I{} "$llvm_ct" --fix --config-file=config/clang-tidy.yaml {} -- --target=x86_64-w64-mingw32 -std=c11 +wasm_flags=({{ vars.clang_resource_arg }} --target=wasm32-unknown-unknown -fwasm-exceptions -mexception-handling) +git ls-files 'services/ws-modules/zig-*/src/*.c' 'services/ws-modules/zig-*/src/*.cpp' | + xargs -r -I{} clang-tidy --fix --config-file=config/clang-tidy.yaml {} -- "${wasm_flags[@]}" -fno-rtti """ shell = "bash -euo pipefail -c" [tasks.cpplint-check] -description = "Lint C sources for Google C++ style (cpplint)" +description = "Lint C/C++ sources for Google C++ style (cpplint)" # cpplint and clang-tidy share the `// NOLINT` syntax, so cpplint sees the shim's clang-tidy-named NOLINTs. # -readability/nolint stops it erroring on those (bugprone-*, clang-diagnostic-*) as unknown cpplint categories. run = """ -git ls-files '*.c' '*.h' | +git ls-files '*.c' '*.cpp' '*.h' | xargs cpplint --quiet --linelength=120 --filter=-legal/copyright,-build/include_subdir,-readability/nolint """ shell = "bash -euo pipefail -c" [tasks.flawfinder-check] -description = "Scan C sources for risky C APIs (flawfinder security lint)" +description = "Scan C/C++ sources for risky C APIs (flawfinder security lint)" # flawfinder is a lexical scanner (no compile), so unlike clang-tidy it also covers the mingw-shim sources. # Gate on risk level >= 2; the blanket level-1 notes (e.g. every strlen) are too noisy to fail CI on. A # reviewed-safe hit at or above the gate is silenced with an inline `flawfinder: ignore` comment stating why. run = """ -git ls-files '*.c' '*.h' | +git ls-files '*.c' '*.cpp' '*.h' | xargs flawfinder --error-level=2 --minlevel=2 --quiet --dataonly """ shell = "bash -euo pipefail -c" @@ -111,21 +129,26 @@ shell = "bash -euo pipefail -c" # Namespaced aggregators picked up by the default config's globbed `check`/`fmt`. [tasks."check:zig"] depends = ["clang-format-check", "clang-tidy-check", "cpplint-check", "flawfinder-check", "zig-check"] -description = "Run Zig + C checks (zig fmt-check, clang-format, clang-tidy, cpplint, flawfinder)" +description = "Run Zig + C/C++ checks (zig fmt-check, clang-format, clang-tidy, cpplint, flawfinder)" [tasks."fmt:zig"] depends = ["clang-format", "zig-fmt"] -description = "Format Zig + C sources" +description = "Format Zig + C/C++ sources" [tasks."fix:zig"] depends = ["clang-tidy-fix"] -description = "Apply C lint-fix passes" +description = "Apply C/C++ lint-fix passes" [tasks.build-ws-zig-data1-module] description = "Build the zig-data1 workflow WASM module" dir = "services/ws-modules/zig-data1" run = "zig build -Doptimize=ReleaseSmall" +[tasks.build-ws-zig-except1-module] +description = "Build the zig-except1 exception-handling demo WASM module" +dir = "services/ws-modules/zig-except1" +run = "zig build -Doptimize=ReleaseSmall" + [tasks."prefetch:zig"] description = "Prefetch Zig build dependencies" run = "zig build --fetch --build-file services/ws-modules/zig-data1/build.zig" diff --git a/CLAUDE.md b/CLAUDE.md index 0af8f829..7b925a3e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -220,28 +220,28 @@ settings, different file globs). taplo specifically: the only right way to forma The `mise run ` formatters and checks per file type. The aggregates (`fmt`/`check`/`fmt-all`/`check-all`) run every loaded language's row; guest rows need their `MISE_ENV` loaded. -| File type | Formatter task(s) | -| --------- | ------------------------------- | -| `*.rs` | `cargo-fmt`, `cargo-clippy-fix` | -| `*.toml` | `taplo-fmt` | -| `*.py` | `ruff-fmt` | -| `*.dart` | `fmt:dart` | -| `*.zig` | `fmt:zig` | -| `*.c` | `clang-format` | -| `*.cs` | `fmt:dotnet` | - -| File type | Check task(s) | -| --------- | ---------------------------------------------------------------------------------------------- | -| `*.rs` | `cargo-check`, `cargo-clippy`, `cargo-fmt-check`, `cargo-doc-check`, `ast-grep-check` | -| `*.toml` | `taplo-check`, `conftest-check-toml`, `semgrep-check` | -| `*.yaml` | `ast-grep-check`, `conftest-check-yaml`, `ryl-check`, `action-validator-check`, `zizmor-check` | -| `*.json` | `semgrep-check` | -| `*.py` | `check:python` | -| `*.dart` | `check:dart` | -| `*.zig` | `check:zig` | -| `*.c` | `clang-format-check`, `clang-tidy-check`, `cpplint-check` | -| `*.cs` | `check:dotnet` | -| `*.java` | `check:java` | +| File type | Formatter task(s) | +| -------------- | ------------------------------- | +| `*.rs` | `cargo-fmt`, `cargo-clippy-fix` | +| `*.toml` | `taplo-fmt` | +| `*.py` | `ruff-fmt` | +| `*.dart` | `fmt:dart` | +| `*.zig` | `fmt:zig` | +| `*.c`, `*.cpp` | `clang-format` | +| `*.cs` | `fmt:dotnet` | + +| File type | Check task(s) | +| -------------- | ---------------------------------------------------------------------------------------------- | +| `*.rs` | `cargo-check`, `cargo-clippy`, `cargo-fmt-check`, `cargo-doc-check`, `ast-grep-check` | +| `*.toml` | `taplo-check`, `conftest-check-toml`, `semgrep-check` | +| `*.yaml` | `ast-grep-check`, `conftest-check-yaml`, `ryl-check`, `action-validator-check`, `zizmor-check` | +| `*.json` | `semgrep-check` | +| `*.py` | `check:python` | +| `*.dart` | `check:dart` | +| `*.zig` | `check:zig` | +| `*.c`, `*.cpp` | `clang-format-check`, `clang-tidy-check`, `cpplint-check` | +| `*.cs` | `check:dotnet` | +| `*.java` | `check:java` | `dprint-fmt` / `dprint-check` cover `*.md`, `*.yaml`, `*.json`/`*.jsonc`, `*.ts`/`*.js`, `*.css`, `*.html`, `*.java`, and `Dockerfile*`; `hadolint-check` also lints Dockerfiles, and `link-check` scans `*.md` + `*.rs`. Every @@ -304,15 +304,15 @@ The server only serves them from disk. Languages: -- **Rust -> WASM** (wasm-pack): audio1, bluetooth, comm1, data1, face-detection, geolocation, graphics-info, har1, nfc, - sensor1, speech-recognition, video1 +- **Rust -> WASM** (wasm-pack): audio1, bluetooth, comm1, data1, except1, face-detection, geolocation, graphics-info, + har1, nfc, sensor1, speech-recognition, video1 - **Dart -> JS**: dart-comm1 - **Python (Pyodide)**: pydata1, pyeye1, pyface1 - **C# (.NET WASM)**: dotnet-data1 - **Java (TeaVM -> JS)**: java-data1 - **R (webR -> WASM)**: rdata1, rcomm1 -- browser-only (webR spawns a classic Worker, unsupported by Deno's ws-web-runner). Their JS shims are linted by the `js` env; `MISE_ENV=r` supplies webR + the vendoring build tasks. -- **Zig -> WASM**: zig-data1 +- **Zig -> WASM**: zig-data1, zig-except1 (C++ wasm-exception-handling demo) - **Python (componentize-py -> WASI Preview 2 component)**: wasi-graphics-info -- runs in `et-ws-wasi-runner` rather than the browser. The WIT world the component implements is at `services/ws-wasi-runner/wit/world.wit` and is mirrored under the module's own `wit/`. diff --git a/Cargo.lock b/Cargo.lock index eb376e3e..1b09b9ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4451,6 +4451,21 @@ dependencies = [ "web-sys", ] +[[package]] +name = "et-ws-except1" +version = "0.1.0" +dependencies = [ + "et-web", + "et-ws-wasm-agent", + "js-sys", + "tracing", + "tracing-wasm", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test", + "web-sys", +] + [[package]] name = "et-ws-face-detection" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index a8bbf4c3..ffd011ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ members = [ "services/ws-modules/bluetooth", "services/ws-modules/comm1", "services/ws-modules/data1", + "services/ws-modules/except1", "services/ws-modules/face-detection", "services/ws-modules/geolocation", "services/ws-modules/graphics-info", diff --git a/README.md b/README.md index 1e608225..7f94cf5b 100644 --- a/README.md +++ b/README.md @@ -255,7 +255,7 @@ Most are Rust built with `wasm-pack build --target web`; other languages: - Python, using [pyodide](https://pyodide.org/) and [RustPython](https://rustpython.github.io/) - R, using [webR](https://docs.r-wasm.org/webr/latest/) (rdata1, rcomm1) -- browser-only: webR spawns a classic Web Worker, which Deno's `et-ws-web-runner` does not support, so these fail there by design -- Zig, including C code +- Zig, including C and C++ code #### et-ws-web-runner on Windows diff --git a/config/ast-grep/rules/doc-summary-ends-with-period.yaml b/config/ast-grep/rules/doc-summary-ends-with-period.yaml index 02d82b8f..35756c0a 100644 --- a/config/ast-grep/rules/doc-summary-ends-with-period.yaml +++ b/config/ast-grep/rules/doc-summary-ends-with-period.yaml @@ -30,6 +30,7 @@ files: - libs/ws-runner-common/tests/config.rs - services/websockify/src/lib.rs - services/websockify/tests/relay.rs + - services/ws-modules/except1/src/lib.rs - services/ws-pyo3-runner/tests/modules.rs - services/ws-server/src/lib.rs - services/ws-wasi-runner/tests/modules.rs diff --git a/config/clang-format.yaml b/config/clang-format.yaml index 81362648..a3b6e21c 100644 --- a/config/clang-format.yaml +++ b/config/clang-format.yaml @@ -1,6 +1,10 @@ -# clang-format style for C sources. +# clang-format style for C/C++ sources. # Applied via `mise run clang-format` (and clang-format-check). Passed to clang-format as --style=file:. LLVM -# base with the editorconfig line length and 4-space indent (matches the C sources). +# base with the editorconfig line length and 4-space indent (matches the C/C++ sources). BasedOnStyle: LLVM IndentWidth: 4 ColumnLimit: 120 +# Two spaces before trailing comments, not LLVM's one. +# cpplint's whitespace/comments rule demands >= 2 spaces before a trailing `//` comment, and its +# readability/namespace rule demands a `} // namespace` terminator -- so the two styles must agree. +SpacesBeforeTrailingComments: 2 diff --git a/config/clang-tidy.yaml b/config/clang-tidy.yaml index 5d29b8e1..79372cb9 100644 --- a/config/clang-tidy.yaml +++ b/config/clang-tidy.yaml @@ -1,4 +1,4 @@ -# clang-tidy checks for C sources. +# clang-tidy checks for C/C++ sources. # Applied via `mise run clang-tidy-check` (passed as --config-file=). # Bug-finding analysis only; opinionated readability/style checks are left off to avoid churn on the small C surface. # cppcoreguidelines-avoid-non-const-global-variables is pulled in specifically (not the whole cppcoreguidelines set) diff --git a/config/ls-lint.yaml b/config/ls-lint.yaml index ac414339..0ed58be2 100644 --- a/config/ls-lint.yaml +++ b/config/ls-lint.yaml @@ -10,6 +10,8 @@ ls: .py: snake_case | regex:__[a-z]+__ .js: snake_case .zig: snake_case + .c: snake_case + .cpp: snake_case .dart: snake_case .wit: snake_case | kebab-case .rego: snake_case | kebab-case diff --git a/config/semgrep/comment-summary-line.yaml b/config/semgrep/comment-summary-line.yaml index bb6115f7..07b629a7 100644 --- a/config/semgrep/comment-summary-line.yaml +++ b/config/semgrep/comment-summary-line.yaml @@ -37,6 +37,7 @@ rules: paths: include: - "*.c" + - "*.cpp" - "*.h" - "*.jsonc" # The C-style twin of comment-summary-line above, covering `//` and `/* */` comments (so it also covers .jsonc). diff --git a/services/ws-modules/except1/Cargo.toml b/services/ws-modules/except1/Cargo.toml new file mode 100644 index 00000000..7933fd4c --- /dev/null +++ b/services/ws-modules/except1/Cargo.toml @@ -0,0 +1,28 @@ +[package] +description = "exception handling demo 1" +name = "et-ws-except1" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] +doctest = false +test = false + +[dependencies] +et-web.workspace = true +et-ws-wasm-agent.workspace = true +js-sys.workspace = true +tracing.workspace = true +tracing-wasm.workspace = true +wasm-bindgen.workspace = true +wasm-bindgen-futures.workspace = true +web-sys = { workspace = true, features = ["Window", "console"] } + +[dev-dependencies] +wasm-bindgen-test.workspace = true + +[lints] +workspace = true diff --git a/services/ws-modules/except1/src/lib.rs b/services/ws-modules/except1/src/lib.rs new file mode 100644 index 00000000..be2e3a47 --- /dev/null +++ b/services/ws-modules/except1/src/lib.rs @@ -0,0 +1,157 @@ +//! except1: demonstrates Rust's exception model in a browser WASM module. +//! +//! Rust has no throw/catch. Recoverable failures are `Result` values, handled where they occur or propagated +//! with `?`; the `Err` a `#[wasm_bindgen]` entry point returns becomes a rejected promise the JS host catches. +//! A `panic!` on wasm32-unknown-unknown does not unwind at all: it lowers to the `unreachable` instruction, +//! the instance traps, and the JS host observes an unrecoverable `RuntimeError` -- `std::panic::catch_unwind` +//! cannot intercept it on this target. The whole demo is therefore `Result`-shaped: an Ok path, a recovered +//! Err path, and boundary translation of the domain error into a `JsValue`. + +#![expect( + clippy::future_not_send, + clippy::single_call_fn, + reason = "browser WASM module: JsFuture is !Send; module-local helpers like wait_for_* are single-use by design" +)] + +use core::fmt; + +use et_ws_wasm_agent::{WsClient, WsClientConfig, append_to_textarea}; +use js_sys::{Promise, Reflect}; +use tracing::info; +use wasm_bindgen::prelude::*; +use wasm_bindgen_futures::JsFuture; + +#[wasm_bindgen(start)] +pub fn init() { + tracing_wasm::set_as_global_default(); + info!("except1 exception-handling demo module initialized"); +} + +/// Failure of [`checked_divide`]: the quotient is unrepresentable (zero divisor, or `i32::MIN / -1`). +#[derive(Debug, PartialEq, Eq)] +struct DivideError; + +impl fmt::Display for DivideError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("quotient is unrepresentable (zero divisor or i32::MIN / -1)") + } +} + +/// Returns the quotient, or a [`DivideError`] the caller must consume. +/// +/// The unignorable `Result` is the compiler-enforced analog of the C++ `throw` in zig-except1's +/// checked_divide. +fn checked_divide(num: i32, den: i32) -> Result { + num.checked_div(den).ok_or(DivideError) +} + +#[wasm_bindgen] +pub async fn run() -> Result<(), JsValue> { + let msg = "except1: entered run()"; + log(msg); + set_module_status(msg)?; + + let ws_url = websocket_url()?; + let mut client = WsClient::new(WsClientConfig::new(ws_url)); + + client.connect()?; + wait_for_connected(&client).await?; + let agent_id = wait_for_agent_id(&client).await?; + let msg = format!("except1: connected as {agent_id}"); + log(&msg); + set_module_status(&msg)?; + + // Ok path: the value flows out of the Result exactly where the caller consumes it. + let quotient = checked_divide(84, 4); + let msg = match quotient { + Ok(value) => format!("except1: checked_divide(84, 4) = {value} (Ok path)"), + Err(ref error) => format!("except1: checked_divide(84, 4) failed unexpectedly: {error}"), + }; + log(&msg); + set_module_status(&msg)?; + + // Recovered Err path: the zero divisor produces an Err the caller handles in place and execution + // continues -- Rust's analog of zig-except1's throw-caught-in-C++ demo. + let recovered = checked_divide(1, 0); + let msg = match recovered { + Ok(value) => format!("except1: checked_divide(1, 0) unexpectedly returned {value}"), + Err(ref error) => format!("except1: checked_divide(1, 0) recovered from error: {error}"), + }; + log(&msg); + set_module_status(&msg)?; + + if quotient == Ok(21) && recovered.is_err() { + let msg = "except1: VERIFICATION SUCCESS - Result handling behaved as expected!"; + log(msg); + set_module_status(msg)?; + } else { + let msg = "except1: VERIFICATION FAILURE - unexpected results!"; + log(msg); + set_module_status(msg)?; + // Boundary translation: the domain failure leaves the module as a JsValue, rejecting the promise the + // JS host awaits -- the same boundary role as zig-except1's non-zero run() status code. + return Err(JsValue::from_str("except1: unexpected Result outcomes")); + } + + sleep_ms(2000).await?; + client.disconnect(); + let msg = "except1: workflow complete"; + log(msg); + set_module_status(msg)?; + Ok(()) +} + +fn log(message: &str) { + let line = format!("[except1] {message}"); + web_sys::console::log_1(&JsValue::from_str(&line)); +} + +fn set_module_status(message: &str) -> Result<(), JsValue> { + append_to_textarea("module-output", message) +} + +async fn wait_for_connected(client: &WsClient) -> Result<(), JsValue> { + for _ in 0_u32..100 { + if client.get_state() == "connected" { + return Ok(()); + } + sleep_ms(100).await?; + } + Err(JsValue::from_str("Timed out waiting for websocket connection")) +} + +async fn wait_for_agent_id(client: &WsClient) -> Result { + for _ in 0_u32..100 { + let agent_id = client.get_agent_id(); + if !agent_id.is_empty() { + return Ok(agent_id); + } + sleep_ms(100).await?; + } + Err(JsValue::from_str("Timed out waiting for assigned agent_id")) +} + +async fn sleep_ms(duration_ms: i32) -> Result<(), JsValue> { + let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; + let promise = Promise::new(&mut |resolve, _reject| { + let callback = Closure::once_into_js(move || { + et_web::ignore(resolve.call0(&JsValue::NULL)); + }); + let _id: Result = + window.set_timeout_with_callback_and_timeout_and_arguments_0(callback.unchecked_ref(), duration_ms); + }); + JsFuture::from(promise).await.map(|_| ()) +} + +fn websocket_url() -> Result { + let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; + let location = Reflect::get(window.as_ref(), &JsValue::from_str("location"))?; + let protocol = Reflect::get(&location, &JsValue::from_str("protocol"))? + .as_string() + .ok_or_else(|| JsValue::from_str("window.location.protocol is unavailable"))?; + let host = Reflect::get(&location, &JsValue::from_str("host"))? + .as_string() + .ok_or_else(|| JsValue::from_str("window.location.host is unavailable"))?; + let ws_protocol = if protocol == "https:" { "wss:" } else { "ws:" }; + Ok(format!("{ws_protocol}//{host}/ws")) +} diff --git a/services/ws-modules/zig-data1/build.zig b/services/ws-modules/zig-data1/build.zig index 68e9d0f0..ecbe88b8 100644 --- a/services/ws-modules/zig-data1/build.zig +++ b/services/ws-modules/zig-data1/build.zig @@ -43,6 +43,12 @@ pub fn build(b: *std.Build) void { lib.entry = .disabled; lib.rdynamic = true; root_module.addCSourceFile(.{ .file = b.path("src/util.c") }); + // C++ compiles through the same clang, but freestanding wasm32 has no libc++: keep exceptions and RTTI + // off so nothing references the missing C++ runtime (unwind tables, type_info, __cxa_* symbols). + root_module.addCSourceFile(.{ + .file = b.path("src/util.cpp"), + .flags = &.{ "-fno-exceptions", "-fno-rtti" }, + }); const install = b.addInstallFile(lib.getEmittedBin(), wasm_install_path); b.getInstallStep().dependOn(&install.step); diff --git a/services/ws-modules/zig-data1/src/main.zig b/services/ws-modules/zig-data1/src/main.zig index 2a67c97f..a694d3c6 100644 --- a/services/ws-modules/zig-data1/src/main.zig +++ b/services/ws-modules/zig-data1/src/main.zig @@ -20,6 +20,9 @@ extern fn js_get_iso_timestamp(buf: [*]u8, max: usize) usize; // Declared in src/util.c extern fn byte_sum(buf: [*]const u8, len: usize) u8; +// Declared in src/util.cpp +extern fn fnv1a_hash(buf: [*]const u8, len: usize) u32; + // Bumped from 64K because the REST client allocates a 64K response buffer // per request and the workflow runs several round-trips before completing. var heap: [256 * 1024]u8 = undefined; @@ -96,6 +99,9 @@ export fn run() i32 { const cksum = byte_sum(content.ptr, content.len); log("content checksum (byte_sum from C): {d}", .{cksum}); + const hash = fnv1a_hash(content.ptr, content.len); + log("content hash (fnv1a_hash from C++): {x:0>8}", .{hash}); + // The REST client targets the same origin we were served from, so an // empty base_url leaves it with relative paths like `/storage/{id}/{f}` // -- the browser resolves those against the page origin via fetch(). diff --git a/services/ws-modules/zig-data1/src/util.cpp b/services/ws-modules/zig-data1/src/util.cpp new file mode 100644 index 00000000..d7ce8126 --- /dev/null +++ b/services/ws-modules/zig-data1/src/util.cpp @@ -0,0 +1,32 @@ +// C++ counterpart to util.c, compiled by the same `zig build` via clang's C++ mode. +// The wasm32-freestanding target has no libc++ (and `zig build` links none), so this file is freestanding C++: +// compiler-provided C headers only, no exceptions/RTTI (build.zig passes -fno-exceptions -fno-rtti), no operator +// new, and no non-trivial static initializers. Language-level features -- templates, constexpr, namespaces -- +// all work. +#include +#include + +namespace { + +// FNV-1a parameters, specialized per hash width. +template struct Fnv1aParams; + +template <> struct Fnv1aParams { + static constexpr uint32_t offset_basis = 0x811c9dc5U; + static constexpr uint32_t prime = 0x01000193U; +}; + +// Returns the FNV-1a hash of buf. +template constexpr T fnv1a(const uint8_t *buf, size_t len) { + T acc = Fnv1aParams::offset_basis; + for (size_t i = 0; i < len; i++) { + acc = (acc ^ buf[i]) * Fnv1aParams::prime; + } + return acc; +} + +static_assert(fnv1a(nullptr, 0) == 0x811c9dc5U, "empty input must hash to the offset basis"); + +} // namespace + +extern "C" uint32_t fnv1a_hash(const uint8_t *buf, size_t len) { return fnv1a(buf, len); } diff --git a/services/ws-modules/zig-except1/build.zig b/services/ws-modules/zig-except1/build.zig new file mode 100644 index 00000000..cb077cd1 --- /dev/null +++ b/services/ws-modules/zig-except1/build.zig @@ -0,0 +1,61 @@ +const std = @import("std"); +const zon = @import("build.zig.zon"); + +const npm_name = blk: { + const s = @tagName(zon.name); + var buf: [s.len]u8 = s[0..s.len].*; + for (&buf) |*c| if (c.* == '_') { + c.* = '-'; + }; + break :blk buf; +}; + +const name = @tagName(zon.name); +const wasm_install_path = "../pkg/" ++ name ++ ".wasm"; + +pub fn build(b: *std.Build) void { + // exception_handling is added to zig's baseline wasm CPU because -mcpu baseline is appended after any + // per-file cflags, so a plain -mexception-handling cflag on exceptions.cpp would be stripped again and + // its __builtin_wasm_throw would fail with "needs target feature exception-handling". Zig itself emits + // no EH instructions; only the exception-enabled C++ TU uses the feature. + const target = b.resolveTargetQuery(.{ + .cpu_arch = .wasm32, + .cpu_features_add = std.Target.wasm.featureSet(&.{.exception_handling}), + .os_tag = .freestanding, + }); + const optimize = b.standardOptimizeOption(.{}); + + const root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }); + + const lib = b.addExecutable(.{ + .name = name, + .root_module = root_module, + }); + lib.entry = .disabled; + lib.rdynamic = true; + // Exception-enabled C++ TU: real wasm exception-handling instructions plus the minimal runtime defined in + // the file itself. See src/exceptions.cpp for the model and its catch (...)-only constraint. + root_module.addCSourceFile(.{ + .file = b.path("src/exceptions.cpp"), + .flags = &.{ "-fwasm-exceptions", "-mexception-handling", "-fno-rtti" }, + }); + + const install = b.addInstallFile(lib.getEmittedBin(), wasm_install_path); + b.getInstallStep().dependOn(&install.step); + + const pkg_json = std.json.Stringify.valueAlloc(b.allocator, .{ + .name = &npm_name, + .type = "module", + .description = zon.description, + .version = zon.version, + .license = zon.license, + .main = zon.main, + }, .{ .whitespace = .indent_2 }) catch unreachable; + const wf = b.addWriteFile("package.json", pkg_json); + const install_pkg_json = b.addInstallFile(wf.getDirectory().path(b, "package.json"), "../pkg/package.json"); + b.getInstallStep().dependOn(&install_pkg_json.step); +} diff --git a/services/ws-modules/zig-except1/build.zig.zon b/services/ws-modules/zig-except1/build.zig.zon new file mode 100644 index 00000000..4b3805ea --- /dev/null +++ b/services/ws-modules/zig-except1/build.zig.zon @@ -0,0 +1,9 @@ +.{ + .name = .et_ws_zig_except1, + .version = "0.1.0", + .description = "zig exception handling demo 1", + .license = "Apache-2.0 or MIT", + .main = "et_ws_zig_except1.js", + .fingerprint = 0x7e0344ac53a4bcec, + .paths = .{""}, +} diff --git a/services/ws-modules/zig-except1/pkg/.gitignore b/services/ws-modules/zig-except1/pkg/.gitignore new file mode 100644 index 00000000..b67918f1 --- /dev/null +++ b/services/ws-modules/zig-except1/pkg/.gitignore @@ -0,0 +1,2 @@ +*.wasm +package.json diff --git a/services/ws-modules/zig-except1/pkg/et_ws_zig_except1.js b/services/ws-modules/zig-except1/pkg/et_ws_zig_except1.js new file mode 100644 index 00000000..ee559f7b --- /dev/null +++ b/services/ws-modules/zig-except1/pkg/et_ws_zig_except1.js @@ -0,0 +1,132 @@ +// et_ws_zig_except1.js — zig-except1 WASM module +// Runs WASM in a Web Worker; main thread proxies WebSocket calls via +// SharedArrayBuffer. Shared memory layout (Int32 offsets): +// [0] signal: 0=idle, 1=request-pending +// [1] request type: 0=sleep, 1=ws_connect, 2=ws_get_state, 3=ws_get_agent_id, +// 6=ws_disconnect, 7=log, 8=set_status, 9=get_ws_url +// [2] payload length (also response length) +// [3] aux length (unused by this module; kept for shim parity with zig-data1) +// Data area starts at byte offset 16. + +export default async function init() {} + +export async function run() { + const DATA_OFFSET = 16; + const sab = new SharedArrayBuffer(64 * 1024); + const ctrl = new Int32Array(sab, 0, 4); + const data = new Uint8Array(sab, DATA_OFFSET); + const enc = new TextEncoder(); + const dec = new TextDecoder(); + + const workerUrl = new URL("et_ws_zig_except1_worker.js", import.meta.url).href; + + const respond = (str = "") => { + if (str) { + const b = enc.encode(str); + data.set(b); + Atomics.store(ctrl, 2, b.length); + } else Atomics.store(ctrl, 2, 0); + Atomics.store(ctrl, 0, 0); + Atomics.notify(ctrl, 0); + }; + + return new Promise((resolve, reject) => { + let ws = null, + wsState = "disconnected", + agentId = ""; + + const poll = () => { + if (Atomics.load(ctrl, 0) !== 1) { + setTimeout(poll, 0); + return; + } + + const type = Atomics.load(ctrl, 1); + const plen = Atomics.load(ctrl, 2); + const payload = dec.decode(Uint8Array.from(data.subarray(0, plen))); + + switch (type) { + case 0: + setTimeout( + () => { + respond(); + poll(); + }, + parseInt(payload) || 0, + ); + return; + case 1: + ws = new WebSocket(payload); + wsState = "connecting"; + ws.onopen = () => { + wsState = "connected"; + ws.send(JSON.stringify({ type: "et-connect" })); + }; + ws.onmessage = (e) => { + try { + const msg = JSON.parse(e.data); + if (msg.type === "et-connect-ack" && msg.agent_id) agentId = msg.agent_id; + } catch {} + }; + ws.onclose = ws.onerror = () => { + wsState = "disconnected"; + }; + respond(); + break; + case 2: + respond(wsState); + break; + case 3: + respond(agentId); + break; + case 6: + ws?.close(); + wsState = "disconnected"; + respond(); + break; + case 7: + console.log(payload); + appendOutput(payload); + respond(); + break; + case 8: + appendOutput(payload); + respond(); + break; + case 9: { + const p = location.protocol === "https:" ? "wss:" : "ws:"; + respond(`${p}//${location.host}/ws`); + break; + } + default: + respond(); + break; + } + setTimeout(poll, 0); + }; + + const worker = new Worker(workerUrl, { type: "module" }); + worker.onmessage = (e) => { + if (e.data.done) { + worker.terminate(); + if (e.data.ret === 0) { + resolve(); + } else { + reject(new Error("zig-except1: run() returned " + e.data.ret)); + } + } + }; + worker.onerror = (e) => { + worker.terminate(); + reject(e); + }; + // The worker resolves its own wasm URL from import.meta.url; only the shared buffer crosses the boundary. + worker.postMessage({ sab }); + poll(); + }); +} + +function appendOutput(msg) { + const el = document.getElementById("module-output"); + if (el) el.value = (el.value ? el.value + "\n" : "") + msg; +} diff --git a/services/ws-modules/zig-except1/pkg/et_ws_zig_except1_worker.js b/services/ws-modules/zig-except1/pkg/et_ws_zig_except1_worker.js new file mode 100644 index 00000000..7e425c4a --- /dev/null +++ b/services/ws-modules/zig-except1/pkg/et_ws_zig_except1_worker.js @@ -0,0 +1,56 @@ +// et_ws_zig_except1_worker.js — Web Worker for zig-except1 WASM module +const DATA_OFFSET = 16; +let ctrl, data, wasmMemory; +const enc = new TextEncoder(), + dec = new TextDecoder(); +const readStr = (ptr, len) => dec.decode(new Uint8Array(wasmMemory.buffer, ptr, len)); + +// String payload, response is a UTF-8 string. +function call(type, payload = "") { + const pb = enc.encode(payload); + data.set(pb); + Atomics.store(ctrl, 3, 0); + Atomics.store(ctrl, 2, pb.length); + Atomics.store(ctrl, 1, type); + Atomics.store(ctrl, 0, 1); + Atomics.notify(ctrl, 0); + Atomics.wait(ctrl, 0, 1); // block until main thread responds + const rlen = Atomics.load(ctrl, 2); + return dec.decode(Uint8Array.from(data.subarray(0, rlen))); +} + +const writeBack = (r, buf, max) => { + const b = enc.encode(r); + const n = Math.min(b.length, max); + new Uint8Array(wasmMemory.buffer, buf, n).set(b.subarray(0, n)); + return n; +}; + +const imports = { + env: { + js_log: (p, l) => call(7, readStr(p, l)), + js_set_status: (p, l) => call(8, readStr(p, l)), + js_ws_connect: (p, l) => call(1, readStr(p, l)), + js_ws_disconnect: () => call(6), + js_ws_get_state: (buf, max) => writeBack(call(2), buf, max), + js_ws_get_agent_id: (buf, max) => writeBack(call(3), buf, max), + js_sleep_ms: (ms) => call(0, String(ms)), + js_get_ws_url: (buf, max) => writeBack(call(9), buf, max), + }, +}; + +self.onmessage = async (e) => { + // Dedicated worker: messages only originate from the same-origin context that created it. Reject any + // cross-origin message defensively (the browser already guarantees this, but make the check explicit). + if (e.origin && e.origin !== self.location.origin) return; + const { sab } = e.data; + ctrl = new Int32Array(sab, 0, 4); + data = new Uint8Array(sab, DATA_OFFSET); + // Resolve the module wasm from this worker's own location (self.location), never from a postMessage value, + // so the fetch URL cannot depend on message data. The wasm is a fixed-name sibling of this worker script. + const wasmUrl = new URL("et_ws_zig_except1.wasm", self.location.href); + const { instance } = await WebAssembly.instantiateStreaming(fetch(wasmUrl), imports); + wasmMemory = instance.exports.memory; + const ret = instance.exports.run(); + self.postMessage({ done: true, ret }); +}; diff --git a/services/ws-modules/zig-except1/src/exceptions.cpp b/services/ws-modules/zig-except1/src/exceptions.cpp new file mode 100644 index 00000000..19dd6048 --- /dev/null +++ b/services/ws-modules/zig-except1/src/exceptions.cpp @@ -0,0 +1,116 @@ +// Demonstrates C++ exceptions in the .cpp layer of this Zig wasm module, plus the minimal runtime they need. +// Probed against zig's clang on wasm32-freestanding (run under V8 via node; V8 has native wasm +// exception-handling support), there are four exception models to choose from per translation unit: +// +// - `-fno-exceptions` (zig-data1's util.cpp model): `throw` / `try` are compile errors. The safe default for +// a TU that doesn't need exceptions -- misuse is diagnosed at compile time. +// - Default C++ flags (exceptions nominally on, but no wasm EH): a `throw` still lowers to `__cxa_throw`, but +// wasm has no unwinder here, so clang discards every `catch` as dead code. A throw can then never be caught +// -- code that LOOKS exception-correct silently is not. Never ship this model. +// - `-fwasm-exceptions -mexception-handling` (this TU's model): real wasm exception-handling instructions. +// `catch (...)` compiles to a plain wasm catch of the C++ tag and needs only the four Itanium ABI entry +// points and the referenced typeinfo symbol, all defined below. Throw and catch then genuinely work: a +// throw caught in C++ stays in C++, and a throw nothing catches surfaces to the JS host as a catchable +// `WebAssembly.Exception` (both probed under V8). +// - Typed catches (e.g. `catch (int32_t)`) additionally require LSDA type matching -- the libc++abi +// personality routine plus libunwind's wasm context, neither of which exists on wasm32-freestanding. +// Linking a typed catch against this runtime fails with, verbatim: +// wasm-ld: error: exc.o: undefined symbol: __wasm_lpad_context +// wasm-ld: error: exc.o: undefined symbol: _Unwind_CallPersonality +// +// House rules that follow: only `catch (...)` is available; an exception must never unwind through Zig frames +// (Zig has no C++ cleanup semantics and the exception would escape as a raw `WebAssembly.Exception`), so every +// extern "C" entry point in an exception-enabled TU catches everything it can throw and translates the failure +// to a status code at the boundary, as try_divide() does at the bottom of this file. +#include +#include + +namespace { + +// One static in-flight exception slot: no nested or rethrown exceptions, which the demo never needs. +// The mutable-global suppressions below match the mingw-shim's ABI globals: the Itanium ABI hands the runtime +// ownership of in-flight exception state, so it cannot be const and cannot live on a stack frame. +// skipcq: CXX-W2009 -- in-flight exception state owned by the runtime +// skipcq: CXX-W2066 -- freestanding wasm32 has no libc++, so std::array/ is unavailable; fixed ABI slot +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +alignas(16) unsigned char exception_slot[64]; // flawfinder: ignore -- __cxa_allocate_exception bounds-checks size +// Destructor of the in-flight exception object, captured at throw time and run by __cxa_end_catch. +// skipcq: CXX-W2009 -- in-flight exception state owned by the runtime +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +void (*pending_dtor)(void *) = nullptr; + +} // namespace + +// Minimal Itanium C++ ABI exception runtime. +// clang lowers `throw x` to __cxa_allocate_exception + construct + __cxa_throw, and a landed catch handler to +// __cxa_begin_catch / __cxa_end_catch; these definitions are the whole runtime a `catch (...)`-only TU links +// against. The double-underscore names are mandated by the ABI (they must match what clang emits), so the +// reserved-identifier checks are suppressed per symbol, as on the mingw-shim's CRT symbols. +extern "C" { + +// Returns storage for a to-be-thrown exception object of `size` bytes, or null when it cannot fit. +// The generated throw site does not null-check, so an oversized payload traps -- acceptable for this demo. +// NOLINTNEXTLINE(bugprone-reserved-identifier, cert-dcl37-c, cert-dcl51-cpp) +void *__cxa_allocate_exception(size_t size) { + return size <= sizeof(exception_slot) ? static_cast(exception_slot) : nullptr; +} + +// Records the payload destructor, then throws the wasm C++ exception tag (tag 0) carrying the payload pointer. +// The typeinfo is ignored: with `catch (...)` only, no type matching ever happens. The (thrown, tinfo) pair is +// the ABI's parameter order, so the swap-risk warning does not apply. +// NOLINTNEXTLINE(bugprone-reserved-identifier, cert-dcl37-c, cert-dcl51-cpp, bugprone-easily-swappable-parameters) +void __cxa_throw(void *thrown, void *tinfo, void (*dtor)(void *)) { + (void)tinfo; + pending_dtor = dtor; + __builtin_wasm_throw(0, thrown); +} + +// Returns the payload pointer the catch handler adjusts from. +// This runtime throws the payload pointer itself, so it passes through unchanged. +// NOLINTNEXTLINE(bugprone-reserved-identifier, cert-dcl37-c, cert-dcl51-cpp) +void *__cxa_begin_catch(void *thrown) { return thrown; } + +// Destroys the caught exception object, ending its lifetime in the static slot. +// NOLINTNEXTLINE(bugprone-reserved-identifier, cert-dcl37-c, cert-dcl51-cpp) +void __cxa_end_catch(void) { + if (pending_dtor != nullptr) { + pending_dtor(exception_slot); + pending_dtor = nullptr; + } +} + +// Address-only stand-in for libc++abi's `typeinfo for int`. +// Every `throw ` site references _ZTIi, but this runtime never dereferences it, so a dummy with the +// right symbol name satisfies the linker. +struct MinimalTypeInfo { + const void *vtable; + const char *name; +}; +// NOLINTNEXTLINE(bugprone-reserved-identifier, cert-dcl37-c, cert-dcl51-cpp) +extern const MinimalTypeInfo int_type_info asm("_ZTIi"); +const MinimalTypeInfo int_type_info = {nullptr, "i"}; + +} // extern "C" + +namespace { + +// Throwing callee: the int payload is trivially destructible, so __cxa_throw records a null destructor. +int32_t checked_divide(int32_t num, int32_t den) { + if (den == 0) { + throw den; + } + return num / den; +} + +} // namespace + +// Exception-safe boundary: returns the quotient, or -1 when checked_divide() throws. +// The catch-all is the house rule above -- nothing may unwind past an extern "C" entry point into the Zig +// caller. +extern "C" int32_t try_divide(int32_t num, int32_t den) { + try { + return checked_divide(num, den); + } catch (...) { + return -1; + } +} diff --git a/services/ws-modules/zig-except1/src/main.zig b/services/ws-modules/zig-except1/src/main.zig new file mode 100644 index 00000000..11d6880b --- /dev/null +++ b/services/ws-modules/zig-except1/src/main.zig @@ -0,0 +1,106 @@ +// zig-except1: demonstrates exception handling across the C++ layer of a Zig wasm module. +// The C++ TU (src/exceptions.cpp) documents the exception models available on wasm32-freestanding and +// carries the minimal runtime its `catch (...)`-only model needs. This Zig side only ever sees status +// codes: no exception may unwind through Zig frames, so every C++ entry point catches what it throws. +// All browser I/O is provided by JS imports, mirroring zig-data1's worker shim (minus its REST path). + +const std = @import("std"); + +extern fn js_log(ptr: [*]const u8, len: usize) void; +extern fn js_set_status(ptr: [*]const u8, len: usize) void; +extern fn js_ws_connect(url_ptr: [*]const u8, url_len: usize) void; +extern fn js_ws_disconnect() void; +extern fn js_ws_get_state(buf: [*]u8, max: usize) usize; +extern fn js_ws_get_agent_id(buf: [*]u8, max: usize) usize; +extern fn js_sleep_ms(ms: u32) void; +extern fn js_get_ws_url(buf: [*]u8, max: usize) usize; + +// Declared in src/exceptions.cpp; returns the quotient, or -1 when the divide throws (caught in C++). +extern fn try_divide(num: i32, den: i32) i32; + +var heap: [16 * 1024]u8 = undefined; +var fba = std.heap.FixedBufferAllocator.init(&heap); +const alloc = fba.allocator(); + +fn log(comptime fmt: []const u8, args: anytype) void { + const msg = std.fmt.allocPrint(alloc, "[zig-except1] " ++ fmt, args) catch return; + defer alloc.free(msg); + js_log(msg.ptr, msg.len); +} + +fn set_status(comptime fmt: []const u8, args: anytype) void { + const msg = std.fmt.allocPrint(alloc, fmt, args) catch return; + defer alloc.free(msg); + js_set_status(msg.ptr, msg.len); +} + +fn wait_state(want: []const u8) bool { + var buf: [32]u8 = undefined; + var i: u32 = 0; + while (i < 100) : (i += 1) { + const n = js_ws_get_state(&buf, buf.len); + if (std.mem.eql(u8, buf[0..n], want)) return true; + js_sleep_ms(100); + } + return false; +} + +fn wait_agent_id(buf: []u8) usize { + var i: u32 = 0; + while (i < 100) : (i += 1) { + const n = js_ws_get_agent_id(buf.ptr, buf.len); + if (n > 0) return n; + js_sleep_ms(100); + } + return 0; +} + +export fn run() i32 { + var url_buf: [256]u8 = undefined; + const url_len = js_get_ws_url(&url_buf, url_buf.len); + const ws_url = url_buf[0..url_len]; + + log("entered run()", .{}); + set_status("zig-except1: entered run()", .{}); + + js_ws_connect(ws_url.ptr, ws_url.len); + + if (!wait_state("connected")) { + log("timed out waiting for connection", .{}); + return -1; + } + + var agent_buf: [128]u8 = undefined; + const agent_len = wait_agent_id(&agent_buf); + if (agent_len == 0) { + log("timed out waiting for agent_id", .{}); + return -1; + } + const agent_id = agent_buf[0..agent_len]; + log("connected as {s}", .{agent_id}); + set_status("zig-except1: connected as {s}", .{agent_id}); + + const quotient = try_divide(84, 4); + log("try_divide(84, 4) = {d} (no throw)", .{quotient}); + set_status("zig-except1: try_divide(84, 4) = {d} (no throw)", .{quotient}); + + const caught = try_divide(1, 0); + log("try_divide(1, 0) = {d} (throw caught in C++)", .{caught}); + set_status("zig-except1: try_divide(1, 0) = {d} (throw caught in C++)", .{caught}); + + if (quotient == 21 and caught == -1) { + log("VERIFICATION SUCCESS - C++ throw/catch behaved as expected!", .{}); + set_status("zig-except1: VERIFICATION SUCCESS - C++ throw/catch behaved as expected!", .{}); + } else { + log("VERIFICATION FAILURE - unexpected results!", .{}); + set_status("zig-except1: VERIFICATION FAILURE - unexpected results!", .{}); + js_ws_disconnect(); + return -1; + } + + js_sleep_ms(2000); + js_ws_disconnect(); + log("workflow complete", .{}); + set_status("zig-except1: workflow complete", .{}); + return 0; +} diff --git a/services/ws-web-runner/tests/modules.rs b/services/ws-web-runner/tests/modules.rs index 883b81ea..d415bb32 100644 --- a/services/ws-web-runner/tests/modules.rs +++ b/services/ws-web-runner/tests/modules.rs @@ -61,11 +61,13 @@ use rstest::rstest; #[rstest] #[case::data1("et-ws-data1", Language::Rust)] +#[case::except1("et-ws-except1", Language::Rust)] #[case::pydata1("et-ws-pydata1", Language::Python)] #[case::graphics_info("et-ws-graphics-info", Language::Rust)] #[case::dotnet_data1("et-ws-dotnet-data1", Language::Dotnet)] #[case::java_data1("et-ws-java-data1", Language::Java)] #[case::zig_data1("et-ws-zig-data1", Language::Zig)] +#[case::zig_except1("et-ws-zig-except1", Language::Zig)] #[case::dart_data1("et-ws-dart-data1", Language::Dart)] #[case::pywasm1("et-ws-pywasm1", Language::Python)] fn module_runs_successfully(#[case] module: &str, #[case] language: Language) { From 78e019a794cb386da17fe6d0a9f89e83e949ea04 Mon Sep 17 00:00:00 2001 From: John Vandenberg Date: Tue, 21 Jul 2026 14:33:23 +0800 Subject: [PATCH 2/2] fix upstream cache slipup --- .github/workflows/upstream-cache.yaml | 28 +++++++++++++++++++++++---- .mise/config.toml | 2 +- config/upstream-cache/data.toml | 2 +- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/.github/workflows/upstream-cache.yaml b/.github/workflows/upstream-cache.yaml index bccc6ded..dba65712 100644 --- a/.github/workflows/upstream-cache.yaml +++ b/.github/workflows/upstream-cache.yaml @@ -109,7 +109,12 @@ jobs: native_asset="rustpython-${short}-${triple}.tar.gz" wasm_asset="rustpython-wasm-${short}.tar.gz" repo=edge-toolkit/core - assets=$(gh release view rp-v1 --repo "$repo" --json assets --jq '.assets[].name' 2>/dev/null || true) + # Fail closed: on a failed asset query, abort instead of rebuilding. + # A transient API error must not look like "asset missing" and clobber the pinned asset (new hash). + if ! assets=$(gh release view rp-v1 --repo "$repo" --json assets --jq '.assets[].name'); then + echo "::error::could not query rp-v1 assets; refusing to rebuild (transient API failure?)" >&2 + exit 1 + fi native_missing=true; wasm_missing=true if echo "$assets" | grep -Fxq "$native_asset"; then native_missing=false; fi if echo "$assets" | grep -Fxq "$wasm_asset"; then wasm_missing=false; fi @@ -264,7 +269,12 @@ jobs: asset="${AUG_VERSION}-${MATRIX_TRIPLE}.${MATRIX_EXT}" repo=edge-toolkit/core jq='.assets[].name' - assets=$(gh release view "$AUG_RELEASE_TAG" --repo "$repo" --json assets --jq "$jq" 2>/dev/null || true) + # Fail closed: on a failed asset query, abort instead of rebuilding. + # A transient API error must not look like "asset missing" and clobber the pinned asset (new hash). + if ! assets=$(gh release view "$AUG_RELEASE_TAG" --repo "$repo" --json assets --jq "$jq"); then + echo "::error::could not query $AUG_RELEASE_TAG assets; refusing to rebuild (transient API failure?)" >&2 + exit 1 + fi if echo "$assets" | grep -Fxq "$asset"; then echo "::notice::$asset already in $AUG_RELEASE_TAG; skipping" echo "work=" >> "$GITHUB_OUTPUT" @@ -547,7 +557,12 @@ jobs: asset="${DTG_VERSION}-${triple}.tar.gz" repo=edge-toolkit/core jq='.assets[].name' - assets=$(gh release view "$DTG_RELEASE_TAG" --repo "$repo" --json assets --jq "$jq" 2>/dev/null || true) + # Fail closed: on a failed asset query, abort instead of rebuilding. + # A transient API error must not look like "asset missing" and clobber the pinned asset (new hash). + if ! assets=$(gh release view "$DTG_RELEASE_TAG" --repo "$repo" --json assets --jq "$jq"); then + echo "::error::could not query $DTG_RELEASE_TAG assets; refusing to rebuild (transient API failure?)" >&2 + exit 1 + fi if echo "$assets" | grep -Fxq "$asset"; then echo "::notice::$asset already in $DTG_RELEASE_TAG; skipping" echo "work=" >> "$GITHUB_OUTPUT" @@ -638,7 +653,12 @@ jobs: asset="${GNUPG_VERSION}_${GNUPG_BUILD_STAMP}-x86_64-pc-windows.tar.gz" repo=edge-toolkit/core jq='.assets[].name' - assets=$(gh release view "$GNUPG_RELEASE_TAG" --repo "$repo" --json assets --jq "$jq" 2>/dev/null || true) + # Fail closed: on a failed asset query, abort instead of rebuilding. + # A transient API error must not look like "asset missing" and clobber the pinned asset (new hash). + if ! assets=$(gh release view "$GNUPG_RELEASE_TAG" --repo "$repo" --json assets --jq "$jq"); then + echo "::error::could not query $GNUPG_RELEASE_TAG assets; refusing to rebuild (transient API failure?)" >&2 + exit 1 + fi if echo "$assets" | grep -Fxq "$asset"; then echo "::notice::$asset already in $GNUPG_RELEASE_TAG; skipping" echo "work=" >> "$GITHUB_OUTPUT" diff --git a/.mise/config.toml b/.mise/config.toml index cf92df0d..9d9f0192 100644 --- a/.mise/config.toml +++ b/.mise/config.toml @@ -266,7 +266,7 @@ version = "1.14.1" checksum = "sha256:3303e8022294b04fe4ed567b0d88c10c1c7d5b51e443485251a6d4a606b1433a" url = "https://github.com/edge-toolkit/core/releases/download/augeas-v1/1.14.1-aarch64-unknown-linux-gnu.tar.xz" [tools."http:augeas".platforms.linux-x64] -checksum = "sha256:fc6345fb8ffb84e1f57f9144aacc059aa13687f6912ffe26f90713c771892ec6" +checksum = "sha256:76eb80c33355abe9f6ea01ac90a47a70d5917d940a0a843aed9d0b61feed7714" url = "https://github.com/edge-toolkit/core/releases/download/augeas-v1/1.14.1-x86_64-unknown-linux-gnu.tar.xz" [tools."http:augeas".platforms.macos-arm64] checksum = "sha256:10fc4aff94a5c6016c51b4dbbc224277a340a083c315ca0a05e9f5ef9a869403" diff --git a/config/upstream-cache/data.toml b/config/upstream-cache/data.toml index 0076ecd5..1f039e21 100644 --- a/config/upstream-cache/data.toml +++ b/config/upstream-cache/data.toml @@ -57,7 +57,7 @@ url = "https://github.com/edge-toolkit/core/releases/download/augeas-v1/1.14.1-x # job runs and uploads each tarball. [asset."1.14.1-x86_64-unknown-linux-gnu.tar.xz"] license = "LGPL-2.1+" -sha256 = "fc6345fb8ffb84e1f57f9144aacc059aa13687f6912ffe26f90713c771892ec6" +sha256 = "76eb80c33355abe9f6ea01ac90a47a70d5917d940a0a843aed9d0b61feed7714" upstream = "https://github.com/hercules-team/augeas" url = "https://github.com/edge-toolkit/core/releases/download/augeas-v1/1.14.1-x86_64-unknown-linux-gnu.tar.xz"