From e94da7fd22e1ed9cc5da1f1de4374a70a8d82693 Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Tue, 29 Sep 2026 04:07:27 -0500 Subject: [PATCH] =?UTF-8?q?ariadne:=20generic=20async=20URI=20resolver=20?= =?UTF-8?q?=E2=80=94=20(fetch=20uri)=20/=20(fetch-async=20uri)=20(=C2=A713?= =?UTF-8?q?.8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a scheme-agnostic async resolver so an ari program can load ANY registered URI scheme (file/state/cvc/http) without blocking the scheduler thread — the DSL surface for "all ari-generated URI requests async". net_intrinsics.cpp: launch_uri_fetch runs the SYNCHRONOUS cvc::ariadne::resolve() on a compute-pool worker (OFF the scheduler thread) and posts the marshalled result to a unique '#'-reply channel — the same launch shape as launch_http_fetch, but for arbitrary URIs. Two verbs reuse the PR-A park/future machinery: (fetch-async URI [BASE]) -> a future handle (await it, or msg-recv it) (fetch URI [BASE]) -> TRANSPARENT: self-parks, returns the dict directly The reply dict is { ok body(bytes) url error } (scheme-agnostic: no HTTP status/headers). So (get-attr (fetch uri) "body") reads as one expression. The register gate is split so (fetch*) does NOT require an HTTP backend — it works over any registered scheme and is useful in a curl-less build — while (http-get*) still gate on cvc::net::have_http_backend(). resolve() is app-free and has its own try/catch barrier; the worker additionally wraps it and always posts a dict, so a waiter never hangs. The other ari-generated URI requests stay synchronous by design: import:/load:/ include: resolve at LOAD time (off the draw walk, where sync is legal), and scene source:{uri:} is a one-shot at scene setup (not per-frame) — so nothing ari-generated blocks the render thread today. A DSL program that wants async loading uses (fetch). An async scene source:{uri:} realize (so a large remote asset doesn't block scene setup) is a follow-up. Tests (ariadne_runtime_test AriadneNetIntrinsics, offline): (fetch) transparently resolves a canned custom scheme with the bytes body threaded in; (await (fetch-async …)) resolves the future. Full ariadne_runtime_test 106, 0/20 flaky. --- docs/roadmap/CVCGL-UI-DSL-ROADMAP.md | 21 ++++- src/cvc/ariadne/net_intrinsics.cpp | 109 +++++++++++++++++++++---- src/cvc/tests/ariadne_runtime_test.cpp | 70 ++++++++++++++++ 3 files changed, 181 insertions(+), 19 deletions(-) diff --git a/docs/roadmap/CVCGL-UI-DSL-ROADMAP.md b/docs/roadmap/CVCGL-UI-DSL-ROADMAP.md index de747e8b..0af2c135 100644 --- a/docs/roadmap/CVCGL-UI-DSL-ROADMAP.md +++ b/docs/roadmap/CVCGL-UI-DSL-ROADMAP.md @@ -3278,10 +3278,23 @@ render/resolver thread**. wasm: `emscripten_fetch` is async-only (no sync on the > **`(http-get url)`** is the transparent one-shot: it self-parks and yields the dict straight into the > enclosing expression — `(get-attr (http-get url) "status")`, no `msg-recv`. Tests: AriadneNetIntrinsics > gains the transparent-verb + await-future cases; the msg-recv/await refactor is regression-clean -> across the state_exec suites. **NOT YET:** chunked/streaming bodies; the dedicated libcurl-`multi` -> I/O thread (today each in-flight fetch blocks one compute-pool worker); making ALL ari-generated URI -> requests async (the resolver — separate PR); render-pass park budget (separate PR). NATIVE only until -> the wasm worker/`-sASYNCIFY` fetch path is confirmed. +> across the state_exec suites. +> +> **LANDED (generic async resolver) — `(fetch uri)` / `(fetch-async uri)`.** `net_intrinsics.cpp` +> registers a scheme-agnostic async resolver: it runs the synchronous `resolve()` on the compute pool +> (OFF the scheduler thread) and, like `http-get`, either self-parks (`fetch`) or returns a future +> (`fetch-async`), resuming with `{ ok body(bytes) url error }`. So an ari PROGRAM can async-load ANY +> registered scheme — `file`/`state`/`cvc`/`http` — via one primitive: `(get-attr (fetch uri) "body")`. +> It reuses the PR-A launch+park machinery and needs no HTTP backend (only `http-get*` do), so it works +> even in a curl-less build. Tests: AriadneNetIntrinsics fetch-transparent + fetch-async-future over a +> canned custom scheme, offline. **The other ari-generated URI requests stay synchronous by design:** +> `import:`/`load:`/`include:` resolve at LOAD time, off the draw walk (roadmap §13.8 — sync is legal +> there), and scene `source:{uri:}` is a one-shot at scene-setup (not per-frame). So nothing +> ari-generated blocks the render thread today; a DSL program that wants async loading uses `(fetch)`. +> **NOT YET:** chunked/streaming bodies; the dedicated libcurl-`multi` I/O thread (today each in-flight +> fetch blocks one compute-pool worker); an async scene `source:{uri:}` realize (so a large remote +> asset doesn't block scene setup — a follow-up); render-pass park budget (separate PR). NATIVE only +> until the wasm worker/`-sASYNCIFY` fetch path is confirmed. ### 13.9 A cvc::app-wide HTTP(s) cache in the state tree (TTL + conditional GET) — *planned* diff --git a/src/cvc/ariadne/net_intrinsics.cpp b/src/cvc/ariadne/net_intrinsics.cpp index 285b0521..294991e3 100644 --- a/src/cvc/ariadne/net_intrinsics.cpp +++ b/src/cvc/ariadne/net_intrinsics.cpp @@ -4,6 +4,7 @@ #include // register_action_intrinsics, have_state_exec #include +#include // resolve() — the generic (fetch uri) async resolver #include #include @@ -92,37 +93,115 @@ std::string launch_http_fetch(cvc::app &app, const std::string &root, return done; } +// Marshal a resolver UriResult into the DSL reply dict (scheme-agnostic — no HTTP status/headers). +// The body is `bytes` (opaque octets), consistent with (http-get)'s body. +se::value_t marshal_uri_result(const UriResult &r) { + return se::make_dict({ + {"ok", se::value_t(r.ok)}, + {"body", se::make_bytes(r.content)}, + {"url", se::value_t(r.canonical)}, + {"error", se::value_t(r.error)}, + }); +} + +// The generic async resolve (any registered scheme: file/state/cvc/http). Runs the SYNCHRONOUS +// resolve() on a compute-pool worker (OFF the scheduler thread) and posts the marshalled dict to a +// unique '#'-reply channel — the same launch shape as launch_http_fetch, for arbitrary URIs. +std::string launch_uri_fetch(cvc::app &app, const std::string &root, + std::span args) { + std::string uri; + if (!args.empty()) + if (const std::string *u = std::get_if(&args[0].v)) + uri = *u; + std::string base; // optional second arg: the base for a relative URI + if (args.size() > 1) + if (const std::string *b = std::get_if(&args[1].v)) + base = *b; + + static std::atomic seq{0}; + const std::string chan = + "uri.reply#" + std::to_string(seq.fetch_add(1, std::memory_order_relaxed)); + const std::string done = se::resolve_channel_key(root, chan); + app.compute_async( + 1, [](int) {}, + [&app, uri, base, done] { + se::value_t payload; + try { + payload = + marshal_uri_result(resolve(uri, base)); // resolve() is app-free + has its own barrier + } catch (const std::exception &e) { + UriResult er; + er.ok = false; + er.canonical = uri; + er.error = std::string("fetch: ") + e.what(); + payload = marshal_uri_result(er); + } catch (...) { + UriResult er; + er.ok = false; + er.canonical = uri; + er.error = "fetch: unknown error"; + payload = marshal_uri_result(er); + } + app.exec_scheduler().post_message(done, payload); + }); + return done; +} + } // namespace void register_net_intrinsics(cvc::app &app) { - if (!have_state_exec() || !cvc::net::have_http_backend()) + if (!have_state_exec()) return; // Warm the lazy per-app singletons on THIS thread before any background worker touches them, so a // worker never races their first construction (the nav_compute discipline). app.computePool(); app.exec_scheduler(); + // (http-get*) need a compiled cvc::net backend; (fetch*) resolve over any registered scheme + // (file/state/cvc/http) and are useful with or without an HTTP backend. + const bool have_http = cvc::net::have_http_backend(); - register_action_intrinsics([&app](std::shared_ptr env, - se::intrinsics_context &ictx) { + register_action_intrinsics([&app, have_http](std::shared_ptr env, + se::intrinsics_context &ictx) { // Capture the lane's chroot so the reply channel is scoped the same way an (msg-recv)/(await) - // resolves it (launch_http_fetch uses a UNIQUE '#'-suffixed channel per call — policy-exempt + + // resolves it (the launchers use a UNIQUE '#'-suffixed channel per call — policy-exempt + // identity — so two in-flight fetches never collide on the single recv_path a process has). const std::string root = ictx.root_path; - // (http-get-async URL [HEADERS]) — kicks the fetch and returns a FUTURE handle; the program - // awaits it with (await …) or (msg-recv …). The low-level primitive for fanning out N fetches. - se::builtins::register_fn(env, "http-get-async", + if (have_http) { + // (http-get-async URL [HEADERS]) — kicks the fetch and returns a FUTURE handle; the program + // awaits it with (await …) or (msg-recv …). The low-level primitive for fanning out N + // fetches. + se::builtins::register_fn(env, "http-get-async", + [&app, root](std::span args) -> se::value_t { + return se::make_future(launch_http_fetch(app, root, args)); + }); + + // (http-get URL [HEADERS]) — TRANSPARENT: kicks the fetch and SELF-PARKS the calling process, + // resuming with the response dict threaded straight into the enclosing expression (no + // channel, no msg-recv). Reaches the scheduler via the captured app; + // current_pid()/current_process() are valid here (set around the evaluator step). Equivalent + // to (await (http-get-async URL)). + se::builtins::register_fn(env, "http-get", + [&app, root](std::span args) -> se::value_t { + const std::string done = launch_http_fetch(app, root, args); + auto &sched = app.exec_scheduler(); + return se::park_on_channel(&sched, sched.current_process().get(), + sched.current_pid(), done); + }); + } + + // (fetch-async URI [BASE]) / (fetch URI [BASE]) — the GENERIC async resolver over ANY + // registered scheme (file/state/cvc/http). fetch-async returns a future; fetch is transparent + // (self-parks, returns the dict). resolve() runs on the compute pool, so an ari program + // async-loads any URI without blocking the scheduler thread. Reply dict: { ok body(bytes) url + // error }. + se::builtins::register_fn(env, "fetch-async", [&app, root](std::span args) -> se::value_t { - return se::make_future(launch_http_fetch(app, root, args)); + return se::make_future(launch_uri_fetch(app, root, args)); }); - - // (http-get URL [HEADERS]) — TRANSPARENT: kicks the fetch and SELF-PARKS the calling process, - // resuming with the response dict threaded straight into the enclosing expression (no channel, - // no msg-recv). Reaches the scheduler via the captured app; current_pid()/current_process() are - // valid here (set around the evaluator step). Equivalent to (await (http-get-async URL)). - se::builtins::register_fn(env, "http-get", + se::builtins::register_fn(env, "fetch", [&app, root](std::span args) -> se::value_t { - const std::string done = launch_http_fetch(app, root, args); + const std::string done = launch_uri_fetch(app, root, args); auto &sched = app.exec_scheduler(); return se::park_on_channel(&sched, sched.current_process().get(), sched.current_pid(), done); diff --git a/src/cvc/tests/ariadne_runtime_test.cpp b/src/cvc/tests/ariadne_runtime_test.cpp index 579bb1e7..692780be 100644 --- a/src/cvc/tests/ariadne_runtime_test.cpp +++ b/src/cvc/tests/ariadne_runtime_test.cpp @@ -12,6 +12,7 @@ #include #include // §12 end-to-end load: mount through the real Runtime #include // §13.8 async (http-get-async) intrinsic test +#include // §13.8 (fetch uri): register a custom scheme handler #include #include #include @@ -2390,3 +2391,72 @@ TEST(AriadneNetIntrinsics, AwaitResolvesHttpGetAsyncFuture) { EXPECT_EQ(cvc::state::instance(app)("r.status").value(), "200"); EXPECT_EQ(fake->calls.load(), 1); } + +// PR-B: the GENERIC async resolver — (fetch uri) resolves ANY registered scheme off-thread and +// self-parks, returning { ok body(bytes) url error }. No HTTP backend needed (a custom scheme +// here). +TEST(AriadneNetIntrinsics, FetchTransparentResolvesAnyScheme) { + if (!have_state_exec()) + GTEST_SKIP() << "libcvc built without state_exec"; + cvc::app app; + struct Teardown { + ~Teardown() { + clear_action_intrinsics(); + unregister_uri_handler("mem"); + } + } td; + // A canned custom scheme; the handler runs on the compute-pool worker, so it must be thread-safe + // (a pure lambda returning a fixed UriResult is). + register_uri_handler("mem", [](const Uri &u, const std::string &) { + return UriResult{true, "hello", u.raw, std::string()}; + }); + register_net_intrinsics(app); + + Runtime rt(app, ""); + MockBackend mb; + rt.set_backend(&mb); + rt.set_root(group({button("Go", "(begin" + " (set r (fetch \"mem://x\"))" + " (state-set \"r.ok\" (get-attr r \"ok\"))" + " (state-data-set \"r.body\" (get-attr r \"body\")))")})); + mb.button_click = true; + rt.render(); + rt.drain(); + mb.button_click = false; + + ASSERT_TRUE(pump_until(rt, [&] { return !cvc::state::instance(app)("r.ok").value().empty(); })) + << "(fetch …) never resolved"; + EXPECT_EQ(cvc::state::instance(app)("r.ok").value(), "true"); + EXPECT_EQ(node_data_string(app, "r.body"), "hello"); // resolved bytes threaded in transparently +} + +// PR-B: (await (fetch-async uri)) resolves the generic resolver's future. +TEST(AriadneNetIntrinsics, FetchAsyncFutureAwaited) { + if (!have_state_exec()) + GTEST_SKIP() << "libcvc built without state_exec"; + cvc::app app; + struct Teardown { + ~Teardown() { + clear_action_intrinsics(); + unregister_uri_handler("mem"); + } + } td; + register_uri_handler("mem", [](const Uri &u, const std::string &) { + return UriResult{true, "world", u.raw, std::string()}; + }); + register_net_intrinsics(app); + + Runtime rt(app, ""); + MockBackend mb; + rt.set_backend(&mb); + rt.set_root(group({button("Go", "(state-data-set \"r.body\" " + "(get-attr (await (fetch-async \"mem://y\")) \"body\"))")})); + mb.button_click = true; + rt.render(); + rt.drain(); + mb.button_click = false; + + ASSERT_TRUE(pump_until(rt, [&] { return !node_data_string(app, "r.body").empty(); })) + << "(await (fetch-async …)) never resolved"; + EXPECT_EQ(node_data_string(app, "r.body"), "world"); +}