From 65a88617ecd56341a2176fda6b023d8578f5c582 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Fri, 31 Jul 2026 14:47:02 +0200 Subject: [PATCH 01/13] c-api: expose liblightpanda.so and C header for embedding A C ABI (include/lightpanda.h + src/c_api.zig) over the browser tool surface: lp_init/lp_shutdown, lp_fetch, sessions with lp_call/pump/ cancel, lp_tools_json. Built as a shared library only; the version script keeps everything but lp_* internal so the bundled OpenSSL/curl/ sqlite cannot collide with a host's own. Embedders get a hidden 'embed' Config mode (not parseable from the CLI) with telemetry defaulting off; crash reports honor the same opt-out. ToolSession in lightpanda.zig owns the browser/session/ registry lifecycle the C API drives. Build: C deps are always PIC (like boringssl's force_pic) and the pinned zig-v8-fork always builds V8 library-safe, so 'zig build shared-lib' needs no flags. It only refuses -Dprebuilt_v8_path: today's published archives are exe-only (local-exec TLS, malloc shim); the guard goes away once a fork release ships library-safe archives. make lib-shared / lib-test / lib-shared-example drive it. --- Makefile | 22 +- build.zig | 107 +++++- build.zig.zon | 4 +- examples/c/fetch.c | 40 +++ examples/c/tools.c | 57 +++ include/lightpanda.h | 177 ++++++++++ src/App.zig | 2 + src/Config.zig | 87 +++-- src/c_api.zig | 648 +++++++++++++++++++++++++++++++++++ src/cli.zig | 10 + src/crash_handler.zig | 7 +- src/lightpanda.map | 9 + src/lightpanda.zig | 66 ++++ src/telemetry/lightpanda.zig | 1 + src/telemetry/telemetry.zig | 5 +- 15 files changed, 1203 insertions(+), 39 deletions(-) create mode 100644 examples/c/fetch.c create mode 100644 examples/c/tools.c create mode 100644 include/lightpanda.h create mode 100644 src/c_api.zig create mode 100644 src/lightpanda.map diff --git a/Makefile b/Makefile index 3bd9eb4ee7..93dec97076 100644 --- a/Makefile +++ b/Makefile @@ -79,7 +79,7 @@ help: # $(ZIG) commands # ------------ -.PHONY: build build-v8-snapshot build-dev download-v8 run run-release test bench data end2end clean +.PHONY: build build-v8-snapshot build-dev download-v8 lib-shared lib-shared-example lib-test run run-release test bench data end2end clean ## Download the prebuilt V8 archive (skips the 10+ min source build) download-v8: @@ -109,6 +109,26 @@ build-dev: @$(ZIG) build $(ZIGFLAGS) || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;) @printf "\033[33mBuild OK\033[0m\n" +## Run the C ABI unit tests +lib-test: + @$(ZIG) build $(ZIGFLAGS) lib-test -freference-trace + +# No $(ZIGFLAGS): the published prebuilt V8 has an exe-only TLS model, so the +# shared build compiles V8 from source (one-time, ~40 min). +## Build the C shared library (zig-out/lib + zig-out/include) +lib-shared: + @printf "\033[36mBuilding C shared library (first run builds V8 from source)...\033[0m\n" + @$(ZIG) build shared-lib || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;) + @printf "\033[33mBuild OK: zig-out/lib/liblightpanda.so\033[0m\n" + +## Link and run the C example against the shared library (needs network) +lib-shared-example: lib-shared + @mkdir -p zig-out/bin + @cc examples/c/fetch.c $$(PKG_CONFIG_PATH=zig-out/lib/pkgconfig pkg-config --cflags --libs lightpanda) \ + -Wl,-rpath,$(BC)zig-out/lib -o zig-out/bin/fetch-example + @./zig-out/bin/fetch-example https://example.com > /dev/null \ + && printf "\033[33mExample OK\033[0m\n" + ## Run the server in release mode run: build @printf "\033[36mRunning...\033[0m\n" diff --git a/build.zig b/build.zig index 92609cc9cb..07eddd0d7e 100644 --- a/build.zig +++ b/build.zig @@ -58,7 +58,6 @@ pub fn build(b: *Build) !void { const enable_tsan = b.option(bool, "tsan", "Enable Thread Sanitizer") orelse false; const enable_asan = b.option(bool, "asan", "Enable Address Sanitizer") orelse false; const enable_csan = b.option(std.zig.SanitizeC, "csan", "Enable C Sanitizers"); - const lightpanda_module = blk: { const mod = b.addModule("lightpanda", .{ .root_source_file = b.path("src/lightpanda.zig"), @@ -218,6 +217,107 @@ pub fn build(b: *Build) !void { const test_step = b.step("test", "Run unit tests"); test_step.dependOn(&run_tests.step); } + + { + // C API (src/c_api.zig): liblightpanda.so for embedders. + const c_api_module = createCApiModule(b, lightpanda_module); + + const c_api_check = b.addLibrary(.{ + .name = "c_api_check", + .root_module = c_api_module, + }); + check.dependOn(&c_api_check.step); + + const install_header = b.addInstallHeaderFile(b.path("include/lightpanda.h"), "lightpanda.h"); + + // The published prebuilt V8 is exe-only (local-exec TLS, malloc + // shim); the .so needs a source-built V8. Drop this guard once the + // fork releases library-safe archives. + const shared_step = b.step("shared-lib", "Build the C shared library (needs a source-built V8)"); + if (prebuilt_v8_path == null) { + const shared_lib = b.addLibrary(.{ + .name = "lightpanda", + .linkage = .dynamic, + .use_llvm = true, + .root_module = c_api_module, + }); + shared_lib.version_script = b.path("src/lightpanda.map"); + shared_lib.linker_allow_shlib_undefined = false; + const install_so = b.addInstallArtifact(shared_lib, .{}); + // The version script is load-bearing: a host's own OpenSSL/curl + // must not collide with the bundled copies. Gate the install on + // the check so an installed .so is always a checked one. + if (target.result.os.tag == .linux) { + const export_check = b.addSystemCommand(&.{ + "sh", "-ec", + \\test -z "$(nm -D "$0" | awk '$2 == "T" && $3 !~ /^lp_/')" || + \\ { echo "liblightpanda.so exports non-lp_ symbols" >&2; exit 1; } + \\: > "$1" + }); + export_check.addFileArg(shared_lib.getEmittedBin()); + _ = export_check.addOutputFileArg("export-check-ok"); + install_so.step.dependOn(&export_check.step); + } + shared_step.dependOn(&install_so.step); + shared_step.dependOn(&install_header.step); + // The .so resolves its own dependencies, so the link line is + // just the library. + const shared_pc = pkgConfigFile(b, version_string, "-L${libdir} -llightpanda"); + shared_step.dependOn(&b.addInstallLibFile(shared_pc, "pkgconfig/lightpanda.pc").step); + } else { + shared_step.dependOn(&b.addFail("shared-lib needs a source-built V8: drop -Dprebuilt_v8_path").step); + } + + // Own binary: the two test suites must not share one V8 platform. + // The ABI-sync test compares c_api.zig's mirrors against the header + // itself. Test-only import: the .so module must not depend on the + // header, or every header edit relinks it. + const lib_tests_module = createCApiModule(b, lightpanda_module); + const header_translate_c = b.addTranslateC(.{ + .root_source_file = b.path("include/lightpanda.h"), + .target = target, + .optimize = optimize, + }); + lib_tests_module.addImport("lightpanda_h", header_translate_c.createModule()); + const lib_tests = b.addTest(.{ + .root_module = lib_tests_module, + .use_llvm = true, + .test_runner = .{ .path = b.path("src/test_runner.zig"), .mode = .simple }, + }); + const lib_test_step = b.step("lib-test", "Run the C ABI unit tests"); + lib_test_step.dependOn(&b.addRunArtifact(lib_tests).step); + } +} + +/// Root module for a C-API artifact. The ABI tests get their own instance +/// so their test-only header import stays off the .so. +fn createCApiModule(b: *Build, lightpanda: *Build.Module) *Build.Module { + const mod = b.createModule(.{ + .root_source_file = b.path("src/c_api.zig"), + .target = lightpanda.resolved_target.?, + .optimize = lightpanda.optimize.?, + .link_libc = true, + .link_libcpp = true, + .sanitize_c = lightpanda.sanitize_c, + .sanitize_thread = lightpanda.sanitize_thread, + }); + mod.addImport("lightpanda", lightpanda); + return mod; +} + +fn pkgConfigFile(b: *Build, version: []const u8, libs: []const u8) Build.LazyPath { + return b.addWriteFiles().add("lightpanda.pc", b.fmt( + \\prefix=${{pcfiledir}}/../.. + \\libdir=${{prefix}}/lib + \\includedir=${{prefix}}/include + \\ + \\Name: lightpanda + \\Description: Lightpanda headless browser C library + \\Version: {s} + \\Cflags: -I${{includedir}} + \\Libs: {s} + \\ + , .{ version, libs })); } fn linkV8( @@ -285,6 +385,7 @@ fn linkSqlite(b: *Build, mod: *Build.Module, enable_csan: ?std.zig.SanitizeC, is const lib = dep.artifact("sqlite3"); lib.root_module.sanitize_c = enable_csan; lib.root_module.sanitize_thread = is_tsan; + lib.root_module.pic = true; const macros = [_]struct { []const u8, []const u8 }{ .{ "SQLITE_DEFAULT_FILE_PERMISSIONS", "0600" }, @@ -380,6 +481,7 @@ fn buildZlib(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.Opti .optimize = optimize, .link_libc = true, .sanitize_thread = is_tsan, + .pic = true, }); const lib = b.addLibrary(.{ .name = "z", .root_module = mod }); @@ -412,6 +514,7 @@ fn buildBrotli(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.Op .optimize = optimize, .link_libc = true, .sanitize_thread = is_tsan, + .pic = true, }); mod.addIncludePath(dep.path("c/include")); @@ -475,6 +578,7 @@ fn buildNghttp2(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.O .optimize = optimize, .link_libc = true, .sanitize_thread = is_tsan, + .pic = true, }); mod.addIncludePath(dep.path("lib/includes")); @@ -528,6 +632,7 @@ fn buildCurl( .optimize = optimize, .link_libc = true, .sanitize_thread = is_tsan, + .pic = true, }); mod.addIncludePath(dep.path("lib")); mod.addIncludePath(dep.path("include")); diff --git a/build.zig.zon b/build.zig.zon index 94707cffb7..2f20c35253 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -5,8 +5,8 @@ .minimum_zig_version = "0.16.0", .dependencies = .{ .v8 = .{ - .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/78b6c77d5f6f040a575539203e6f3fad42453890.tar.gz", - .hash = "v8-0.0.0-xddH6xHyAgDVbf39iQaZfmzZCdNi7m3iTqOKbKz74Ggx", + .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/508ae5ef169b7202886decd751ccbde1f872e0cb.tar.gz", + .hash = "v8-0.0.0-xddH62D1AgDOizgZ_Il7HVloE_bAK3H4oQnxLcgeKGyW", }, // .v8 = .{ .path = "../zig-v8-fork" }, .brotli = .{ diff --git a/examples/c/fetch.c b/examples/c/fetch.c new file mode 100644 index 0000000000..543f34525a --- /dev/null +++ b/examples/c/fetch.c @@ -0,0 +1,40 @@ +/* Fetch a page and print it as markdown. + * + * Build from the repo root after `make lib-shared`, with the link line + * documented in include/lightpanda.h. Run: + * ./fetch https://example.com + */ + +#include +#include + +int main(int argc, char **argv) { + if (argc < 2) { + fprintf(stderr, "usage: %s \n", argv[0]); + return 2; + } + + lp_browser *browser = NULL; + lp_status status = lp_init(NULL, &browser); + if (status != LP_OK) { + fprintf(stderr, "lp_init failed: %d\n", status); + return 1; + } + + lp_fetch_opts opts = {0}; + opts.format = LP_FORMAT_MARKDOWN; + + lp_result result = {0}; + status = lp_fetch(browser, argv[1], &opts, &result); + if (status != LP_OK) { + fprintf(stderr, "lp_fetch failed: %d\n", status); + lp_shutdown(browser); + return 1; + } + + /* result.text is library-owned: valid until the next lp_fetch on this + * browser or lp_shutdown. */ + fwrite(result.text, 1, result.len, stdout); + lp_shutdown(browser); + return 0; +} diff --git a/examples/c/tools.c b/examples/c/tools.c new file mode 100644 index 0000000000..22375f38a3 --- /dev/null +++ b/examples/c/tools.c @@ -0,0 +1,57 @@ +/* Drive the browser through the tool surface: navigate, then extract the + * page title and links via a selector schema. + * + * Build from the repo root after `make lib-shared`, with the link line + * documented in include/lightpanda.h. Run: + * ./tools https://example.com + */ + +#include +#include + +static lp_status call(lp_session *session, const char *tool, const char *args) { + lp_result result = {0}; + lp_status status = lp_call(session, tool, args, &result); + if (status != LP_OK) { + fprintf(stderr, "%s failed: %d\n", tool, status); + return status; + } + printf("--- %s%s ---\n%s\n", tool, result.is_error ? " (page error)" : "", result.text); + return LP_OK; +} + +int main(int argc, char **argv) { + const char *url = argc > 1 ? argv[1] : "https://example.com"; + + lp_browser *browser = NULL; + if (lp_init(NULL, &browser) != LP_OK) { + fprintf(stderr, "lp_init failed\n"); + return 1; + } + + lp_session *session = NULL; + if (lp_session_new(browser, &session) != LP_OK) { + fprintf(stderr, "lp_session_new failed\n"); + lp_shutdown(browser); + return 1; + } + + char args[1024]; + snprintf(args, sizeof args, "{\"url\":\"%s\"}", url); + lp_status status = call(session, "goto", args); + + /* extract's "schema" argument is a string holding a JSON object literal + * that maps output fields to CSS-selector specs (not a JSON Schema). */ + if (status == LP_OK) + status = call(session, "extract", + "{\"schema\":" + "\"{\\\"title\\\": \\\"title\\\"," + " \\\"links\\\": [{\\\"selector\\\": \\\"a\\\", \\\"attr\\\": \\\"href\\\"}]}\"" + "}"); + if (status == LP_OK) + status = call(session, "getUrl", NULL); + + lp_session_close(session); + lp_shutdown(browser); + return status == LP_OK ? 0 : 1; +} diff --git a/include/lightpanda.h b/include/lightpanda.h new file mode 100644 index 0000000000..f2d88f34dd --- /dev/null +++ b/include/lightpanda.h @@ -0,0 +1,177 @@ +/* Copyright (C) 2023-2026 Lightpanda (Selecy SAS) + * + * Francis Bouvier + * Pierre Tachoire + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/* Lightpanda embedded as a C library. Implemented by src/c_api.zig, which + * must stay in sync with this header. + * + * Threading contract (v1): + * - lp_init may be called ONCE per process. After lp_shutdown the library + * cannot be initialized again (V8's platform is not re-initializable). + * - Every call on the lp_browser handle and on all of its sessions must + * come from the thread that called lp_init. Sessions are cheap and + * isolated; parallelism means multiple processes. + * - The library is not fork-safe after lp_init. + * + * Logging goes to stderr (level: warnings and errors in release builds). + * + * Linking: `make lib-shared` builds liblightpanda.so (zig-out/lib) and + * installs this header (zig-out/include) plus a pkg-config file: + * cc app.c $(PKG_CONFIG_PATH=zig-out/lib/pkgconfig pkg-config --cflags --libs lightpanda) + * or by hand: + * cc app.c -Izig-out/include -Lzig-out/lib -llightpanda + * The library resolves its dependencies internally and exports only lp_* + * symbols (safe next to a host's own OpenSSL/curl/sqlite), and it is + * dlopen-able for FFI (Python ctypes etc.). + */ + +#ifndef LIGHTPANDA_H +#define LIGHTPANDA_H + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct lp_browser lp_browser; +typedef struct lp_session lp_session; + +typedef enum lp_status { + LP_OK = 0, + LP_ERR_INVALID_PARAMS = 1, + /* No page loaded yet; call goto (or pass a url) first. */ + LP_ERR_FRAME_NOT_LOADED = 2, + LP_ERR_NODE_NOT_FOUND = 3, + LP_ERR_NAVIGATION_FAILED = 4, + /* The cancel hook (lp_session_set_cancel_hook) returned true. */ + LP_ERR_CANCELLED = 5, + LP_ERR_TIMEOUT = 6, + LP_ERR_OUT_OF_MEMORY = 7, + LP_ERR_INTERNAL = 8, + /* API misuse: double init, use after shutdown, NULL handle. */ + LP_ERR_MISUSE = 9 +} lp_status; + +/* Output of lp_fetch and lp_call. text is NUL-terminated (len excludes the + * NUL), read-only, and owned by the library: it stays valid until the next + * lp_call on the same session (for lp_fetch: the next lp_fetch on the same + * browser), or until that session/browser is torn down. Copy it out to keep + * it longer — including before handing it to another thread. lp_session_pump + * does not invalidate it. is_error signals an in-band page-level failure + * (e.g. a JS throw inside evaluate/extract) whose message is in text; the + * call itself still returns LP_OK. */ +typedef struct lp_result { + const char *text; + size_t len; + bool is_error; +} lp_result; + +/* Zero-initialize for defaults: no proxy, default user agent, no HTTP + * cache, 5s HTTP timeout, 30s JS watchdog, telemetry off. */ +typedef struct lp_options { + const char *user_agent; /* NULL: default ("Lightpanda/1.0") */ + const char *http_proxy; /* NULL: none */ + const char *http_cache_dir; /* NULL: no persistent HTTP cache */ + uint32_t http_timeout_ms; /* 0: default (5000) */ + int32_t watchdog_ms; /* 0: default (30000), <0: disabled */ + bool enable_telemetry; /* false: no telemetry */ +} lp_options; + +typedef enum lp_format { + LP_FORMAT_HTML = 0, + LP_FORMAT_MARKDOWN = 1, + LP_FORMAT_TREE_JSON = 2, /* semantic (accessibility-style) tree, JSON */ + LP_FORMAT_TREE_TEXT = 3 /* semantic tree, indented text */ +} lp_format; + +typedef enum lp_wait_until { + LP_WAIT_DEFAULT = 0, /* page fully settled ("done"), or the selector */ + LP_WAIT_LOAD = 1, + LP_WAIT_DOMCONTENTLOADED = 2, + LP_WAIT_NETWORKALMOSTIDLE = 3, + LP_WAIT_NETWORKIDLE = 4, + LP_WAIT_DONE = 5 +} lp_wait_until; + +/* Zero-initialize for defaults: HTML after the page settles, 5s budget. */ +typedef struct lp_fetch_opts { + int format; /* lp_format */ + uint32_t wait_ms; /* 0: default (5000) */ + int wait_until; /* lp_wait_until */ + const char *wait_selector; /* NULL: none; else wait for this CSS selector */ +} lp_fetch_opts; + +/* Initialize the library. opts may be NULL (all defaults). On LP_OK, + * *out is the process-wide browser handle. */ +lp_status lp_init(const lp_options *opts, lp_browser **out); + +/* Tear down the handle, closing any remaining sessions. Terminal — see the + * threading contract above. */ +void lp_shutdown(lp_browser *browser); + +/* Load url in a throwaway session, run its JavaScript, and return the page + * serialized per opts (NULL: all defaults). "curl that runs JavaScript". + * Every call gets a fresh session (cookies, storage, pages); the underlying + * browser is created on first use and reused, so looping lp_fetch is cheap. */ +lp_status lp_fetch(lp_browser *browser, const char *url, + const lp_fetch_opts *opts, lp_result *out); + +/* Create an isolated browsing session: its own page, cookies, JS heap. */ +lp_status lp_session_new(lp_browser *browser, lp_session **out); + +/* Close a session. The pointer is invalid afterwards. Sessions still open + * at lp_shutdown are closed then. */ +void lp_session_close(lp_session *session); + +/* Run one browser tool (goto, markdown, html, extract, tree, click, fill, + * waitForSelector, evaluate, ...) against the session. args_json is the + * tool's argument object as a JSON string (NULL: no arguments); the tool + * names and their JSON schemas are enumerated by lp_tools_json. Tools that + * read the page accept a "url" argument to navigate first, so + * lp_call(s, "markdown", "{\"url\":\"https://example.com\"}", &r) + * is a complete one-call scrape. */ +lp_status lp_call(lp_session *session, const char *tool, + const char *args_json, lp_result *out); + +/* Pump background work (timers, in-flight fetches) once; returns how many + * milliseconds the caller may sleep before pumping again. Only needed when + * idling between calls — every call waits for its own completion. */ +uint32_t lp_session_pump(lp_session *session); + +/* Install (cb != NULL) or clear (cb == NULL) a cancellation probe, polled + * on the session's thread during blocking waits; returning true fails the + * in-flight call with LP_ERR_CANCELLED. The probe may read state set from + * other threads (e.g. an atomic flag set by a signal handler). */ +void lp_session_set_cancel_hook(lp_session *session, + bool (*cb)(void *), void *ctx); + +/* JSON array of every tool lp_call accepts: + * [{"name", "description", "inputSchema"}, ...]. Static — do not free. */ +const char *lp_tools_json(void); + +/* Library version string. Static — do not free. */ +const char *lp_version(void); + +#ifdef __cplusplus +} +#endif + +#endif /* LIGHTPANDA_H */ diff --git a/src/App.zig b/src/App.zig index 5eff1f64e8..b290a1d00c 100644 --- a/src/App.zig +++ b/src/App.zig @@ -27,6 +27,7 @@ const Telemetry = @import("telemetry/telemetry.zig").Telemetry; const Storage = @import("storage/Storage.zig"); const Network = @import("network/Network.zig"); const Watchdog = @import("Watchdog.zig"); +const crash_handler = @import("crash_handler.zig"); pub const ArenaPool = @import("ArenaPool.zig"); const log = lp.log; @@ -78,6 +79,7 @@ pub fn init(allocator: Allocator, config: *const Config) !*App { app.app_dir_path = getAndMakeAppDir(allocator); + crash_handler.config_disables_reports = config.telemetryDisabled(); app.telemetry = try Telemetry.init(app, config.command, config.interactive()); errdefer app.telemetry.deinit(allocator); diff --git a/src/Config.zig b/src/Config.zig index 7129f00827..51e614ec2d 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -23,6 +23,7 @@ const builtin = @import("builtin"); const cli = @import("cli.zig"); const dump = @import("browser/dump.zig"); +const telemetry = @import("telemetry/telemetry.zig"); const Storage = @import("storage/Storage.zig"); const WebBotAuthConfig = @import("network/WebBotAuth.zig").Config; @@ -389,6 +390,16 @@ const Commands = cli.Builder(.{ .options = .{}, .shared_options = CommonOptions, }, + .{ + // The C API's mode (src/c_api.zig). Hidden: embedders construct it + // programmatically; it is not typeable on the command line. + .name = "embed", + .hidden = true, + .options = .{ + .{ .name = "enable_telemetry", .type = bool }, + }, + .shared_options = CommonOptions, + }, .{ .name = "version", .options = .{ .{ .name = "check", .type = bool }, } }, @@ -432,9 +443,17 @@ pub fn deinit(self: *const Config, allocator: Allocator) void { } } +pub fn telemetryDisabled(self: *const Config) bool { + if (telemetry.isDisabled()) return true; + return switch (self.mode) { + .embed => |opts| !opts.enable_telemetry, + else => false, + }; +} + pub fn interactive(self: *const Config) bool { return switch (self.mode) { - .fetch => false, + .fetch, .embed => false, .serve, .mcp => true, .agent => |opts| opts.script_file == null, else => unreachable, @@ -443,7 +462,7 @@ pub fn interactive(self: *const Config) bool { pub fn tlsVerifyHost(self: *const Config) bool { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| !opts.insecure_disable_tls_host_verification, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| !opts.insecure_disable_tls_host_verification, // `version --check` talks to the release endpoint; always verify. .version => true, else => unreachable, @@ -452,28 +471,28 @@ pub fn tlsVerifyHost(self: *const Config) bool { pub fn obeyRobots(self: *const Config) bool { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.obey_robots, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.obey_robots, else => unreachable, }; } pub fn disableSubframes(self: *const Config) bool { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.disable_subframes, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.disable_subframes, else => unreachable, }; } pub fn disableWorkers(self: *const Config) bool { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.disable_workers, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.disable_workers, else => unreachable, }; } pub fn watchdogMs(self: *const Config) ?u32 { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| { + inline .serve, .fetch, .mcp, .agent, .embed => |opts| { const ms = opts.watchdog_ms orelse 30000; return if (ms == 0) null else ms; }, @@ -483,28 +502,28 @@ pub fn watchdogMs(self: *const Config) ?u32 { pub fn enableExternalStylesheets(self: *const Config) bool { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.enable_external_stylesheets, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.enable_external_stylesheets, else => unreachable, }; } pub fn v8Flags(self: *const Config) ?[]const u8 { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.v8_flags_unsafe, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.v8_flags_unsafe, else => unreachable, }; } pub fn v8MaxHeapMb(self: *const Config) ?u32 { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.v8_max_heap_mb, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.v8_max_heap_mb, else => unreachable, }; } pub fn httpProxy(self: *const Config) ?[:0]const u8 { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.http_proxy, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.http_proxy, .version => null, else => unreachable, }; @@ -512,28 +531,28 @@ pub fn httpProxy(self: *const Config) ?[:0]const u8 { pub fn proxyBearerToken(self: *const Config) ?[:0]const u8 { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.proxy_bearer_token, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.proxy_bearer_token, else => null, }; } pub fn httpMaxConcurrent(self: *const Config) u8 { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.http_max_concurrent orelse 40, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.http_max_concurrent orelse 40, else => unreachable, }; } pub fn httpMaxHostOpen(self: *const Config) u8 { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.http_max_host_open orelse 6, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.http_max_host_open orelse 6, else => unreachable, }; } pub fn httpConnectTimeout(self: *const Config) u31 { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.http_connect_timeout orelse 0, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.http_connect_timeout orelse 0, .version => 0, else => unreachable, }; @@ -541,7 +560,7 @@ pub fn httpConnectTimeout(self: *const Config) u31 { pub fn httpTimeout(self: *const Config) u31 { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.http_timeout orelse 5000, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.http_timeout orelse 5000, .version => 5000, else => unreachable, }; @@ -553,14 +572,14 @@ pub fn httpMaxRedirects(_: *const Config) u8 { pub fn httpMaxResponseSize(self: *const Config) ?usize { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.http_max_response_size, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.http_max_response_size, else => unreachable, }; } pub fn wsMaxConcurrent(self: *const Config) u8 { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.ws_max_concurrent orelse 8, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.ws_max_concurrent orelse 8, else => unreachable, }; } @@ -572,7 +591,7 @@ pub fn logLevel(self: *const Config) ?log.Level { .low, .medium => .err, .high => null, }, - inline .serve, .fetch, .mcp => |opts| opts.log_level, + inline .serve, .fetch, .mcp, .embed => |opts| opts.log_level, else => unreachable, }; } @@ -602,49 +621,49 @@ fn stderrIsTty() bool { pub fn logFormat(self: *const Config) ?log.Format { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.log_format, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.log_format, else => unreachable, }; } pub fn logFilterScopes(self: *const Config) std.ArrayList(log.FilterRule) { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.log_filter_scopes, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.log_filter_scopes, else => unreachable, }; } pub fn userAgentSuffix(self: *const Config) ?[]const u8 { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.user_agent_suffix, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.user_agent_suffix, else => null, }; } pub fn userAgent(self: *const Config) ?[]const u8 { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.user_agent, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.user_agent, else => null, }; } pub fn httpCacheDir(self: *const Config) ?[]const u8 { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.http_cache_dir, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.http_cache_dir, else => null, }; } pub fn cookieFile(self: *const Config) ?[]const u8 { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.cookie, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.cookie, else => null, }; } pub fn cookieJarFile(self: *const Config) ?[]const u8 { return switch (self.mode) { - inline .fetch, .mcp, .agent => |opts| opts.cookie_jar, + inline .fetch, .mcp, .agent, .embed => |opts| opts.cookie_jar, else => null, }; } @@ -667,7 +686,7 @@ pub fn advertiseHost(self: *const Config) []const u8 { pub fn webBotAuth(self: *const Config) ?WebBotAuthConfig { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| WebBotAuthConfig{ + inline .serve, .fetch, .mcp, .agent, .embed => |opts| WebBotAuthConfig{ .key_file = opts.web_bot_auth_key_file orelse return null, .keyid = opts.web_bot_auth_keyid orelse return null, .domain = opts.web_bot_auth_domain orelse return null, @@ -678,21 +697,21 @@ pub fn webBotAuth(self: *const Config) ?WebBotAuthConfig { pub fn blockPrivateNetworks(self: *const Config) bool { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.block_private_networks, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.block_private_networks, else => unreachable, }; } pub fn blockCidrs(self: *const Config) ?[]const u8 { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.block_cidrs, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.block_cidrs, else => unreachable, }; } pub fn blockedUrlPatterns(self: *const Config) ?std.mem.SplitIterator(u8, .scalar) { const patterns = switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.block_urls, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.block_urls, else => unreachable, } orelse return null; return std.mem.splitScalar(u8, patterns, ','); @@ -702,7 +721,7 @@ pub fn maxConnections(self: *const Config) u16 { return switch (self.mode) { .serve => |opts| opts.cdp_max_connections, .mcp => 16, - .fetch, .agent => 0, + .fetch, .agent, .embed => 0, else => unreachable, }; } @@ -745,14 +764,14 @@ pub fn cdpMaxHTTPMessageSize(self: *const Config) u14 { pub fn storageEngine(self: *const Config) ?Storage.EngineType { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.storage_engine, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.storage_engine, else => unreachable, }; } pub fn storageSqlitePath(self: *const Config) ?[:0]const u8 { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.storage_sqlite_path, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.storage_sqlite_path, else => unreachable, }; } @@ -761,7 +780,7 @@ pub fn storageSqlitePath(self: *const Config) ?[:0]const u8 { /// if any was loaded during argument parsing. The caller takes ownership. pub fn customCertStore(self: *const Config) ?*crypto.X509_STORE { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| { + inline .serve, .fetch, .mcp, .agent, .embed => |opts| { const store = opts.cert.store orelse return null; // Validators guarantee a created store loaded something. lp.assert(opts.cert.count > 0, "empty custom cert store", .{}); @@ -892,6 +911,8 @@ pub fn printUsageAndExit(self: *const Config, allocator: Allocator, help_for: Ru const template = Help.version ++ "\n"; break :text try std.fmt.allocPrint(allocator, template, .{exec_name}); }, + // Hidden command: the CLI parser can never produce it. + .embed => unreachable, }; defer allocator.free(text); diff --git a/src/c_api.zig b/src/c_api.zig new file mode 100644 index 0000000000..06d1a794c2 --- /dev/null +++ b/src/c_api.zig @@ -0,0 +1,648 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! C ABI for embedding Lightpanda as a library. The contract lives in +//! include/lightpanda.h; this file must stay in sync with it. +//! +//! Threading contract (v1): `lp_init` may be called once per process (V8's +//! platform cannot be re-initialized after `lp_shutdown`), and every call on +//! the resulting handle — including all its sessions — must come from the +//! thread that called `lp_init`. V8 isolates have thread affinity; sessions +//! park their isolate between calls so many can share that one thread, the +//! same discipline as `mcp.HttpServer`. + +const std = @import("std"); +const lp = @import("lightpanda"); + +const c_allocator = std.heap.c_allocator; + +// Cap on arena capacity kept across result-arena resets: recycle small +// results, return multi-MB page dumps to the OS. +const result_retain_limit = 256 * 1024; + +// Values mirrored in include/lightpanda.h (lp_status). +const Status = enum(c_int) { + ok = 0, + invalid_params = 1, + frame_not_loaded = 2, + node_not_found = 3, + navigation_failed = 4, + cancelled = 5, + timeout = 6, + out_of_memory = 7, + internal = 8, + // API misuse: double init, init after shutdown, null handle. + misuse = 9, +}; + +// Mirrored in include/lightpanda.h (lp_result), which documents the +// result lifetime. +const Result = extern struct { + text: ?[*:0]const u8, + len: usize, + is_error: bool, + + const empty: Result = .{ .text = null, .len = 0, .is_error = false }; +}; + +// Mirrored in include/lightpanda.h (lp_options), which documents the +// sentinel values. Zero-initialized means defaults everywhere. +const InitOpts = extern struct { + user_agent: ?[*:0]const u8, + http_proxy: ?[*:0]const u8, + http_cache_dir: ?[*:0]const u8, + http_timeout_ms: u32, + watchdog_ms: i32, + enable_telemetry: bool, +}; + +// Mirrored in include/lightpanda.h (lp_fetch_opts). Formats and wait +// conditions travel as plain ints so out-of-range values from C are +// rejected instead of being illegal to load. +const FetchOpts = extern struct { + format: c_int, + wait_ms: u32, + wait_until: c_int, + wait_selector: ?[*:0]const u8, +}; + +// Mirrored in include/lightpanda.h (lp_format). +const Format = enum(c_int) { + html = 0, + markdown = 1, + tree_json = 2, + tree_text = 3, +}; + +// Mirrored in include/lightpanda.h (lp_wait_until). +const WaitUntil = enum(c_int) { + default = 0, + load = 1, + domcontentloaded = 2, + networkalmostidle = 3, + networkidle = 4, + done = 5, +}; + +const BrowserHandle = struct { + config: lp.Config, + app: *lp.App, + // Owns the option strings duped out of the caller's InitOpts. + config_arena: std.heap.ArenaAllocator, + // Owns the previous lp_fetch result; reset at the start of the next one. + fetch_arena: std.heap.ArenaAllocator, + // lp_fetch's browser, created on first use and reused after — each call + // still gets a fresh session. Its isolate parks between calls. + fetch_browser: ?lp.Browser, + sessions: std.ArrayList(*SessionHandle), +}; + +const Cancel = struct { + cb: *const fn (?*anyopaque) callconv(.c) bool, + ctx: ?*anyopaque, +}; + +const SessionHandle = struct { + owner: *BrowserHandle, + ts: lp.ToolSession, + // Per-call scratch and the returned result; the reset at the start of + // the next lp_call is the header's documented result lifetime. + arena: std.heap.ArenaAllocator, + cancel: ?Cancel, +}; + +// V8's platform is process-global and cannot be re-initialized after +// dispose, so init is once-and-final: live never coexists with a second +// handle, and shutdown is terminal. +const AppState = enum { uninitialized, live, shutdown }; +var app_state: AppState = .uninitialized; + +/// Initialize the library and return the process-wide browser handle. +/// Callable exactly once per process; after `lp_shutdown` it fails with +/// `misuse` forever (V8 cannot be re-initialized). +pub export fn lp_init(opts_: ?*const InitOpts, out_: ?**BrowserHandle) Status { + const out = out_ orelse return .misuse; + if (app_state != .uninitialized) return .misuse; + + const handle = createBrowser(opts_) catch |err| return errStatus(err); + app_state = .live; + out.* = handle; + return .ok; +} + +fn createBrowser(opts_: ?*const InitOpts) !*BrowserHandle { + const handle = try c_allocator.create(BrowserHandle); + errdefer c_allocator.destroy(handle); + + handle.config_arena = .init(c_allocator); + errdefer handle.config_arena.deinit(); + handle.fetch_arena = .init(c_allocator); + errdefer handle.fetch_arena.deinit(); + const arena = handle.config_arena.allocator(); + + var mode: @FieldType(lp.Config.Mode, "embed") = .{}; + if (opts_) |opts| { + mode.enable_telemetry = opts.enable_telemetry; + if (opts.user_agent) |ua| { + const span = std.mem.span(ua); + lp.Config.validateUserAgent(span) catch return error.InvalidParams; + mode.user_agent = try arena.dupe(u8, span); + } + if (opts.http_proxy) |proxy| { + mode.http_proxy = try arena.dupeZ(u8, std.mem.span(proxy)); + } + if (opts.http_cache_dir) |dir| { + mode.http_cache_dir = try arena.dupe(u8, std.mem.span(dir)); + } + if (opts.http_timeout_ms != 0) { + mode.http_timeout = std.math.cast(u31, opts.http_timeout_ms) orelse return error.InvalidParams; + } + // Config.watchdogMs treats 0 as "no watchdog" and null as the default. + if (opts.watchdog_ms < 0) { + mode.watchdog_ms = 0; + } else if (opts.watchdog_ms > 0) { + mode.watchdog_ms = @intCast(opts.watchdog_ms); + } + } + + handle.config = try lp.Config.init(c_allocator, "lightpanda", .{ .embed = mode }); + errdefer handle.config.deinit(c_allocator); + + handle.app = try lp.App.init(c_allocator, &handle.config); + handle.fetch_browser = null; + handle.sessions = .empty; + return handle; +} + +/// Tear down every remaining session and the process-wide state. Terminal: +/// the library cannot be initialized again in this process. +pub export fn lp_shutdown(handle_: ?*BrowserHandle) void { + const handle = handle_ orelse return; + if (app_state != .live) return; + + while (handle.sessions.pop()) |session| destroySession(session); + handle.sessions.deinit(c_allocator); + if (handle.fetch_browser) |*browser| { + // Browser.deinit's Env.deinit exit balances against this enter. + browser.env.isolate.enter(); + browser.deinit(); + } + handle.app.deinit(); + handle.config.deinit(c_allocator); + handle.fetch_arena.deinit(); + handle.config_arena.deinit(); + c_allocator.destroy(handle); + app_state = .shutdown; +} + +/// Load `url` in a throwaway session and return the page serialized per +/// `opts` (default: HTML after the page settles). NULL `opts` means all +/// defaults. `out` stays valid until the next `lp_fetch` on this handle or +/// `lp_shutdown`. +pub export fn lp_fetch( + handle_: ?*BrowserHandle, + url_: ?[*:0]const u8, + opts_: ?*const FetchOpts, + out_: ?*Result, +) Status { + const handle = handle_ orelse return .misuse; + const out = out_ orelse return .misuse; + out.* = .empty; + if (app_state != .live) return .misuse; + const url = url_ orelse return .invalid_params; + + var fetch_opts: lp.FetchOpts = .{ .dump = .{}, .dump_mode = .html }; + if (opts_) |opts| { + const format = std.enums.fromInt(Format, opts.format) orelse return .invalid_params; + fetch_opts.dump_mode = switch (format) { + .html => .html, + .markdown => .markdown, + .tree_json => .semantic_tree, + .tree_text => .semantic_tree_text, + }; + if (opts.wait_ms != 0) fetch_opts.wait_ms = opts.wait_ms; + const wait_until = std.enums.fromInt(WaitUntil, opts.wait_until) orelse return .invalid_params; + fetch_opts.wait_until = switch (wait_until) { + .default => null, + .load => .load, + .domcontentloaded => .domcontentloaded, + .networkalmostidle => .networkalmostidle, + .networkidle => .networkidle, + .done => .done, + }; + if (opts.wait_selector) |selector| fetch_opts.wait_selector = std.mem.span(selector); + } + + _ = handle.fetch_arena.reset(.{ .retain_with_limit = result_retain_limit }); + var writer: std.Io.Writer.Allocating = .init(handle.fetch_arena.allocator()); + fetch_opts.writer = &writer.writer; + + // Sessions park their isolate between calls, so entering this + // browser's isolate here nests correctly. Browser.init leaves the + // isolate entered; the exit below parks it either way. + if (handle.fetch_browser) |*browser| { + browser.env.isolate.enter(); + } else { + handle.fetch_browser = @as(lp.Browser, undefined); + (&handle.fetch_browser.?).init(handle.app, .{}, null) catch { + handle.fetch_browser = null; + return .internal; + }; + } + const browser = &handle.fetch_browser.?; + defer browser.env.isolate.exit(); + + lp.fetch(handle.app, browser, &.{std.mem.span(url)}, fetch_opts) catch |err| return errStatus(err); + + const text = sentinelText(&writer) catch return .out_of_memory; + out.* = .{ .text = text.ptr, .len = text.len, .is_error = false }; + return .ok; +} + +/// Create an isolated browsing session (its own V8 isolate, page, cookies +/// and memory). Close with `lp_session_close`; any session still open at +/// `lp_shutdown` is closed then. +pub export fn lp_session_new(handle_: ?*BrowserHandle, out_: ?**SessionHandle) Status { + const handle = handle_ orelse return .misuse; + const out = out_ orelse return .misuse; + if (app_state != .live) return .misuse; + + out.* = createSession(handle) catch |err| return errStatus(err); + return .ok; +} + +/// Exhaustive so a new ToolError tag forces a mapping decision at +/// compile time. +fn toolStatus(err: lp.tools.ToolError) Status { + return switch (err) { + error.FrameNotLoaded => .frame_not_loaded, + error.InvalidParams => .invalid_params, + error.NodeNotFound => .node_not_found, + error.NavigationFailed => .navigation_failed, + error.Cancelled => .cancelled, + error.Timeout => .timeout, + error.InternalError => .internal, + error.OutOfMemory => .out_of_memory, + }; +} + +/// For the anyerror paths (init, fetch): tool errors keep their toolStatus +/// mapping, anything else is internal. +fn errStatus(err: anyerror) Status { + inline for (@typeInfo(lp.tools.ToolError).error_set.?) |e| { + const tool_err = @field(lp.tools.ToolError, e.name); + if (err == tool_err) return toolStatus(tool_err); + } + return .internal; +} + +fn createSession(handle: *BrowserHandle) !*SessionHandle { + const entry = try c_allocator.create(SessionHandle); + errdefer c_allocator.destroy(entry); + + entry.owner = handle; + entry.cancel = null; + entry.arena = .init(c_allocator); + errdefer entry.arena.deinit(); + + try entry.ts.init(handle.app); + errdefer entry.ts.deinit(); + + entry.ts.setCancelHook(.{ .context = entry, .check = cancelTrampoline }); + + try handle.sessions.append(c_allocator, entry); + + // ToolSession.init left the isolate entered; park it so sessions can + // share the thread. + entry.ts.exitIsolate(); + return entry; +} + +/// Close a session created by `lp_session_new`, freeing its isolate, page +/// and memory. The handle is invalid afterwards. +pub export fn lp_session_close(entry_: ?*SessionHandle) void { + const entry = entry_ orelse return; + const sessions = &entry.owner.sessions; + for (sessions.items, 0..) |session, i| { + if (session == entry) { + _ = sessions.swapRemove(i); + break; + } + } + destroySession(entry); +} + +fn destroySession(entry: *SessionHandle) void { + entry.ts.enterIsolate(); + entry.ts.deinit(); + entry.arena.deinit(); + c_allocator.destroy(entry); +} + +/// Run one browser tool against the session. `tool` is a name from +/// `lp_tools_json` (goto, markdown, extract, click, …); `args_json` is that +/// tool's argument object as a JSON string, or NULL for no arguments. On +/// `LP_OK`, `out` stays valid until the next `lp_call` on this session or +/// `lp_session_close`; `out->is_error` signals an in-band page-level +/// failure (e.g. a JS throw inside evaluate/extract) whose message is in +/// `out->text`. +pub export fn lp_call( + entry_: ?*SessionHandle, + tool_: ?[*:0]const u8, + args_json_: ?[*:0]const u8, + out_: ?*Result, +) Status { + const entry = entry_ orelse return .misuse; + const out = out_ orelse return .misuse; + out.* = .empty; + if (app_state != .live) return .misuse; + const tool = tool_ orelse return .invalid_params; + + // At the start, not on the way out: the previous result must stay + // valid until this call begins. + _ = entry.arena.reset(.{ .retain_with_limit = result_retain_limit }); + const arena = entry.arena.allocator(); + + var args: ?std.json.Value = null; + if (args_json_) |args_json| { + args = std.json.parseFromSliceLeaky(std.json.Value, arena, std.mem.span(args_json), .{}) catch { + return .invalid_params; + }; + } + + entry.ts.enterIsolate(); + defer entry.ts.exitIsolate(); + + const result = lp.tools.call(arena, entry.ts.session, &entry.ts.registry, std.mem.span(tool), args) catch |err| { + return toolStatus(err); + }; + + const text = arena.dupeZ(u8, result.text) catch return .out_of_memory; + out.* = .{ .text = text.ptr, .len = text.len, .is_error = result.is_error }; + return .ok; +} + +/// Terminate an Allocating writer's buffer in place — unlike +/// `toOwnedSliceSentinel`, no resize-to-fit remap/copy on an arena. +fn sentinelText(aw: *std.Io.Writer.Allocating) error{OutOfMemory}![:0]const u8 { + aw.writer.writeByte(0) catch return error.OutOfMemory; + const s = aw.written(); + return s[0 .. s.len - 1 :0]; +} + +/// Pump the session's background work (timers, in-flight fetches, resumed +/// JS) once. Returns the number of milliseconds the caller may sleep before +/// pumping again. Only needed when idling between calls; every tool call +/// already waits for its own completion. +pub export fn lp_session_pump(entry_: ?*SessionHandle) u32 { + const entry = entry_ orelse return 0; + entry.ts.enterIsolate(); + defer entry.ts.exitIsolate(); + return entry.ts.session.idleSlice(); +} + +/// Install (or clear, with NULL `cb`) a cancellation probe polled during +/// blocking waits: once it returns true, the in-flight call fails with +/// `LP_ERR_CANCELLED`. The probe is invoked on the session's thread, but it +/// may read state set by another thread (e.g. an atomic flag flipped by a +/// signal handler). +pub export fn lp_session_set_cancel_hook( + entry_: ?*SessionHandle, + cb: ?*const fn (?*anyopaque) callconv(.c) bool, + ctx: ?*anyopaque, +) void { + const entry = entry_ orelse return; + entry.cancel = if (cb) |f| .{ .cb = f, .ctx = ctx } else null; +} + +fn cancelTrampoline(ctx: *anyopaque) bool { + const entry: *SessionHandle = @ptrCast(@alignCast(ctx)); + const cancel = entry.cancel orelse return false; + return cancel.cb(cancel.ctx); +} + +/// JSON array describing every tool `lp_call` accepts, in the MCP +/// tools/list wire shape: [{"name", "description", "inputSchema"}, …]. +/// Static storage — do not free. +pub export fn lp_tools_json() [*:0]const u8 { + tools_json_once.call(); + return tools_json.ptr; +} + +var tools_json: [:0]const u8 = undefined; +var tools_json_once = lp.once(buildToolsJson); + +fn buildToolsJson() void { + var writer: std.Io.Writer.Allocating = .init(c_allocator); + defer writer.deinit(); + writeToolsJson(&writer.writer) catch @panic("OOM"); + tools_json = writer.toOwnedSliceSentinel(0) catch @panic("OOM"); +} + +// Rendered from the protocol-neutral lp.tools.tool_defs: the shape is this +// ABI's contract (see the header), independent of how the MCP adapter +// evolves its own tools/list wire type. +fn writeToolsJson(w: *std.Io.Writer) !void { + var jw: std.json.Stringify = .{ .writer = w }; + try jw.beginArray(); + for (lp.tools.names, lp.tools.tool_defs) |name, def| { + try jw.beginObject(); + try jw.objectField("name"); + try jw.write(name); + try jw.objectField("description"); + try jw.write(def.description); + try jw.objectField("inputSchema"); + _ = try jw.beginWriteRaw(); + try jw.writer.writeAll(def.input_schema); + jw.endWriteRaw(); + try jw.endObject(); + } + try jw.endArray(); +} + +/// The library version. Static storage — do not free. +pub export fn lp_version() [*:0]const u8 { + return version.ptr; +} + +const version: [:0]const u8 = lp.build_config.version ++ ""; + +const testing = std.testing; + +test "c_api: mirrors the header ABI" { + const h = @import("lightpanda_h"); + + try testing.expectEqual(h.LP_OK, @intFromEnum(Status.ok)); + try testing.expectEqual(h.LP_ERR_INVALID_PARAMS, @intFromEnum(Status.invalid_params)); + try testing.expectEqual(h.LP_ERR_FRAME_NOT_LOADED, @intFromEnum(Status.frame_not_loaded)); + try testing.expectEqual(h.LP_ERR_NODE_NOT_FOUND, @intFromEnum(Status.node_not_found)); + try testing.expectEqual(h.LP_ERR_NAVIGATION_FAILED, @intFromEnum(Status.navigation_failed)); + try testing.expectEqual(h.LP_ERR_CANCELLED, @intFromEnum(Status.cancelled)); + try testing.expectEqual(h.LP_ERR_TIMEOUT, @intFromEnum(Status.timeout)); + try testing.expectEqual(h.LP_ERR_OUT_OF_MEMORY, @intFromEnum(Status.out_of_memory)); + try testing.expectEqual(h.LP_ERR_INTERNAL, @intFromEnum(Status.internal)); + try testing.expectEqual(h.LP_ERR_MISUSE, @intFromEnum(Status.misuse)); + + try testing.expectEqual(h.LP_FORMAT_HTML, @intFromEnum(Format.html)); + try testing.expectEqual(h.LP_FORMAT_MARKDOWN, @intFromEnum(Format.markdown)); + try testing.expectEqual(h.LP_FORMAT_TREE_JSON, @intFromEnum(Format.tree_json)); + try testing.expectEqual(h.LP_FORMAT_TREE_TEXT, @intFromEnum(Format.tree_text)); + + try testing.expectEqual(h.LP_WAIT_DEFAULT, @intFromEnum(WaitUntil.default)); + try testing.expectEqual(h.LP_WAIT_LOAD, @intFromEnum(WaitUntil.load)); + try testing.expectEqual(h.LP_WAIT_DOMCONTENTLOADED, @intFromEnum(WaitUntil.domcontentloaded)); + try testing.expectEqual(h.LP_WAIT_NETWORKALMOSTIDLE, @intFromEnum(WaitUntil.networkalmostidle)); + try testing.expectEqual(h.LP_WAIT_NETWORKIDLE, @intFromEnum(WaitUntil.networkidle)); + try testing.expectEqual(h.LP_WAIT_DONE, @intFromEnum(WaitUntil.done)); + + try expectSameLayout(h.lp_result, Result); + try expectSameLayout(h.lp_options, InitOpts); + try expectSameLayout(h.lp_fetch_opts, FetchOpts); + + // The exports are pub so this reflection sees them; a header prototype + // must exist (compile error otherwise) and agree on arity and sizes. + inline for (@typeInfo(@This()).@"struct".decls) |decl| { + if (comptime std.mem.startsWith(u8, decl.name, "lp_")) { + try expectSameSignature(@TypeOf(@field(h, decl.name)), @TypeOf(@field(@This(), decl.name))); + } + } + + // The lines above only prove the Zig side exists in the header; the + // counts catch a constant or function added to the header alone. + try testing.expectEqual(@typeInfo(Format).@"enum".fields.len, comptime countPrefixed(h, "LP_FORMAT_")); + try testing.expectEqual(@typeInfo(WaitUntil).@"enum".fields.len, comptime countPrefixed(h, "LP_WAIT_")); + try testing.expectEqual(@typeInfo(Status).@"enum".fields.len, comptime countPrefixed(h, "LP_ERR_") + 1); // + LP_OK + try testing.expectEqual(comptime countFns(@This(), "lp_"), comptime countFns(h, "lp_")); +} + +fn countPrefixed(comptime T: type, comptime prefix: []const u8) usize { + @setEvalBranchQuota(100_000); + comptime var n: usize = 0; + inline for (@typeInfo(T).@"struct".decls) |decl| { + if (comptime std.mem.startsWith(u8, decl.name, prefix)) n += 1; + } + return n; +} + +fn countFns(comptime T: type, comptime prefix: []const u8) usize { + @setEvalBranchQuota(100_000); + comptime var n: usize = 0; + inline for (@typeInfo(T).@"struct".decls) |decl| { + if (comptime std.mem.startsWith(u8, decl.name, prefix) and + @typeInfo(@TypeOf(@field(T, decl.name))) == .@"fn") n += 1; + } + return n; +} + +/// Opaque handles make C-vs-Zig type identity meaningless, but every +/// mismatch that matters at the call boundary shows up as an arity or a +/// size difference. +fn expectSameSignature(comptime C: type, comptime Zig: type) !void { + const c_fn = @typeInfo(C).@"fn"; + const zig_fn = @typeInfo(Zig).@"fn"; + try testing.expectEqual(c_fn.params.len, zig_fn.params.len); + try testing.expectEqual(@sizeOf(c_fn.return_type.?), @sizeOf(zig_fn.return_type.?)); + // Over the min so an arity drift fails the expectEqual above instead + // of breaking the unroll. + inline for (0..@min(c_fn.params.len, zig_fn.params.len)) |i| { + try testing.expectEqual(@sizeOf(c_fn.params[i].type.?), @sizeOf(zig_fn.params[i].type.?)); + } +} + +fn expectSameLayout(comptime C: type, comptime Zig: type) !void { + try testing.expectEqual(@sizeOf(C), @sizeOf(Zig)); + inline for (@typeInfo(Zig).@"struct".fields) |field| { + try testing.expectEqual(@offsetOf(C, field.name), @offsetOf(Zig, field.name)); + try testing.expectEqual(@sizeOf(@FieldType(C, field.name)), @sizeOf(field.type)); + } +} + +test "c_api: defaults match the header's documented values" { + var config = try lp.Config.init(testing.allocator, "lightpanda", .{ .embed = .{} }); + defer config.deinit(testing.allocator); + try testing.expectEqual(5000, config.httpTimeout()); + try testing.expectEqual(30000, config.watchdogMs()); + try testing.expectEqual(5000, (lp.FetchOpts{ .dump = .{} }).wait_ms); +} + +test "c_api: version matches the build" { + try testing.expectEqualStrings(lp.build_config.version, std.mem.span(lp_version())); +} + +test "c_api: tools_json is valid JSON covering every tool" { + const parsed = try std.json.parseFromSlice( + std.json.Value, + testing.allocator, + std.mem.span(lp_tools_json()), + .{}, + ); + defer parsed.deinit(); + + const list = parsed.value.array.items; + try testing.expectEqual(lp.tools.names.len, list.len); + for (list, lp.tools.names) |entry, name| { + try testing.expectEqualStrings(name, entry.object.get("name").?.string); + try testing.expect(entry.object.get("description").?.string.len > 0); + try testing.expect(entry.object.get("inputSchema").? == .object); + } +} + +test "c_api: null handles are rejected" { + var result: Result = .empty; + try testing.expectEqual(.misuse, lp_init(null, null)); + try testing.expectEqual(.misuse, lp_call(null, "getUrl", null, &result)); + try testing.expectEqual(.misuse, lp_session_new(null, null)); + lp_session_close(null); + try testing.expectEqual(null, result.text); +} + +test "c_api: lifecycle" { + var browser: *BrowserHandle = undefined; + try testing.expectEqual(.ok, lp_init(null, &browser)); + + var second: *BrowserHandle = undefined; + try testing.expectEqual(.misuse, lp_init(null, &second)); + + var session: *SessionHandle = undefined; + try testing.expectEqual(.ok, lp_session_new(browser, &session)); + + var result: Result = .empty; + try testing.expectEqual(.invalid_params, lp_call(session, "nosuchtool", null, &result)); + try testing.expectEqual(.invalid_params, lp_call(session, "goto", null, &result)); + try testing.expectEqual(.invalid_params, lp_call(session, "goto", "not json", &result)); + + try testing.expectEqual(.ok, lp_call(session, "getEnv", null, &result)); + try testing.expect(result.text != null); + try testing.expectEqual(std.mem.span(result.text.?).len, result.len); + + // Pumping must not invalidate a held result. + try testing.expect(lp_session_pump(session) > 0); + try testing.expectEqual(std.mem.span(result.text.?).len, result.len); + + // Twice: the second call reuses the lazily-created fetch browser. + try testing.expectEqual(.ok, lp_fetch(browser, "about:blank", null, &result)); + try testing.expect(result.text != null); + try testing.expectEqual(.ok, lp_fetch(browser, "about:blank", null, &result)); + try testing.expectEqual(std.mem.span(result.text.?).len, result.len); + + lp_session_close(session); + lp_shutdown(browser); + + // Terminal: V8 cannot be re-initialized after dispose. + try testing.expectEqual(.misuse, lp_init(null, &second)); +} diff --git a/src/cli.zig b/src/cli.zig index 75472eef62..826d46ebb8 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -182,6 +182,13 @@ pub fn Builder(comptime commands: anytype) type { return struct { const Self = @This(); + /// Hidden commands exist in `Enum`/`Union` (for programmatic + /// construction, e.g. the C API's `embed`) but are not parseable + /// and never appear in help. + fn isHidden(comptime command: anytype) bool { + return @hasField(@TypeOf(command), "hidden") and command.hidden; + } + /// Enum type for provided commands. pub const Enum = blk: { const len = commands.len + 1; @@ -385,6 +392,7 @@ pub fn Builder(comptime commands: anytype) type { const cmd_str: []const u8 = args.next() orelse "serve"; inline for (commands) |command| { + if (comptime isHidden(command)) continue; // Match a command. if (std.mem.eql(u8, cmd_str, command.name)) { const cmd_parsed = try parseCommand(allocator, command, &args); @@ -401,6 +409,7 @@ pub fn Builder(comptime commands: anytype) type { }; inline for (commands) |command| { + if (comptime isHidden(command)) continue; if (std.mem.eql(u8, command_name, command.name)) { return .{ exec_name, @@ -435,6 +444,7 @@ pub fn Builder(comptime commands: anytype) type { _ = args.skip(); inline for (commands) |command| { + if (comptime isHidden(command)) continue; if (std.mem.eql(u8, @tagName(command_enum), command.name)) { const cmd_parsed = try parseCommand(allocator, command, &args); return .{ exec_name, cmd_parsed }; diff --git a/src/crash_handler.zig b/src/crash_handler.zig index 82830df738..ac4900dc9f 100644 --- a/src/crash_handler.zig +++ b/src/crash_handler.zig @@ -71,12 +71,17 @@ pub noinline fn crash( abort(); } +/// Stamped by App.init from Config.telemetryDisabled so crash reports +/// honor a programmatic opt-out (the C API's); the env check in `report` +/// still covers crashes before an App exists. +pub var config_disables_reports: bool = false; + fn report(reason: []const u8, begin_addr: usize, args: anytype) !void { if (comptime IS_DEBUG) { return; } - if (@import("telemetry/telemetry.zig").isDisabled()) { + if (config_disables_reports or @import("telemetry/telemetry.zig").isDisabled()) { return; } diff --git a/src/lightpanda.map b/src/lightpanda.map new file mode 100644 index 0000000000..21fabea85c --- /dev/null +++ b/src/lightpanda.map @@ -0,0 +1,9 @@ +/* liblightpanda.so exports exactly the C API; everything else (BoringSSL, + * curl, sqlite, V8, zig runtime) stays local so a host linking its own + * copies cannot interpose. */ +{ + global: + lp_*; + local: + *; +}; diff --git a/src/lightpanda.zig b/src/lightpanda.zig index 06ceb23d7e..812be010a6 100644 --- a/src/lightpanda.zig +++ b/src/lightpanda.zig @@ -173,6 +173,72 @@ pub fn Once(comptime f: fn () void) type { }; } +/// What a tool-driving embedder owns per isolated browsing context: a Browser +/// (its own V8 isolate), that browser's session, the notification hub, and +/// the node registry `tools.call` needs. Used by the C API. `self` must not +/// move after `init` — Browser registers self-pointers. +pub const ToolSession = struct { + browser: Browser, + session: *Session, + notification: *Notification, + registry: CDPNode.Registry, + // The hook is session-scoped; storing it here lets `reset` re-apply it + // so cancellation survives session replacement. + cancel_hook: ?Session.CancelHook, + + /// Leaves the browser's isolate entered, like `Browser.init`; callers + /// sharing one thread between several isolates park it with + /// `exitIsolate` afterwards. + pub fn init(self: *ToolSession, app: *App) !void { + self.cancel_hook = null; + + self.notification = try Notification.init(app.allocator); + errdefer self.notification.deinit(); + + self.registry = .init(app.allocator); + errdefer self.registry.deinit(); + + try self.browser.init(app, .{}, null); + errdefer self.browser.deinit(); + + try self.reset(); + } + + /// Install a cancellation probe on this and every future session. + pub fn setCancelHook(self: *ToolSession, hook: Session.CancelHook) void { + self.cancel_hook = hook; + self.session.cancel_hook = hook; + } + + /// Replace the browsing session with a fresh one (`Browser.newSession` + /// closes the old one, cookies and all); the stored cancel hook is + /// re-applied. + pub fn reset(self: *ToolSession) !void { + self.session = try self.browser.newSession(self.notification); + self.session.cancel_hook = self.cancel_hook; + try self.session.enableConsoleCapture(); + } + + /// The isolate must be current (`enterIsolate` if parked): Browser.deinit's + /// Env.deinit exit has to balance against this context's isolate. + pub fn deinit(self: *ToolSession) void { + self.registry.deinit(); + self.browser.deinit(); + self.notification.deinit(); + } + + /// V8's "current isolate" is a per-thread stack: when several contexts + /// share a thread, bracket any use of the Browser/Session with + /// enterIsolate/exitIsolate and leave it un-entered otherwise. + pub fn enterIsolate(self: *ToolSession) void { + self.browser.env.isolate.enter(); + } + + pub fn exitIsolate(self: *ToolSession) void { + self.browser.env.isolate.exit(); + } +}; + pub const FetchOpts = struct { wait_ms: u32 = 5000, wait_until: ?Config.WaitUntil = null, diff --git a/src/telemetry/lightpanda.zig b/src/telemetry/lightpanda.zig index 15e1e29cd4..e29a26c488 100644 --- a/src/telemetry/lightpanda.zig +++ b/src/telemetry/lightpanda.zig @@ -84,6 +84,7 @@ pub fn init(self: *LightPanda, app: *App, iid: ?[36]u8, run_mode: Config.RunMode .agent => if (interactive == false) "AR" else "A", .run => "R", .mcp => "M", + .embed => "E", .version => "V", .help => "H", }, diff --git a/src/telemetry/telemetry.zig b/src/telemetry/telemetry.zig index 3c3dd30063..bc5e2accff 100644 --- a/src/telemetry/telemetry.zig +++ b/src/telemetry/telemetry.zig @@ -10,6 +10,9 @@ const log = lp.log; const IID_FILE = "iid"; const Allocator = std.mem.Allocator; +/// The env/build-level opt-out. Config.telemetryDisabled folds it into +/// the predicate everything else reads; only crash_handler calls this +/// directly (no Config exists on the crash path). pub fn isDisabled() bool { if (builtin.mode == .Debug or builtin.is_test) { return true; @@ -29,7 +32,7 @@ fn TelemetryT(comptime P: type) type { const Self = @This(); pub fn init(app: *App, run_mode: Config.RunMode, interactive: bool) !Self { - const disabled = isDisabled(); + const disabled = app.config.telemetryDisabled(); if (builtin.mode != .Debug and builtin.is_test == false) { log.info(.telemetry, "telemetry status", .{ .disabled = disabled }); } From 6f2fbb12ed682aec3cf0e342c9534afd8a4c9bbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Fri, 31 Jul 2026 21:15:42 +0200 Subject: [PATCH 02/13] build: rename lib-shared and lib-test targets --- Makefile | 12 ++++++------ build.zig | 14 +++++++------- examples/c/fetch.c | 2 +- examples/c/tools.c | 2 +- include/lightpanda.h | 2 +- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/Makefile b/Makefile index 93dec97076..30cab0a5e1 100644 --- a/Makefile +++ b/Makefile @@ -79,7 +79,7 @@ help: # $(ZIG) commands # ------------ -.PHONY: build build-v8-snapshot build-dev download-v8 lib-shared lib-shared-example lib-test run run-release test bench data end2end clean +.PHONY: build build-v8-snapshot build-dev download-v8 lib lib-example test-lib run run-release test bench data end2end clean ## Download the prebuilt V8 archive (skips the 10+ min source build) download-v8: @@ -110,19 +110,19 @@ build-dev: @printf "\033[33mBuild OK\033[0m\n" ## Run the C ABI unit tests -lib-test: - @$(ZIG) build $(ZIGFLAGS) lib-test -freference-trace +test-lib: + @$(ZIG) build $(ZIGFLAGS) test-lib -freference-trace # No $(ZIGFLAGS): the published prebuilt V8 has an exe-only TLS model, so the # shared build compiles V8 from source (one-time, ~40 min). ## Build the C shared library (zig-out/lib + zig-out/include) -lib-shared: +lib: @printf "\033[36mBuilding C shared library (first run builds V8 from source)...\033[0m\n" - @$(ZIG) build shared-lib || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;) + @$(ZIG) build lib || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;) @printf "\033[33mBuild OK: zig-out/lib/liblightpanda.so\033[0m\n" ## Link and run the C example against the shared library (needs network) -lib-shared-example: lib-shared +lib-example: lib @mkdir -p zig-out/bin @cc examples/c/fetch.c $$(PKG_CONFIG_PATH=zig-out/lib/pkgconfig pkg-config --cflags --libs lightpanda) \ -Wl,-rpath,$(BC)zig-out/lib -o zig-out/bin/fetch-example diff --git a/build.zig b/build.zig index 07eddd0d7e..abe6f0e4d4 100644 --- a/build.zig +++ b/build.zig @@ -233,7 +233,7 @@ pub fn build(b: *Build) !void { // The published prebuilt V8 is exe-only (local-exec TLS, malloc // shim); the .so needs a source-built V8. Drop this guard once the // fork releases library-safe archives. - const shared_step = b.step("shared-lib", "Build the C shared library (needs a source-built V8)"); + const lib_step = b.step("lib", "Build the C shared library (needs a source-built V8)"); if (prebuilt_v8_path == null) { const shared_lib = b.addLibrary(.{ .name = "lightpanda", @@ -258,14 +258,14 @@ pub fn build(b: *Build) !void { _ = export_check.addOutputFileArg("export-check-ok"); install_so.step.dependOn(&export_check.step); } - shared_step.dependOn(&install_so.step); - shared_step.dependOn(&install_header.step); + lib_step.dependOn(&install_so.step); + lib_step.dependOn(&install_header.step); // The .so resolves its own dependencies, so the link line is // just the library. const shared_pc = pkgConfigFile(b, version_string, "-L${libdir} -llightpanda"); - shared_step.dependOn(&b.addInstallLibFile(shared_pc, "pkgconfig/lightpanda.pc").step); + lib_step.dependOn(&b.addInstallLibFile(shared_pc, "pkgconfig/lightpanda.pc").step); } else { - shared_step.dependOn(&b.addFail("shared-lib needs a source-built V8: drop -Dprebuilt_v8_path").step); + lib_step.dependOn(&b.addFail("lib needs a source-built V8: drop -Dprebuilt_v8_path").step); } // Own binary: the two test suites must not share one V8 platform. @@ -284,8 +284,8 @@ pub fn build(b: *Build) !void { .use_llvm = true, .test_runner = .{ .path = b.path("src/test_runner.zig"), .mode = .simple }, }); - const lib_test_step = b.step("lib-test", "Run the C ABI unit tests"); - lib_test_step.dependOn(&b.addRunArtifact(lib_tests).step); + const test_lib_step = b.step("test-lib", "Run the C ABI unit tests"); + test_lib_step.dependOn(&b.addRunArtifact(lib_tests).step); } } diff --git a/examples/c/fetch.c b/examples/c/fetch.c index 543f34525a..bb8397a299 100644 --- a/examples/c/fetch.c +++ b/examples/c/fetch.c @@ -1,6 +1,6 @@ /* Fetch a page and print it as markdown. * - * Build from the repo root after `make lib-shared`, with the link line + * Build from the repo root after `make lib`, with the link line * documented in include/lightpanda.h. Run: * ./fetch https://example.com */ diff --git a/examples/c/tools.c b/examples/c/tools.c index 22375f38a3..e80e60c0e8 100644 --- a/examples/c/tools.c +++ b/examples/c/tools.c @@ -1,7 +1,7 @@ /* Drive the browser through the tool surface: navigate, then extract the * page title and links via a selector schema. * - * Build from the repo root after `make lib-shared`, with the link line + * Build from the repo root after `make lib`, with the link line * documented in include/lightpanda.h. Run: * ./tools https://example.com */ diff --git a/include/lightpanda.h b/include/lightpanda.h index f2d88f34dd..d39204ed3e 100644 --- a/include/lightpanda.h +++ b/include/lightpanda.h @@ -30,7 +30,7 @@ * * Logging goes to stderr (level: warnings and errors in release builds). * - * Linking: `make lib-shared` builds liblightpanda.so (zig-out/lib) and + * Linking: `make lib` builds liblightpanda.so (zig-out/lib) and * installs this header (zig-out/include) plus a pkg-config file: * cc app.c $(PKG_CONFIG_PATH=zig-out/lib/pkgconfig pkg-config --cflags --libs lightpanda) * or by hand: From dca314216602ba04903aab3f3c5968cddfb095ca Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 3 Aug 2026 13:46:25 +0800 Subject: [PATCH 03/13] Limit exported symbols to lp_* on MacOS This was all Claude. I'm not good enough at builds. But the issue appears to be that the MacOS build exports everything and the only solution is to hide them at compile time. This does not work for v8 (which goes through its own build system), but Claude says it's fine since those are mangled and would only conflict if the user embedded v8 directly also. --- Makefile | 4 +++- build.zig | 56 +++++++++++++++++++++++++++++++++++++++----- build.zig.zon | 4 ++-- include/lightpanda.h | 13 ++++++---- 4 files changed, 63 insertions(+), 14 deletions(-) diff --git a/Makefile b/Makefile index 30cab0a5e1..ba63cb836c 100644 --- a/Makefile +++ b/Makefile @@ -32,6 +32,8 @@ else $(error "Unhandled kernel: $(kernel)") endif +LIB_EXT := $(if $(filter macos,$(OS)),dylib,so) + # Prebuilt V8 # ----------- @@ -119,7 +121,7 @@ test-lib: lib: @printf "\033[36mBuilding C shared library (first run builds V8 from source)...\033[0m\n" @$(ZIG) build lib || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;) - @printf "\033[33mBuild OK: zig-out/lib/liblightpanda.so\033[0m\n" + @printf "\033[33mBuild OK: zig-out/lib/liblightpanda.$(LIB_EXT)\033[0m\n" ## Link and run the C example against the shared library (needs network) lib-example: lib diff --git a/build.zig b/build.zig index abe6f0e4d4..1bbaec2206 100644 --- a/build.zig +++ b/build.zig @@ -22,6 +22,13 @@ const builtin = @import("builtin"); const lightpanda_version = std.SemanticVersion.parse(@import("build.zig.zon").version) catch unreachable; const min_zig_version = std.SemanticVersion.parse(@import("build.zig.zon").minimum_zig_version) catch unreachable; +// Keeps the bundled C libraries out of liblightpanda's export table so a host +// linking its own curl/zlib/... cannot be interposed. The ELF build hides them +// with src/lightpanda.map, but Mach-O has no version-script equivalent, so the +// symbols have to be hidden at compile time. Unconditional: the modules are +// shared with the executable, which exports nothing either way. +const hide_symbols = "-fvisibility=hidden"; + const Build = blk: { if (builtin.zig_version.order(min_zig_version) == .lt) { const message = std.fmt.comptimePrint( @@ -244,15 +251,41 @@ pub fn build(b: *Build) !void { shared_lib.version_script = b.path("src/lightpanda.map"); shared_lib.linker_allow_shlib_undefined = false; const install_so = b.addInstallArtifact(shared_lib, .{}); - // The version script is load-bearing: a host's own OpenSSL/curl + // Symbol hiding is load-bearing: a host's own OpenSSL/curl/sqlite // must not collide with the bundled copies. Gate the install on - // the check so an installed .so is always a checked one. - if (target.result.os.tag == .linux) { + // the check so an installed library is always a checked one. + const Query = struct { nm: []const u8, awk: []const u8 }; + const leak_query: ?Query = switch (target.result.os.tag) { + // The version script keeps everything but lp_* local, so + // anything else in the dynamic table is a leak. + .linux => .{ + .nm = "nm -D", + .awk = "$2 == \"T\" && $3 !~ /^lp_/ { print $3 }", + }, + // Mach-O has no version script, so the bundled C libraries are + // hidden at compile time instead (see hide_symbols). V8's own + // C++ symbols stay exported, so only the C libraries — the ones + // include/lightpanda.h promises — can be asserted absent. + .macos => .{ + .nm = "nm -gU", + .awk = "$2 == \"T\" && $3 ~ /^_(AES|ASN1|BIO|Brotli|EVP|OPENSSL|RSA|SSL|X509|adler32|crc32|curl|deflate|inflate|nghttp2|sqlite3)/ { print $3 }", + }, + else => null, + }; + if (leak_query) |query| { const export_check = b.addSystemCommand(&.{ "sh", "-ec", - \\test -z "$(nm -D "$0" | awk '$2 == "T" && $3 !~ /^lp_/')" || - \\ { echo "liblightpanda.so exports non-lp_ symbols" >&2; exit 1; } - \\: > "$1" + // Two statements, not `test -z "$(nm ... | awk ...)"`: there + // a failing nm yields no output and the check passes. The + // bare assignment lets -e see nm's status. + b.fmt( + \\symbols=$({s} "$0") + \\leaked=$(printf '%s\n' "$symbols" | awk '{s}') + \\test -z "$leaked" || + \\ {{ echo "liblightpanda exports bundled dependency symbols:" >&2 + \\ printf '%s\n' "$leaked" | head -20 >&2; exit 1; }} + \\: > "$1" + , .{ query.nm, query.awk }), }); export_check.addFileArg(shared_lib.getEmittedBin()); _ = export_check.addOutputFileArg("export-check-ok"); @@ -388,6 +421,10 @@ fn linkSqlite(b: *Build, mod: *Build.Module, enable_csan: ?std.zig.SanitizeC, is lib.root_module.pic = true; const macros = [_]struct { []const u8, []const u8 }{ + // The amalgamation is one translation unit, so everything not static is + // tagged SQLITE_API; redefining it hides the lot. `-fvisibility=hidden` + // is not reachable here — the sources belong to the dependency. + .{ "SQLITE_API", "__attribute__((visibility(\"hidden\")))" }, .{ "SQLITE_DEFAULT_FILE_PERMISSIONS", "0600" }, .{ "SQLITE_DEFAULT_MEMSTATUS", "0" }, .{ "SQLITE_DEFAULT_WAL_SYNCHRONOUS", "1" }, @@ -489,6 +526,7 @@ fn buildZlib(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.Opti mod.addCSourceFiles(.{ .root = dep.path(""), .flags = &.{ + hide_symbols, "-DHAVE_SYS_TYPES_H", "-DHAVE_STDINT_H", "-DHAVE_STDDEF_H", @@ -525,6 +563,7 @@ fn buildBrotli(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.Op brotlicmn.installHeadersDirectory(dep.path("c/include/brotli"), "brotli", .{}); mod.addCSourceFiles(.{ .root = dep.path("c/common"), + .flags = &.{hide_symbols}, .files = &.{ "transform.c", "shared_dictionary.c", "platform.c", "dictionary.c", "context.c", "constants.c", @@ -532,6 +571,7 @@ fn buildBrotli(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.Op }); mod.addCSourceFiles(.{ .root = dep.path("c/dec"), + .flags = &.{hide_symbols}, .files = &.{ "bit_reader.c", "decode.c", "huffman.c", "prefix.c", "state.c", "static_init.c", @@ -539,6 +579,7 @@ fn buildBrotli(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.Op }); mod.addCSourceFiles(.{ .root = dep.path("c/enc"), + .flags = &.{hide_symbols}, .files = &.{ "backward_references.c", "backward_references_hq.c", "bit_cost.c", "block_splitter.c", "brotli_bit_stream.c", "cluster.c", @@ -559,6 +600,7 @@ fn buildBoringSsl(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin .target = target, .optimize = optimize, .force_pic = true, + .hidden_visibility = true, }); const ssl = dep.artifact("ssl"); @@ -598,6 +640,7 @@ fn buildNghttp2(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.O mod.addCSourceFiles(.{ .root = dep.path("lib"), .flags = &.{ + hide_symbols, "-DNGHTTP2_STATICLIB", "-DHAVE_TIME_H", "-DHAVE_ARPA_INET_H", @@ -884,6 +927,7 @@ fn buildCurl( mod.addCSourceFiles(.{ .root = dep.path("lib"), .flags = &.{ + hide_symbols, "-D_GNU_SOURCE", "-DHAVE_CONFIG_H", "-DCURL_STATICLIB", diff --git a/build.zig.zon b/build.zig.zon index 2f20c35253..2a2c2bbad9 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -23,8 +23,8 @@ .hash = "N-V-__8AAMHpvwChC6orFCDB8MeiumT6OlfmKfmrWlak7Int", }, .@"boringssl-zig" = .{ - .url = "git+https://github.com/lightpanda-io/boringssl-zig.git#f07cc58fa4a051eb0985287e691db5dbb0e7e76f", - .hash = "boringssl-0.1.0-VtJeWd1OAAAQ5AuNOZGP5a_5ZyNn5BfeE-jiKqEm2gsE", + .url = "git+https://github.com/lightpanda-io/boringssl-zig.git#16cf3037855545e06406f553c3b57ffbf27e7cfd", + .hash = "boringssl-0.1.0-VtJeWTZSAAANq2AyGl10FISzYmg8rVXlc8Hlc75QWdJs", }, // .@"boringssl-zig" = .{ .path = "../boringssl-zig" }, .curl = .{ diff --git a/include/lightpanda.h b/include/lightpanda.h index d39204ed3e..98d67eb4e2 100644 --- a/include/lightpanda.h +++ b/include/lightpanda.h @@ -30,14 +30,17 @@ * * Logging goes to stderr (level: warnings and errors in release builds). * - * Linking: `make lib` builds liblightpanda.so (zig-out/lib) and - * installs this header (zig-out/include) plus a pkg-config file: + * Linking: `make lib` builds liblightpanda.so (liblightpanda.dylib on macOS) + * into zig-out/lib and installs this header (zig-out/include) plus a + * pkg-config file: * cc app.c $(PKG_CONFIG_PATH=zig-out/lib/pkgconfig pkg-config --cflags --libs lightpanda) * or by hand: * cc app.c -Izig-out/include -Lzig-out/lib -llightpanda - * The library resolves its dependencies internally and exports only lp_* - * symbols (safe next to a host's own OpenSSL/curl/sqlite), and it is - * dlopen-able for FFI (Python ctypes etc.). + * The library resolves its dependencies internally, and the bundled + * OpenSSL/curl/sqlite are hidden on both platforms, so it is safe to load + * next to a host's own copies — including dlopen'd for FFI (Python ctypes + * etc.). ELF exports nothing but lp_*; Mach-O has no version script, so V8's + * own C++ symbols stay visible there. */ #ifndef LIGHTPANDA_H From 14753826d62c6584c5f7fe898fb92c8004cfe89b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 3 Aug 2026 08:46:51 +0200 Subject: [PATCH 04/13] c_api: transition to shutdown state on App.init failure --- src/c_api.zig | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/c_api.zig b/src/c_api.zig index 06d1a794c2..6bd4885081 100644 --- a/src/c_api.zig +++ b/src/c_api.zig @@ -183,7 +183,13 @@ fn createBrowser(opts_: ?*const InitOpts) !*BrowserHandle { handle.config = try lp.Config.init(c_allocator, "lightpanda", .{ .embed = mode }); errdefer handle.config.deinit(c_allocator); - handle.app = try lp.App.init(c_allocator, &handle.config); + // Everything above is plain validation and can be retried; App.init + // touches V8's process-global platform, so a failure here poisons the + // process like a shutdown does. + handle.app = lp.App.init(c_allocator, &handle.config) catch |err| { + app_state = .shutdown; + return err; + }; handle.fetch_browser = null; handle.sessions = .empty; return handle; @@ -613,6 +619,19 @@ test "c_api: null handles are rejected" { test "c_api: lifecycle" { var browser: *BrowserHandle = undefined; + + // Option validation happens before V8 is touched, so a rejected init + // must leave the library initializable. + const bad_opts: InitOpts = .{ + .user_agent = "bad\nagent", + .http_proxy = null, + .http_cache_dir = null, + .http_timeout_ms = 0, + .watchdog_ms = 0, + .enable_telemetry = false, + }; + try testing.expectEqual(.invalid_params, lp_init(&bad_opts, &browser)); + try testing.expectEqual(.ok, lp_init(null, &browser)); var second: *BrowserHandle = undefined; From 9660160451aa36d5c6d8651bcab1e1a41a462f0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 3 Aug 2026 08:49:55 +0200 Subject: [PATCH 05/13] c_api: ignore stale session handles after shutdown --- src/c_api.zig | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/c_api.zig b/src/c_api.zig index 6bd4885081..30ce92854b 100644 --- a/src/c_api.zig +++ b/src/c_api.zig @@ -343,6 +343,9 @@ fn createSession(handle: *BrowserHandle) !*SessionHandle { /// and memory. The handle is invalid afterwards. pub export fn lp_session_close(entry_: ?*SessionHandle) void { const entry = entry_ orelse return; + // After lp_shutdown every session is already destroyed; a stale handle + // must be inert, not a use-after-free. + if (app_state != .live) return; const sessions = &entry.owner.sessions; for (sessions.items, 0..) |session, i| { if (session == entry) { @@ -417,6 +420,7 @@ fn sentinelText(aw: *std.Io.Writer.Allocating) error{OutOfMemory}![:0]const u8 { /// already waits for its own completion. pub export fn lp_session_pump(entry_: ?*SessionHandle) u32 { const entry = entry_ orelse return 0; + if (app_state != .live) return 0; entry.ts.enterIsolate(); defer entry.ts.exitIsolate(); return entry.ts.session.idleSlice(); @@ -433,6 +437,7 @@ pub export fn lp_session_set_cancel_hook( ctx: ?*anyopaque, ) void { const entry = entry_ orelse return; + if (app_state != .live) return; entry.cancel = if (cb) |f| .{ .cb = f, .ctx = ctx } else null; } @@ -664,4 +669,9 @@ test "c_api: lifecycle" { // Terminal: V8 cannot be re-initialized after dispose. try testing.expectEqual(.misuse, lp_init(null, &second)); + + // Stale handles are inert after shutdown — no deref, no crash. + try testing.expectEqual(0, lp_session_pump(session)); + lp_session_close(session); + lp_session_set_cancel_hook(session, null, null); } From 8c0e20cdbc1a984bfdda81ca782933374874afc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 3 Aug 2026 09:16:05 +0200 Subject: [PATCH 06/13] c-api: use explicit string lengths --- examples/c/fetch.c | 3 +- examples/c/tools.c | 5 ++- include/lightpanda.h | 20 ++++++--- src/c_api.zig | 102 ++++++++++++++++++++++++++----------------- 4 files changed, 81 insertions(+), 49 deletions(-) diff --git a/examples/c/fetch.c b/examples/c/fetch.c index bb8397a299..41a7e85285 100644 --- a/examples/c/fetch.c +++ b/examples/c/fetch.c @@ -7,6 +7,7 @@ #include #include +#include int main(int argc, char **argv) { if (argc < 2) { @@ -25,7 +26,7 @@ int main(int argc, char **argv) { opts.format = LP_FORMAT_MARKDOWN; lp_result result = {0}; - status = lp_fetch(browser, argv[1], &opts, &result); + status = lp_fetch(browser, argv[1], strlen(argv[1]), &opts, &result); if (status != LP_OK) { fprintf(stderr, "lp_fetch failed: %d\n", status); lp_shutdown(browser); diff --git a/examples/c/tools.c b/examples/c/tools.c index e80e60c0e8..84546d68ad 100644 --- a/examples/c/tools.c +++ b/examples/c/tools.c @@ -8,15 +8,16 @@ #include #include +#include static lp_status call(lp_session *session, const char *tool, const char *args) { lp_result result = {0}; - lp_status status = lp_call(session, tool, args, &result); + lp_status status = lp_call(session, tool, strlen(tool), args, args ? strlen(args) : 0, &result); if (status != LP_OK) { fprintf(stderr, "%s failed: %d\n", tool, status); return status; } - printf("--- %s%s ---\n%s\n", tool, result.is_error ? " (page error)" : "", result.text); + printf("--- %s%s ---\n%.*s\n", tool, result.is_error ? " (page error)" : "", (int)result.len, result.text); return LP_OK; } diff --git a/include/lightpanda.h b/include/lightpanda.h index d39204ed3e..0abb7630ce 100644 --- a/include/lightpanda.h +++ b/include/lightpanda.h @@ -28,6 +28,11 @@ * isolated; parallelism means multiple processes. * - The library is not fork-safe after lp_init. * + * Strings: every string, input and output, is a pointer plus a byte + * length. Inputs need not be NUL-terminated and outputs are not guaranteed + * to be. (The only exceptions are lp_version and lp_tools_json, which + * return static NUL-terminated C strings.) + * * Logging goes to stderr (level: warnings and errors in release builds). * * Linking: `make lib` builds liblightpanda.so (zig-out/lib) and @@ -70,8 +75,8 @@ typedef enum lp_status { LP_ERR_MISUSE = 9 } lp_status; -/* Output of lp_fetch and lp_call. text is NUL-terminated (len excludes the - * NUL), read-only, and owned by the library: it stays valid until the next +/* Output of lp_fetch and lp_call. text points at len bytes — read-only, + * not NUL-terminated — owned by the library: it stays valid until the next * lp_call on the same session (for lp_fetch: the next lp_fetch on the same * browser), or until that session/browser is torn down. Copy it out to keep * it longer — including before handing it to another thread. lp_session_pump @@ -88,8 +93,11 @@ typedef struct lp_result { * cache, 5s HTTP timeout, 30s JS watchdog, telemetry off. */ typedef struct lp_options { const char *user_agent; /* NULL: default ("Lightpanda/1.0") */ + size_t user_agent_len; const char *http_proxy; /* NULL: none */ + size_t http_proxy_len; const char *http_cache_dir; /* NULL: no persistent HTTP cache */ + size_t http_cache_dir_len; uint32_t http_timeout_ms; /* 0: default (5000) */ int32_t watchdog_ms; /* 0: default (30000), <0: disabled */ bool enable_telemetry; /* false: no telemetry */ @@ -117,6 +125,7 @@ typedef struct lp_fetch_opts { uint32_t wait_ms; /* 0: default (5000) */ int wait_until; /* lp_wait_until */ const char *wait_selector; /* NULL: none; else wait for this CSS selector */ + size_t wait_selector_len; } lp_fetch_opts; /* Initialize the library. opts may be NULL (all defaults). On LP_OK, @@ -131,7 +140,7 @@ void lp_shutdown(lp_browser *browser); * serialized per opts (NULL: all defaults). "curl that runs JavaScript". * Every call gets a fresh session (cookies, storage, pages); the underlying * browser is created on first use and reused, so looping lp_fetch is cheap. */ -lp_status lp_fetch(lp_browser *browser, const char *url, +lp_status lp_fetch(lp_browser *browser, const char *url, size_t url_len, const lp_fetch_opts *opts, lp_result *out); /* Create an isolated browsing session: its own page, cookies, JS heap. */ @@ -148,8 +157,9 @@ void lp_session_close(lp_session *session); * read the page accept a "url" argument to navigate first, so * lp_call(s, "markdown", "{\"url\":\"https://example.com\"}", &r) * is a complete one-call scrape. */ -lp_status lp_call(lp_session *session, const char *tool, - const char *args_json, lp_result *out); +lp_status lp_call(lp_session *session, const char *tool, size_t tool_len, + const char *args_json, size_t args_json_len, + lp_result *out); /* Pump background work (timers, in-flight fetches) once; returns how many * milliseconds the caller may sleep before pumping again. Only needed when diff --git a/src/c_api.zig b/src/c_api.zig index 30ce92854b..a1ab26dca5 100644 --- a/src/c_api.zig +++ b/src/c_api.zig @@ -51,9 +51,9 @@ const Status = enum(c_int) { }; // Mirrored in include/lightpanda.h (lp_result), which documents the -// result lifetime. +// result lifetime and string contract. const Result = extern struct { - text: ?[*:0]const u8, + text: ?[*]const u8, len: usize, is_error: bool, @@ -63,9 +63,12 @@ const Result = extern struct { // Mirrored in include/lightpanda.h (lp_options), which documents the // sentinel values. Zero-initialized means defaults everywhere. const InitOpts = extern struct { - user_agent: ?[*:0]const u8, - http_proxy: ?[*:0]const u8, - http_cache_dir: ?[*:0]const u8, + user_agent: ?[*]const u8, + user_agent_len: usize, + http_proxy: ?[*]const u8, + http_proxy_len: usize, + http_cache_dir: ?[*]const u8, + http_cache_dir_len: usize, http_timeout_ms: u32, watchdog_ms: i32, enable_telemetry: bool, @@ -78,7 +81,8 @@ const FetchOpts = extern struct { format: c_int, wait_ms: u32, wait_until: c_int, - wait_selector: ?[*:0]const u8, + wait_selector: ?[*]const u8, + wait_selector_len: usize, }; // Mirrored in include/lightpanda.h (lp_format). @@ -159,15 +163,15 @@ fn createBrowser(opts_: ?*const InitOpts) !*BrowserHandle { if (opts_) |opts| { mode.enable_telemetry = opts.enable_telemetry; if (opts.user_agent) |ua| { - const span = std.mem.span(ua); + const span = ua[0..opts.user_agent_len]; lp.Config.validateUserAgent(span) catch return error.InvalidParams; mode.user_agent = try arena.dupe(u8, span); } if (opts.http_proxy) |proxy| { - mode.http_proxy = try arena.dupeZ(u8, std.mem.span(proxy)); + mode.http_proxy = try arena.dupeZ(u8, proxy[0..opts.http_proxy_len]); } if (opts.http_cache_dir) |dir| { - mode.http_cache_dir = try arena.dupe(u8, std.mem.span(dir)); + mode.http_cache_dir = try arena.dupe(u8, dir[0..opts.http_cache_dir_len]); } if (opts.http_timeout_ms != 0) { mode.http_timeout = std.math.cast(u31, opts.http_timeout_ms) orelse return error.InvalidParams; @@ -222,7 +226,8 @@ pub export fn lp_shutdown(handle_: ?*BrowserHandle) void { /// `lp_shutdown`. pub export fn lp_fetch( handle_: ?*BrowserHandle, - url_: ?[*:0]const u8, + url_: ?[*]const u8, + url_len: usize, opts_: ?*const FetchOpts, out_: ?*Result, ) Status { @@ -230,7 +235,15 @@ pub export fn lp_fetch( const out = out_ orelse return .misuse; out.* = .empty; if (app_state != .live) return .misuse; - const url = url_ orelse return .invalid_params; + const url_ptr = url_ orelse return .invalid_params; + if (url_len == 0) return .invalid_params; + + // At the start, not on the way out: the previous result must stay valid + // until this call begins. The reset precedes the option parsing because + // the input strings are duped into this arena. + _ = handle.fetch_arena.reset(.{ .retain_with_limit = result_retain_limit }); + const arena = handle.fetch_arena.allocator(); + const url = arena.dupeZ(u8, url_ptr[0..url_len]) catch return .out_of_memory; var fetch_opts: lp.FetchOpts = .{ .dump = .{}, .dump_mode = .html }; if (opts_) |opts| { @@ -251,11 +264,12 @@ pub export fn lp_fetch( .networkidle => .networkidle, .done => .done, }; - if (opts.wait_selector) |selector| fetch_opts.wait_selector = std.mem.span(selector); + if (opts.wait_selector) |selector| { + fetch_opts.wait_selector = arena.dupeZ(u8, selector[0..opts.wait_selector_len]) catch return .out_of_memory; + } } - _ = handle.fetch_arena.reset(.{ .retain_with_limit = result_retain_limit }); - var writer: std.Io.Writer.Allocating = .init(handle.fetch_arena.allocator()); + var writer: std.Io.Writer.Allocating = .init(arena); fetch_opts.writer = &writer.writer; // Sessions park their isolate between calls, so entering this @@ -273,9 +287,9 @@ pub export fn lp_fetch( const browser = &handle.fetch_browser.?; defer browser.env.isolate.exit(); - lp.fetch(handle.app, browser, &.{std.mem.span(url)}, fetch_opts) catch |err| return errStatus(err); + lp.fetch(handle.app, browser, &.{url}, fetch_opts) catch |err| return errStatus(err); - const text = sentinelText(&writer) catch return .out_of_memory; + const text = writer.written(); out.* = .{ .text = text.ptr, .len = text.len, .is_error = false }; return .ok; } @@ -372,8 +386,10 @@ fn destroySession(entry: *SessionHandle) void { /// `out->text`. pub export fn lp_call( entry_: ?*SessionHandle, - tool_: ?[*:0]const u8, - args_json_: ?[*:0]const u8, + tool_: ?[*]const u8, + tool_len: usize, + args_json_: ?[*]const u8, + args_json_len: usize, out_: ?*Result, ) Status { const entry = entry_ orelse return .misuse; @@ -389,7 +405,7 @@ pub export fn lp_call( var args: ?std.json.Value = null; if (args_json_) |args_json| { - args = std.json.parseFromSliceLeaky(std.json.Value, arena, std.mem.span(args_json), .{}) catch { + args = std.json.parseFromSliceLeaky(std.json.Value, arena, args_json[0..args_json_len], .{}) catch { return .invalid_params; }; } @@ -397,23 +413,14 @@ pub export fn lp_call( entry.ts.enterIsolate(); defer entry.ts.exitIsolate(); - const result = lp.tools.call(arena, entry.ts.session, &entry.ts.registry, std.mem.span(tool), args) catch |err| { + const result = lp.tools.call(arena, entry.ts.session, &entry.ts.registry, tool[0..tool_len], args) catch |err| { return toolStatus(err); }; - const text = arena.dupeZ(u8, result.text) catch return .out_of_memory; - out.* = .{ .text = text.ptr, .len = text.len, .is_error = result.is_error }; + out.* = .{ .text = result.text.ptr, .len = result.text.len, .is_error = result.is_error }; return .ok; } -/// Terminate an Allocating writer's buffer in place — unlike -/// `toOwnedSliceSentinel`, no resize-to-fit remap/copy on an arena. -fn sentinelText(aw: *std.Io.Writer.Allocating) error{OutOfMemory}![:0]const u8 { - aw.writer.writeByte(0) catch return error.OutOfMemory; - const s = aw.written(); - return s[0 .. s.len - 1 :0]; -} - /// Pump the session's background work (timers, in-flight fetches, resumed /// JS) once. Returns the number of milliseconds the caller may sleep before /// pumping again. Only needed when idling between calls; every tool call @@ -616,7 +623,7 @@ test "c_api: tools_json is valid JSON covering every tool" { test "c_api: null handles are rejected" { var result: Result = .empty; try testing.expectEqual(.misuse, lp_init(null, null)); - try testing.expectEqual(.misuse, lp_call(null, "getUrl", null, &result)); + try testing.expectEqual(.misuse, lp_call(null, "getUrl", "getUrl".len, null, 0, &result)); try testing.expectEqual(.misuse, lp_session_new(null, null)); lp_session_close(null); try testing.expectEqual(null, result.text); @@ -629,8 +636,11 @@ test "c_api: lifecycle" { // must leave the library initializable. const bad_opts: InitOpts = .{ .user_agent = "bad\nagent", + .user_agent_len = "bad\nagent".len, .http_proxy = null, + .http_proxy_len = 0, .http_cache_dir = null, + .http_cache_dir_len = 0, .http_timeout_ms = 0, .watchdog_ms = 0, .enable_telemetry = false, @@ -646,23 +656,33 @@ test "c_api: lifecycle" { try testing.expectEqual(.ok, lp_session_new(browser, &session)); var result: Result = .empty; - try testing.expectEqual(.invalid_params, lp_call(session, "nosuchtool", null, &result)); - try testing.expectEqual(.invalid_params, lp_call(session, "goto", null, &result)); - try testing.expectEqual(.invalid_params, lp_call(session, "goto", "not json", &result)); + try testing.expectEqual(.invalid_params, lp_call(session, "nosuchtool", "nosuchtool".len, null, 0, &result)); + try testing.expectEqual(.invalid_params, lp_call(session, "goto", "goto".len, null, 0, &result)); + try testing.expectEqual(.invalid_params, lp_call(session, "goto", "goto".len, "not json", "not json".len, &result)); + + try testing.expectEqual(.ok, lp_call(session, "getEnv", "getEnv".len, null, 0, &result)); + try testing.expect(result.text != null); + try testing.expect(result.len > 0); - try testing.expectEqual(.ok, lp_call(session, "getEnv", null, &result)); + // Lengths are the contract: trailing garbage past them must be ignored. + const padded_tool = "getEnvGARBAGE"; + const padded_args = "{\"name\":\"PATH\"}GARBAGE"; + try testing.expectEqual(.ok, lp_call(session, padded_tool, "getEnv".len, padded_args, "{\"name\":\"PATH\"}".len, &result)); try testing.expect(result.text != null); - try testing.expectEqual(std.mem.span(result.text.?).len, result.len); // Pumping must not invalidate a held result. + var held: [16]u8 = undefined; + const held_len = @min(result.len, held.len); + @memcpy(held[0..held_len], result.text.?[0..held_len]); try testing.expect(lp_session_pump(session) > 0); - try testing.expectEqual(std.mem.span(result.text.?).len, result.len); + try testing.expect(std.mem.eql(u8, held[0..held_len], result.text.?[0..held_len])); - // Twice: the second call reuses the lazily-created fetch browser. - try testing.expectEqual(.ok, lp_fetch(browser, "about:blank", null, &result)); + // Twice: the second call reuses the lazily-created fetch browser; the + // second url carries trailing garbage past its length. + try testing.expectEqual(.ok, lp_fetch(browser, "about:blank", "about:blank".len, null, &result)); try testing.expect(result.text != null); - try testing.expectEqual(.ok, lp_fetch(browser, "about:blank", null, &result)); - try testing.expectEqual(std.mem.span(result.text.?).len, result.len); + try testing.expectEqual(.ok, lp_fetch(browser, "about:blankGARBAGE", "about:blank".len, null, &result)); + try testing.expect(result.len > 0); lp_session_close(session); lp_shutdown(browser); From 11f38b9787930d791d709129e7859898d8e9f559 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 3 Aug 2026 09:24:58 +0200 Subject: [PATCH 07/13] c_api: add lp_last_error functions Expose `lp_last_error` and `lp_browser_last_error` in the C API to retrieve the error name of the most recent failing call. --- examples/c/tools.c | 4 +++- include/lightpanda.h | 8 +++++++ src/c_api.zig | 57 ++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 64 insertions(+), 5 deletions(-) diff --git a/examples/c/tools.c b/examples/c/tools.c index 84546d68ad..669175c29e 100644 --- a/examples/c/tools.c +++ b/examples/c/tools.c @@ -14,7 +14,9 @@ static lp_status call(lp_session *session, const char *tool, const char *args) { lp_result result = {0}; lp_status status = lp_call(session, tool, strlen(tool), args, args ? strlen(args) : 0, &result); if (status != LP_OK) { - fprintf(stderr, "%s failed: %d\n", tool, status); + size_t err_len = 0; + const char *err = lp_last_error(session, &err_len); + fprintf(stderr, "%s failed: %d (%.*s)\n", tool, status, (int)err_len, err ? err : ""); return status; } printf("--- %s%s ---\n%.*s\n", tool, result.is_error ? " (page error)" : "", (int)result.len, result.text); diff --git a/include/lightpanda.h b/include/lightpanda.h index 0abb7630ce..755a2c58e5 100644 --- a/include/lightpanda.h +++ b/include/lightpanda.h @@ -173,6 +173,14 @@ uint32_t lp_session_pump(lp_session *session); void lp_session_set_cancel_hook(lp_session *session, bool (*cb)(void *), void *ctx); +/* Diagnostic for the most recent failing call on this session (lp_call) or + * browser (lp_fetch, lp_session_new): an error name such as "Timeout" or + * "CertificateError", more specific than the lp_status code. Empty when the + * last call succeeded; NULL for a NULL handle or after lp_shutdown. len may + * be NULL. Static — do not free. */ +const char *lp_last_error(lp_session *session, size_t *len); +const char *lp_browser_last_error(lp_browser *browser, size_t *len); + /* JSON array of every tool lp_call accepts: * [{"name", "description", "inputSchema"}, ...]. Static — do not free. */ const char *lp_tools_json(void); diff --git a/src/c_api.zig b/src/c_api.zig index a1ab26dca5..4a3c72e1ae 100644 --- a/src/c_api.zig +++ b/src/c_api.zig @@ -114,6 +114,8 @@ const BrowserHandle = struct { // still gets a fresh session. Its isolate parks between calls. fetch_browser: ?lp.Browser, sessions: std.ArrayList(*SessionHandle), + // Static @errorName of the last failing lp_fetch/lp_session_new. + last_error: []const u8, }; const Cancel = struct { @@ -128,6 +130,8 @@ const SessionHandle = struct { // the next lp_call is the header's documented result lifetime. arena: std.heap.ArenaAllocator, cancel: ?Cancel, + // Static @errorName of the last failing lp_call. + last_error: []const u8, }; // V8's platform is process-global and cannot be re-initialized after @@ -196,6 +200,7 @@ fn createBrowser(opts_: ?*const InitOpts) !*BrowserHandle { }; handle.fetch_browser = null; handle.sessions = .empty; + handle.last_error = ""; return handle; } @@ -242,6 +247,7 @@ pub export fn lp_fetch( // until this call begins. The reset precedes the option parsing because // the input strings are duped into this arena. _ = handle.fetch_arena.reset(.{ .retain_with_limit = result_retain_limit }); + handle.last_error = ""; const arena = handle.fetch_arena.allocator(); const url = arena.dupeZ(u8, url_ptr[0..url_len]) catch return .out_of_memory; @@ -279,15 +285,19 @@ pub export fn lp_fetch( browser.env.isolate.enter(); } else { handle.fetch_browser = @as(lp.Browser, undefined); - (&handle.fetch_browser.?).init(handle.app, .{}, null) catch { + (&handle.fetch_browser.?).init(handle.app, .{}, null) catch |err| { handle.fetch_browser = null; + handle.last_error = @errorName(err); return .internal; }; } const browser = &handle.fetch_browser.?; defer browser.env.isolate.exit(); - lp.fetch(handle.app, browser, &.{url}, fetch_opts) catch |err| return errStatus(err); + lp.fetch(handle.app, browser, &.{url}, fetch_opts) catch |err| { + handle.last_error = @errorName(err); + return errStatus(err); + }; const text = writer.written(); out.* = .{ .text = text.ptr, .len = text.len, .is_error = false }; @@ -302,7 +312,11 @@ pub export fn lp_session_new(handle_: ?*BrowserHandle, out_: ?**SessionHandle) S const out = out_ orelse return .misuse; if (app_state != .live) return .misuse; - out.* = createSession(handle) catch |err| return errStatus(err); + out.* = createSession(handle) catch |err| { + handle.last_error = @errorName(err); + return errStatus(err); + }; + handle.last_error = ""; return .ok; } @@ -337,6 +351,7 @@ fn createSession(handle: *BrowserHandle) !*SessionHandle { entry.owner = handle; entry.cancel = null; + entry.last_error = ""; entry.arena = .init(c_allocator); errdefer entry.arena.deinit(); @@ -401,11 +416,13 @@ pub export fn lp_call( // At the start, not on the way out: the previous result must stay // valid until this call begins. _ = entry.arena.reset(.{ .retain_with_limit = result_retain_limit }); + entry.last_error = ""; const arena = entry.arena.allocator(); var args: ?std.json.Value = null; if (args_json_) |args_json| { - args = std.json.parseFromSliceLeaky(std.json.Value, arena, args_json[0..args_json_len], .{}) catch { + args = std.json.parseFromSliceLeaky(std.json.Value, arena, args_json[0..args_json_len], .{}) catch |err| { + entry.last_error = @errorName(err); return .invalid_params; }; } @@ -414,6 +431,7 @@ pub export fn lp_call( defer entry.ts.exitIsolate(); const result = lp.tools.call(arena, entry.ts.session, &entry.ts.registry, tool[0..tool_len], args) catch |err| { + entry.last_error = @errorName(err); return toolStatus(err); }; @@ -454,6 +472,26 @@ fn cancelTrampoline(ctx: *anyopaque) bool { return cancel.cb(cancel.ctx); } +/// Error name of the most recent failing `lp_call` on this session; empty +/// when the last call succeeded. Static storage — do not free. +pub export fn lp_last_error(entry_: ?*SessionHandle, len_: ?*usize) ?[*]const u8 { + if (len_) |len| len.* = 0; + const entry = entry_ orelse return null; + if (app_state != .live) return null; + if (len_) |len| len.* = entry.last_error.len; + return entry.last_error.ptr; +} + +/// Like `lp_last_error`, for the browser-level calls (`lp_fetch`, +/// `lp_session_new`). +pub export fn lp_browser_last_error(handle_: ?*BrowserHandle, len_: ?*usize) ?[*]const u8 { + if (len_) |len| len.* = 0; + const handle = handle_ orelse return null; + if (app_state != .live) return null; + if (len_) |len| len.* = handle.last_error.len; + return handle.last_error.ptr; +} + /// JSON array describing every tool `lp_call` accepts, in the MCP /// tools/list wire shape: [{"name", "description", "inputSchema"}, …]. /// Static storage — do not free. @@ -627,6 +665,8 @@ test "c_api: null handles are rejected" { try testing.expectEqual(.misuse, lp_session_new(null, null)); lp_session_close(null); try testing.expectEqual(null, result.text); + try testing.expectEqual(null, lp_last_error(null, null)); + try testing.expectEqual(null, lp_browser_last_error(null, null)); } test "c_api: lifecycle" { @@ -660,9 +700,18 @@ test "c_api: lifecycle" { try testing.expectEqual(.invalid_params, lp_call(session, "goto", "goto".len, null, 0, &result)); try testing.expectEqual(.invalid_params, lp_call(session, "goto", "goto".len, "not json", "not json".len, &result)); + // The failing call left its error name behind; a successful one clears it. + var err_len: usize = 0; + try testing.expect(lp_last_error(session, &err_len) != null); + try testing.expect(err_len > 0); + try testing.expectEqual(.ok, lp_call(session, "getEnv", "getEnv".len, null, 0, &result)); try testing.expect(result.text != null); try testing.expect(result.len > 0); + _ = lp_last_error(session, &err_len); + try testing.expectEqual(0, err_len); + _ = lp_browser_last_error(browser, &err_len); + try testing.expectEqual(0, err_len); // Lengths are the contract: trailing garbage past them must be ignored. const padded_tool = "getEnvGARBAGE"; From c860215272071be2474f80cbc2aa3d64f6291b25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 3 Aug 2026 09:39:16 +0200 Subject: [PATCH 08/13] telemetry: remove enable_telemetry option Remove the `enable_telemetry` option from `lp_options` and `Config`. Telemetry opt-out is now managed solely via the `LIGHTPANDA_DISABLE_TELEMETRY` environment variable. --- include/lightpanda.h | 6 ++++-- src/App.zig | 2 -- src/Config.zig | 13 +------------ src/c_api.zig | 3 --- src/crash_handler.zig | 7 +------ src/telemetry/telemetry.zig | 5 +---- 6 files changed, 7 insertions(+), 29 deletions(-) diff --git a/include/lightpanda.h b/include/lightpanda.h index 755a2c58e5..815d8326bf 100644 --- a/include/lightpanda.h +++ b/include/lightpanda.h @@ -35,6 +35,9 @@ * * Logging goes to stderr (level: warnings and errors in release builds). * + * Anonymous usage telemetry is enabled in release builds; set + * LIGHTPANDA_DISABLE_TELEMETRY in the environment to opt out. + * * Linking: `make lib` builds liblightpanda.so (zig-out/lib) and * installs this header (zig-out/include) plus a pkg-config file: * cc app.c $(PKG_CONFIG_PATH=zig-out/lib/pkgconfig pkg-config --cflags --libs lightpanda) @@ -90,7 +93,7 @@ typedef struct lp_result { } lp_result; /* Zero-initialize for defaults: no proxy, default user agent, no HTTP - * cache, 5s HTTP timeout, 30s JS watchdog, telemetry off. */ + * cache, 5s HTTP timeout, 30s JS watchdog. */ typedef struct lp_options { const char *user_agent; /* NULL: default ("Lightpanda/1.0") */ size_t user_agent_len; @@ -100,7 +103,6 @@ typedef struct lp_options { size_t http_cache_dir_len; uint32_t http_timeout_ms; /* 0: default (5000) */ int32_t watchdog_ms; /* 0: default (30000), <0: disabled */ - bool enable_telemetry; /* false: no telemetry */ } lp_options; typedef enum lp_format { diff --git a/src/App.zig b/src/App.zig index 55a5cc01a0..4f6e8dad8f 100644 --- a/src/App.zig +++ b/src/App.zig @@ -27,7 +27,6 @@ const Telemetry = @import("telemetry/telemetry.zig").Telemetry; const Storage = @import("storage/Storage.zig"); const Network = @import("network/Network.zig"); const Watchdog = @import("Watchdog.zig"); -const crash_handler = @import("crash_handler.zig"); pub const ArenaPool = @import("ArenaPool.zig"); const log = lp.log; @@ -79,7 +78,6 @@ pub fn init(allocator: Allocator, config: *const Config) !*App { app.app_dir_path = getAndMakeAppDir(allocator); - crash_handler.config_disables_reports = config.telemetryDisabled(); app.telemetry = try Telemetry.init(app, config.command, config.interactive()); errdefer app.telemetry.deinit(allocator); diff --git a/src/Config.zig b/src/Config.zig index 9e67a6eb02..8ae61cb033 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -22,7 +22,6 @@ const lp = @import("lightpanda"); const cli = @import("cli.zig"); const dump = @import("browser/dump.zig"); -const telemetry = @import("telemetry/telemetry.zig"); const Storage = @import("storage/Storage.zig"); const WebBotAuthConfig = @import("network/WebBotAuth.zig").Config; @@ -394,9 +393,7 @@ const Commands = cli.Builder(.{ // programmatically; it is not typeable on the command line. .name = "embed", .hidden = true, - .options = .{ - .{ .name = "enable_telemetry", .type = bool }, - }, + .options = .{}, .shared_options = CommonOptions, }, .{ .name = "version", .options = .{ @@ -442,14 +439,6 @@ pub fn deinit(self: *const Config, allocator: Allocator) void { } } -pub fn telemetryDisabled(self: *const Config) bool { - if (telemetry.isDisabled()) return true; - return switch (self.mode) { - .embed => |opts| !opts.enable_telemetry, - else => false, - }; -} - pub fn interactive(self: *const Config) bool { return switch (self.mode) { .fetch, .embed => false, diff --git a/src/c_api.zig b/src/c_api.zig index 4a3c72e1ae..f8584a558d 100644 --- a/src/c_api.zig +++ b/src/c_api.zig @@ -71,7 +71,6 @@ const InitOpts = extern struct { http_cache_dir_len: usize, http_timeout_ms: u32, watchdog_ms: i32, - enable_telemetry: bool, }; // Mirrored in include/lightpanda.h (lp_fetch_opts). Formats and wait @@ -165,7 +164,6 @@ fn createBrowser(opts_: ?*const InitOpts) !*BrowserHandle { var mode: @FieldType(lp.Config.Mode, "embed") = .{}; if (opts_) |opts| { - mode.enable_telemetry = opts.enable_telemetry; if (opts.user_agent) |ua| { const span = ua[0..opts.user_agent_len]; lp.Config.validateUserAgent(span) catch return error.InvalidParams; @@ -683,7 +681,6 @@ test "c_api: lifecycle" { .http_cache_dir_len = 0, .http_timeout_ms = 0, .watchdog_ms = 0, - .enable_telemetry = false, }; try testing.expectEqual(.invalid_params, lp_init(&bad_opts, &browser)); diff --git a/src/crash_handler.zig b/src/crash_handler.zig index c9dea34b3f..41200409b0 100644 --- a/src/crash_handler.zig +++ b/src/crash_handler.zig @@ -69,17 +69,12 @@ pub noinline fn crash( abort(); } -/// Stamped by App.init from Config.telemetryDisabled so crash reports -/// honor a programmatic opt-out (the C API's); the env check in `report` -/// still covers crashes before an App exists. -pub var config_disables_reports: bool = false; - fn report(reason: []const u8, begin_addr: usize, args: anytype) !void { if (comptime lp.IS_DEBUG) { return; } - if (config_disables_reports or @import("telemetry/telemetry.zig").isDisabled()) { + if (@import("telemetry/telemetry.zig").isDisabled()) { return; } diff --git a/src/telemetry/telemetry.zig b/src/telemetry/telemetry.zig index fbc3d79134..9a1c54c4ba 100644 --- a/src/telemetry/telemetry.zig +++ b/src/telemetry/telemetry.zig @@ -9,9 +9,6 @@ const log = lp.log; const IID_FILE = "iid"; const Allocator = std.mem.Allocator; -/// The env/build-level opt-out. Config.telemetryDisabled folds it into -/// the predicate everything else reads; only crash_handler calls this -/// directly (no Config exists on the crash path). pub fn isDisabled() bool { if (lp.IS_DEBUG or lp.IS_TEST) { return true; @@ -31,7 +28,7 @@ fn TelemetryT(comptime P: type) type { const Self = @This(); pub fn init(app: *App, run_mode: Config.RunMode, interactive: bool) !Self { - const disabled = app.config.telemetryDisabled(); + const disabled = isDisabled(); if (lp.IS_DEBUG == false and lp.IS_TEST == false) { log.info(.telemetry, "telemetry status", .{ .disabled = disabled }); } From ae4303fdd1d93da7e74f241eadc5e78636d6028f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 3 Aug 2026 10:10:45 +0200 Subject: [PATCH 09/13] build: clarify symbol hiding comments --- build.zig | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/build.zig b/build.zig index 1bbaec2206..ee2b8c91e6 100644 --- a/build.zig +++ b/build.zig @@ -22,11 +22,10 @@ const builtin = @import("builtin"); const lightpanda_version = std.SemanticVersion.parse(@import("build.zig.zon").version) catch unreachable; const min_zig_version = std.SemanticVersion.parse(@import("build.zig.zon").minimum_zig_version) catch unreachable; -// Keeps the bundled C libraries out of liblightpanda's export table so a host -// linking its own curl/zlib/... cannot be interposed. The ELF build hides them -// with src/lightpanda.map, but Mach-O has no version-script equivalent, so the -// symbols have to be hidden at compile time. Unconditional: the modules are -// shared with the executable, which exports nothing either way. +// A host linking its own curl/zlib/... must not bind to liblightpanda's +// bundled copies. ELF hides them via src/lightpanda.map; Mach-O has no +// version-script equivalent, so they are hidden at compile time. +// Unconditional: the executable exports nothing either way. const hide_symbols = "-fvisibility=hidden"; const Build = blk: { @@ -251,9 +250,8 @@ pub fn build(b: *Build) !void { shared_lib.version_script = b.path("src/lightpanda.map"); shared_lib.linker_allow_shlib_undefined = false; const install_so = b.addInstallArtifact(shared_lib, .{}); - // Symbol hiding is load-bearing: a host's own OpenSSL/curl/sqlite - // must not collide with the bundled copies. Gate the install on - // the check so an installed library is always a checked one. + // Gate the install on the export check so an installed library + // is always a checked one (see hide_symbols for why). const Query = struct { nm: []const u8, awk: []const u8 }; const leak_query: ?Query = switch (target.result.os.tag) { // The version script keeps everything but lp_* local, so @@ -262,10 +260,9 @@ pub fn build(b: *Build) !void { .nm = "nm -D", .awk = "$2 == \"T\" && $3 !~ /^lp_/ { print $3 }", }, - // Mach-O has no version script, so the bundled C libraries are - // hidden at compile time instead (see hide_symbols). V8's own - // C++ symbols stay exported, so only the C libraries — the ones - // include/lightpanda.h promises — can be asserted absent. + // V8's own C++ symbols stay exported on Mach-O, so only the + // bundled C libraries — the ones include/lightpanda.h + // promises absent — can be asserted. .macos => .{ .nm = "nm -gU", .awk = "$2 == \"T\" && $3 ~ /^_(AES|ASN1|BIO|Brotli|EVP|OPENSSL|RSA|SSL|X509|adler32|crc32|curl|deflate|inflate|nghttp2|sqlite3)/ { print $3 }", From 080c404581530d09b68d7f4b1896ac0da33b366e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 3 Aug 2026 11:12:59 +0200 Subject: [PATCH 10/13] c-api: heap-allocate fetch_browser to ensure pointer stability Ensure lp.Browser remains pointer-stable as it uses self-pointers. Also simplify ToolSession cancel hooks and pkgconfig file generation. --- build.zig | 14 +++++--------- src/c_api.zig | 45 +++++++++++++++++++++++++-------------------- src/lightpanda.zig | 26 +++----------------------- 3 files changed, 33 insertions(+), 52 deletions(-) diff --git a/build.zig b/build.zig index ee2b8c91e6..dbe1376e03 100644 --- a/build.zig +++ b/build.zig @@ -225,7 +225,7 @@ pub fn build(b: *Build) !void { } { - // C API (src/c_api.zig): liblightpanda.so for embedders. + // c api const c_api_module = createCApiModule(b, lightpanda_module); const c_api_check = b.addLibrary(.{ @@ -290,9 +290,7 @@ pub fn build(b: *Build) !void { } lib_step.dependOn(&install_so.step); lib_step.dependOn(&install_header.step); - // The .so resolves its own dependencies, so the link line is - // just the library. - const shared_pc = pkgConfigFile(b, version_string, "-L${libdir} -llightpanda"); + const shared_pc = pkgConfigFile(b, version_string); lib_step.dependOn(&b.addInstallLibFile(shared_pc, "pkgconfig/lightpanda.pc").step); } else { lib_step.dependOn(&b.addFail("lib needs a source-built V8: drop -Dprebuilt_v8_path").step); @@ -319,8 +317,6 @@ pub fn build(b: *Build) !void { } } -/// Root module for a C-API artifact. The ABI tests get their own instance -/// so their test-only header import stays off the .so. fn createCApiModule(b: *Build, lightpanda: *Build.Module) *Build.Module { const mod = b.createModule(.{ .root_source_file = b.path("src/c_api.zig"), @@ -335,7 +331,7 @@ fn createCApiModule(b: *Build, lightpanda: *Build.Module) *Build.Module { return mod; } -fn pkgConfigFile(b: *Build, version: []const u8, libs: []const u8) Build.LazyPath { +fn pkgConfigFile(b: *Build, version: []const u8) Build.LazyPath { return b.addWriteFiles().add("lightpanda.pc", b.fmt( \\prefix=${{pcfiledir}}/../.. \\libdir=${{prefix}}/lib @@ -345,9 +341,9 @@ fn pkgConfigFile(b: *Build, version: []const u8, libs: []const u8) Build.LazyPat \\Description: Lightpanda headless browser C library \\Version: {s} \\Cflags: -I${{includedir}} - \\Libs: {s} + \\Libs: -L${{libdir}} -llightpanda \\ - , .{ version, libs })); + , .{version})); } fn linkV8( diff --git a/src/c_api.zig b/src/c_api.zig index f8584a558d..913206d49e 100644 --- a/src/c_api.zig +++ b/src/c_api.zig @@ -109,9 +109,9 @@ const BrowserHandle = struct { config_arena: std.heap.ArenaAllocator, // Owns the previous lp_fetch result; reset at the start of the next one. fetch_arena: std.heap.ArenaAllocator, - // lp_fetch's browser, created on first use and reused after — each call - // still gets a fresh session. Its isolate parks between calls. - fetch_browser: ?lp.Browser, + // lp_fetch's browser, created on first use; its isolate parks between + // calls. Heap-allocated: Browser registers self-pointers and must not move. + fetch_browser: ?*lp.Browser, sessions: std.ArrayList(*SessionHandle), // Static @errorName of the last failing lp_fetch/lp_session_new. last_error: []const u8, @@ -210,10 +210,11 @@ pub export fn lp_shutdown(handle_: ?*BrowserHandle) void { while (handle.sessions.pop()) |session| destroySession(session); handle.sessions.deinit(c_allocator); - if (handle.fetch_browser) |*browser| { + if (handle.fetch_browser) |browser| { // Browser.deinit's Env.deinit exit balances against this enter. browser.env.isolate.enter(); browser.deinit(); + c_allocator.destroy(browser); } handle.app.deinit(); handle.config.deinit(c_allocator); @@ -279,17 +280,18 @@ pub export fn lp_fetch( // Sessions park their isolate between calls, so entering this // browser's isolate here nests correctly. Browser.init leaves the // isolate entered; the exit below parks it either way. - if (handle.fetch_browser) |*browser| { + if (handle.fetch_browser) |browser| { browser.env.isolate.enter(); } else { - handle.fetch_browser = @as(lp.Browser, undefined); - (&handle.fetch_browser.?).init(handle.app, .{}, null) catch |err| { - handle.fetch_browser = null; + const browser = c_allocator.create(lp.Browser) catch return .out_of_memory; + browser.init(handle.app, .{}, null) catch |err| { + c_allocator.destroy(browser); handle.last_error = @errorName(err); return .internal; }; + handle.fetch_browser = browser; } - const browser = &handle.fetch_browser.?; + const browser = handle.fetch_browser.?; defer browser.env.isolate.exit(); lp.fetch(handle.app, browser, &.{url}, fetch_opts) catch |err| { @@ -356,7 +358,7 @@ fn createSession(handle: *BrowserHandle) !*SessionHandle { try entry.ts.init(handle.app); errdefer entry.ts.deinit(); - entry.ts.setCancelHook(.{ .context = entry, .check = cancelTrampoline }); + entry.ts.session.cancel_hook = .{ .context = entry, .check = cancelTrampoline }; try handle.sessions.append(c_allocator, entry); @@ -473,21 +475,24 @@ fn cancelTrampoline(ctx: *anyopaque) bool { /// Error name of the most recent failing `lp_call` on this session; empty /// when the last call succeeded. Static storage — do not free. pub export fn lp_last_error(entry_: ?*SessionHandle, len_: ?*usize) ?[*]const u8 { - if (len_) |len| len.* = 0; - const entry = entry_ orelse return null; - if (app_state != .live) return null; - if (len_) |len| len.* = entry.last_error.len; - return entry.last_error.ptr; + const entry = entry_ orelse return publishError(null, len_); + return publishError(if (app_state == .live) entry.last_error else null, len_); } /// Like `lp_last_error`, for the browser-level calls (`lp_fetch`, /// `lp_session_new`). pub export fn lp_browser_last_error(handle_: ?*BrowserHandle, len_: ?*usize) ?[*]const u8 { - if (len_) |len| len.* = 0; - const handle = handle_ orelse return null; - if (app_state != .live) return null; - if (len_) |len| len.* = handle.last_error.len; - return handle.last_error.ptr; + const handle = handle_ orelse return publishError(null, len_); + return publishError(if (app_state == .live) handle.last_error else null, len_); +} + +fn publishError(err: ?[]const u8, len_: ?*usize) ?[*]const u8 { + const e = err orelse { + if (len_) |len| len.* = 0; + return null; + }; + if (len_) |len| len.* = e.len; + return e.ptr; } /// JSON array describing every tool `lp_call` accepts, in the MCP diff --git a/src/lightpanda.zig b/src/lightpanda.zig index a5aa8d5ad4..01769456f4 100644 --- a/src/lightpanda.zig +++ b/src/lightpanda.zig @@ -178,25 +178,19 @@ pub fn Once(comptime f: fn () void) type { }; } -/// What a tool-driving embedder owns per isolated browsing context: a Browser -/// (its own V8 isolate), that browser's session, the notification hub, and -/// the node registry `tools.call` needs. Used by the C API. `self` must not -/// move after `init` — Browser registers self-pointers. +/// Everything a tool-driving embedder owns per isolated browsing context. +/// Used by the C API. `self` must not move after `init` — Browser registers +/// self-pointers. pub const ToolSession = struct { browser: Browser, session: *Session, notification: *Notification, registry: CDPNode.Registry, - // The hook is session-scoped; storing it here lets `reset` re-apply it - // so cancellation survives session replacement. - cancel_hook: ?Session.CancelHook, /// Leaves the browser's isolate entered, like `Browser.init`; callers /// sharing one thread between several isolates park it with /// `exitIsolate` afterwards. pub fn init(self: *ToolSession, app: *App) !void { - self.cancel_hook = null; - self.notification = try Notification.init(app.allocator); errdefer self.notification.deinit(); @@ -206,21 +200,7 @@ pub const ToolSession = struct { try self.browser.init(app, .{}, null); errdefer self.browser.deinit(); - try self.reset(); - } - - /// Install a cancellation probe on this and every future session. - pub fn setCancelHook(self: *ToolSession, hook: Session.CancelHook) void { - self.cancel_hook = hook; - self.session.cancel_hook = hook; - } - - /// Replace the browsing session with a fresh one (`Browser.newSession` - /// closes the old one, cookies and all); the stored cancel hook is - /// re-applied. - pub fn reset(self: *ToolSession) !void { self.session = try self.browser.newSession(self.notification); - self.session.cancel_hook = self.cancel_hook; try self.session.enableConsoleCapture(); } From 0d6407bd857d805881d078116b554b0723c38ae1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Tue, 18 Aug 2026 21:44:21 +0200 Subject: [PATCH 11/13] c_api: support embed config and refine shared lib build --- Makefile | 8 ++++---- build.zig | 15 ++++++--------- src/Config.zig | 6 +++--- src/c_api.zig | 12 +++++++++--- 4 files changed, 22 insertions(+), 19 deletions(-) diff --git a/Makefile b/Makefile index c2e24ca8d5..0a64f31412 100644 --- a/Makefile +++ b/Makefile @@ -121,12 +121,12 @@ build-dev: test-lib: @$(ZIG) build $(ZIGFLAGS) test-lib -freference-trace -# No $(ZIGFLAGS): the published prebuilt V8 has an exe-only TLS model, so the -# shared build compiles V8 from source (one-time, ~40 min). +# -Ddev_fast=false: dev_fast links V8 shared, but liblightpanda.so must stay +# self-contained (no DT_NEEDED on libc_v8.so), so lib embeds the static V8. ## Build the C shared library (zig-out/lib + zig-out/include) lib: - @printf "\033[36mBuilding C shared library (first run builds V8 from source)...\033[0m\n" - @$(ZIG) build lib || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;) + @printf "\033[36mBuilding C shared library...\033[0m\n" + @$(ZIG) build lib -Ddev_fast=false || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;) @printf "\033[33mBuild OK: zig-out/lib/liblightpanda.$(LIB_EXT)\033[0m\n" ## Link and run the C example against the shared library (needs network) diff --git a/build.zig b/build.zig index b3e6f42b99..26b5340f21 100644 --- a/build.zig +++ b/build.zig @@ -218,13 +218,10 @@ pub fn build(b: *Build) !void { }); check.dependOn(&c_api_check.step); - const install_header = b.addInstallHeaderFile(b.path("include/lightpanda.h"), "lightpanda.h"); - - // The published prebuilt V8 is exe-only (local-exec TLS, malloc - // shim); the .so needs a source-built V8. Drop this guard once the - // fork releases library-safe archives. - const lib_step = b.step("lib", "Build the C shared library (needs a source-built V8)"); - if (prebuilt_v8_path == null) { + // A shared V8 would leave liblightpanda.so with a DT_NEEDED on + // libc_v8.so; the artifact must stay self-contained. + const lib_step = b.step("lib", "Build the C shared library"); + if (!shared_v8) { const shared_lib = b.addLibrary(.{ .name = "lightpanda", .linkage = .dynamic, @@ -273,11 +270,11 @@ pub fn build(b: *Build) !void { install_so.step.dependOn(&export_check.step); } lib_step.dependOn(&install_so.step); - lib_step.dependOn(&install_header.step); + lib_step.dependOn(&b.addInstallHeaderFile(b.path("include/lightpanda.h"), "lightpanda.h").step); const shared_pc = pkgConfigFile(b, version_string); lib_step.dependOn(&b.addInstallLibFile(shared_pc, "pkgconfig/lightpanda.pc").step); } else { - lib_step.dependOn(&b.addFail("lib needs a source-built V8: drop -Dprebuilt_v8_path").step); + lib_step.dependOn(&b.addFail("lib needs V8 linked statically: pass -Ddev_fast=false and a libc_v8.a (or no) -Dprebuilt_v8_path").step); } // Own binary: the two test suites must not share one V8 platform. diff --git a/src/Config.zig b/src/Config.zig index 5efab5853f..29e8184f92 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -556,7 +556,7 @@ pub fn httpProxy(self: *const Config) ?[:0]const u8 { pub fn httpHeaders(self: *const Config) []const HttpHeader { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.http_header.items, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.http_header.items, else => &.{}, }; } @@ -688,7 +688,7 @@ pub fn httpCacheDir(self: *const Config) ?[]const u8 { pub fn httpCacheEntryLimit(self: *const Config) u32 { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.http_cache_entry_limit.?, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.http_cache_entry_limit.?, else => 1000, }; } @@ -787,7 +787,7 @@ pub fn blockedUrlPatterns(self: *const Config) ?std.mem.SplitIterator(u8, .scala pub fn adblockLists(self: *const Config) ?std.mem.SplitIterator(u8, .scalar) { const paths = switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.adblock_lists, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.adblock_lists, else => unreachable, } orelse return null; return std.mem.splitScalar(u8, paths, ','); diff --git a/src/c_api.zig b/src/c_api.zig index 913206d49e..13a8f450a9 100644 --- a/src/c_api.zig +++ b/src/c_api.zig @@ -274,7 +274,10 @@ pub export fn lp_fetch( } } - var writer: std.Io.Writer.Allocating = .init(arena); + // Seeded so growth doubling doesn't strand superseded buffers in the + // arena (it cannot free them); the seed itself is covered by the + // retained chunk. + var writer = std.Io.Writer.Allocating.initCapacity(arena, result_retain_limit) catch return .out_of_memory; fetch_opts.writer = &writer.writer; // Sessions park their isolate between calls, so entering this @@ -287,7 +290,7 @@ pub export fn lp_fetch( browser.init(handle.app, .{}, null) catch |err| { c_allocator.destroy(browser); handle.last_error = @errorName(err); - return .internal; + return errStatus(err); }; handle.fetch_browser = browser; } @@ -698,7 +701,10 @@ test "c_api: lifecycle" { try testing.expectEqual(.ok, lp_session_new(browser, &session)); var result: Result = .empty; - try testing.expectEqual(.invalid_params, lp_call(session, "nosuchtool", "nosuchtool".len, null, 0, &result)); + // Unknown tool names come back in-band (is_error) so an LLM caller can + // read them; see tools.call. + try testing.expectEqual(.ok, lp_call(session, "nosuchtool", "nosuchtool".len, null, 0, &result)); + try testing.expect(result.is_error); try testing.expectEqual(.invalid_params, lp_call(session, "goto", "goto".len, null, 0, &result)); try testing.expectEqual(.invalid_params, lp_call(session, "goto", "goto".len, "not json", "not json".len, &result)); From 79f6191eeab61aa42dc84fca647886ca944dab9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Tue, 18 Aug 2026 21:48:24 +0200 Subject: [PATCH 12/13] ci: add c-library test job to zig-test workflow --- .github/workflows/zig-test.yml | 36 ++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/.github/workflows/zig-test.yml b/.github/workflows/zig-test.yml index cd8b160423..4ddf768391 100644 --- a/.github/workflows/zig-test.yml +++ b/.github/workflows/zig-test.yml @@ -13,6 +13,8 @@ on: paths: - ".github/**" - "src/**" + - "include/**" + - "examples/**" - "build.zig" - "build.zig.zon" @@ -27,6 +29,8 @@ on: paths: - ".github/**" - "src/**" + - "include/**" + - "examples/**" - "build.zig" - "build.zig.zon" @@ -73,6 +77,38 @@ jobs: - name: zig build test run: zig build -Dprebuilt_v8_path=v8/libc_v8_debug.a -Dtsan=true test + c-library: + name: c library + needs: zig-fmt + + runs-on: ubuntu-latest + timeout-minutes: 15 + + if: github.event.pull_request.draft == false + + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 0 + + - uses: ./.github/actions/install + + - name: zig build test-lib + run: zig build -Dprebuilt_v8_path=v8/libc_v8.a test-lib + + - name: zig build lib + run: zig build -Dprebuilt_v8_path=v8/libc_v8.a lib + + - name: build the C example against the installed library + run: | + cc examples/c/fetch.c $(PKG_CONFIG_PATH=zig-out/lib/pkgconfig pkg-config --cflags --libs lightpanda) \ + -Wl,-rpath,$PWD/zig-out/lib -o fetch-example + + - name: run the example and verify its output + run: | + ./fetch-example https://example.com > out.html + grep -q "Example Domain" out.html + zig-test-release: name: zig test needs: zig-fmt From bbcc2f795d23d891dbbcd84f48773ea9be07fb63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Sun, 23 Aug 2026 17:13:00 +0200 Subject: [PATCH 13/13] config: support embed mode in http nav delay and burst --- src/Config.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Config.zig b/src/Config.zig index fd14f7554c..5374917118 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -586,7 +586,7 @@ pub fn httpMaxHostOpen(self: *const Config) u8 { pub fn httpNavDelay(self: *const Config) ?u32 { const ms = switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.http_nav_delay, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.http_nav_delay, else => unreachable, } orelse return null; return if (ms == 0) null else ms; @@ -594,7 +594,7 @@ pub fn httpNavDelay(self: *const Config) ?u32 { pub fn httpNavBurst(self: *const Config) u32 { const burst = switch (self.mode) { - inline .serve, .fetch, .mcp, .agent => |opts| opts.http_nav_burst, + inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.http_nav_burst, else => unreachable, } orelse 1; return @max(burst, 1);