// 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 . const std = @import("std"); const lp = @import("lightpanda"); const builtin = @import("builtin"); const Allocator = std.mem.Allocator; const log = lp.log; const App = lp.App; const Config = lp.Config; const SigHandler = @import("Sighandler.zig"); pub const panic = lp.crash_handler.panic; pub const std_options: std.Options = .{ .signal_stack_size = null, }; pub fn main(init: std.process.Init) !void { // allocator // - in Debug mode we use the General Purpose Allocator to detect memory leaks // - in Release mode we use the c allocator var gpa_instance: std.heap.DebugAllocator(.{ .stack_trace_frames = 10 }) = .init; const gpa = if (builtin.mode == .Debug) gpa_instance.allocator() else std.heap.c_allocator; defer if (builtin.mode == .Debug) { if (gpa_instance.detectLeaks() != 0) std.process.exit(1); }; // arena for main-specific allocations var main_arena_instance = std.heap.ArenaAllocator.init(gpa); const main_arena = main_arena_instance.allocator(); defer main_arena_instance.deinit(); run(gpa, main_arena, init.minimal.args) catch |err| { if (err == error.UserCancelled) std.process.exit(130); // error.AgentFailed: the agent thread reported the failure in-context. // lp.Agent.UserError: a user-facing message was already printed. if (err == error.AgentFailed or lp.Agent.isUserError(err)) std.process.exit(1); log.fatal(.app, "exit", .{ .err = err }); std.process.exit(1); }; } fn run(allocator: Allocator, main_arena: Allocator, proc_args: std.process.Args) !void { lp.core_dump.disableIfRequested(); const args = try Config.parseArgs(main_arena, proc_args); defer args.deinit(main_arena); switch (args.mode) { .help => |tag| return args.printUsageAndExit(main_arena, tag, true), .version => |opts| { if (opts.check) { try lp.checkVersion(allocator, &args); } else { var stdout = std.Io.File.stdout().writerStreaming(lp.io, &.{}); try stdout.interface.print("{s}\n", .{lp.build_config.version}); } return std.process.cleanExit(lp.io); }, .agent => |opts| if (opts.list_models) { try lp.Agent.listModels(allocator, opts); return std.process.cleanExit(lp.io); }, else => {}, } if (args.logLevel()) |ll| { log.opts.level = ll; } if (args.logFormat()) |lf| { log.opts.format = lf; } // Set log filter scopes. log.opts.scope_enabled = log.resolveFilterScopes(args.logFilterScopes().items); // must be installed before any other threads const sighandler = try main_arena.create(SigHandler); sighandler.* = .{ .arena = main_arena }; try sighandler.install(); // _app is global to handle graceful shutdown. var app = try App.init(allocator, &args); defer app.deinit(); try sighandler.on(lp.Network.stop, .{&app.network}); app.telemetry.record(.{ .run = {} }); defer if (app.config.dumpMetricsOnExit()) { var writer = std.Io.File.stdout().writerStreaming(lp.io, &.{}); lp.metrics.write(&writer.interface); }; switch (args.mode) { .serve => |opts| { log.debug(.app, "startup", .{ .mode = "serve", .snapshot = app.snapshot.fromEmbedded() }); const address = std.Io.net.IpAddress.parse(opts.host, opts.port) catch |err| { log.fatal(.app, "invalid server address", .{ .err = err, .host = opts.host, .port = opts.port }); return args.printUsageAndExit(main_arena, .serve, false); }; var server = lp.Server.init(app, address) catch |err| { if (err == error.AddressInUse) { log.fatal(.app, "address already in use", .{ .host = opts.host, .port = opts.port, .hint = "Another process is already listening on this address. " ++ "Stop the other process or use --port to choose a different port.", }); } else { log.fatal(.app, "server run error", .{ .err = err }); } return err; }; defer server.deinit(); try sighandler.on(lp.Server.shutdown, .{server}); app.network.run(); }, .fetch => |opts| { const urls = opts.url.items; if (urls.len == 0) { log.fatal(.app, "missing URL", .{}); return error.MissingArgument; } // Plain (non-JSON) dump writes one document to stdout with no // framing, so it can't disambiguate more than one page. if (urls.len == 1) { log.debug(.app, "startup", .{ .mode = "fetch", .dump_mode = opts.dump, .url = urls[0], .snapshot = app.snapshot.fromEmbedded(), }); } else { if (opts.json == false) { log.fatal(.app, "multiple URLs require --json", .{}); return error.InvalidArgument; } log.debug(.app, "startup", .{ .mode = "fetch", .dump_mode = opts.dump, .url_count = urls.len, .snapshot = app.snapshot.fromEmbedded(), }); } var fetch_opts = lp.FetchOpts{ .wait_ms = opts.wait_ms, .wait_until = opts.wait_until, .wait_script = opts.wait_script, .inject_script = opts.inject_script, .wait_selector = opts.wait_selector, .dump_mode = opts.dump, .dump = .{ .strip = opts.strip_mode, .with_base = opts.with_base, .with_frames = opts.with_frames, }, .json = opts.json, }; var writer = std.Io.File.stdout().writerStreaming(lp.io, &.{}); if (opts.dump != null or opts.json) { fetch_opts.writer = &writer.interface; } // Browser owns a V8 isolate, which has thread affinity — it must // be init/used/deinit on the same thread (fetchThread, below). So // we can't treat Browser like the above serve path treats Server. // We need Browser to be createdin fetchThread and to get a reference // to it here. var ft: FetchTerminator = .{}; try sighandler.on(FetchTerminator.terminate, .{&ft}); if (opts.terminate_ms) |ms| { try sighandler.deadline(ms); } var worker_thread = try std.Thread.spawn(.{}, fetchThread, .{ app, &ft, urls, fetch_opts }); worker_thread.join(); }, .mcp => |opts| { log.info(.mcp, "starting server", .{}); log.opts.format = .logfmt; // --port serves MCP over HTTP instead of stdio. It and --cdp-port // both need app.network's single listener, so they can't combine. if (opts.port) |port| { if (opts.cdp_port != null) { log.fatal(.mcp, "port conflicts with cdp-port", .{ .hint = "both need the single network listener" }); return; } const address = std.Io.net.IpAddress.parse(opts.host, port) catch |err| { log.fatal(.mcp, "invalid address", .{ .err = err, .host = opts.host, .port = port }); return; }; const http_server = try lp.mcp.HttpServer.init(allocator, app); defer http_server.deinit(); // Shutdown rides the already-registered Network.stop handler: // a signal stops the accept loop, run() returns, deinit joins. http_server.run(address) catch |err| { log.fatal(.mcp, "mcp http error", .{ .err = err }); }; return; } var cdp_server: ?*lp.Server = null; if (opts.cdp_port) |port| { const address = std.Io.net.IpAddress.parse("127.0.0.1", port) catch |err| { log.fatal(.mcp, "invalid cdp address", .{ .err = err, .port = port }); return; }; cdp_server = try lp.Server.init(app, address); try sighandler.on(lp.Server.shutdown, .{cdp_server.?}); } defer if (cdp_server) |s| s.deinit(); var worker_thread = try std.Thread.spawn(.{}, mcpThread, .{ allocator, app }); defer worker_thread.join(); // mcp talks over stdio on mcpThread. Only run the CDP accept/read // loop when an optional CDP server was started; otherwise the main // thread just waits for the worker. if (cdp_server != null) { app.network.run(); } }, .agent => |opts| { log.info(.app, "starting agent", .{}); // Ctrl-C cancels the current turn; signals never kill the // process. `/quit` (or Ctrl-D on an empty prompt) exits. sighandler.no_hard_exit = true; var sig_bridge: lp.Agent.SigBridge = .{}; try sighandler.on(lp.Agent.SigBridge.onSignal, .{&sig_bridge}); var failed: bool = false; var cancelled: bool = false; { var worker_thread = try std.Thread.spawn(.{}, agentThread, .{ allocator, app, opts, &failed, &cancelled, &sig_bridge, }); worker_thread.join(); } if (cancelled) return error.UserCancelled; if (failed) return error.AgentFailed; }, else => unreachable, } } fn agentThread( allocator: std.mem.Allocator, app: *App, opts: Config.Agent, failed: *bool, cancelled: *bool, sig_bridge: *lp.Agent.SigBridge, ) void { defer app.network.stop(); var agent_instance = lp.Agent.init(allocator, app, opts) catch |err| { if (err == error.UserCancelled) { cancelled.* = true; } else { // UserError: message already printed inside Agent.init. if (!lp.Agent.isUserError(err)) log.fatal(.app, "agent init error", .{ .err = err }); failed.* = true; } return; }; sig_bridge.attach(agent_instance); defer agent_instance.deinit(); defer sig_bridge.detach(); if (agent_instance.ai_client) |cli| { app.telemetry.record(.{ .llm = app.telemetry.llm_init(@tagName(cli), agent_instance.model), }); } else { app.telemetry.record(.{ .llm = app.telemetry.llm_init("nollm", null), }); } if (!agent_instance.run()) { failed.* = true; } } const FetchTerminator = struct { mutex: std.Io.Mutex = .init, browser: ?*lp.Browser = null, fn storeBrowser(self: *FetchTerminator, browser: *lp.Browser) void { self.mutex.lockUncancelable(lp.io); defer self.mutex.unlock(lp.io); self.browser = browser; } fn releaseBrowser(self: *FetchTerminator) void { self.mutex.lockUncancelable(lp.io); defer self.mutex.unlock(lp.io); const b = self.browser orelse return; b.env.cancelTerminate(); self.browser = null; } fn terminate(self: *FetchTerminator) void { self.mutex.lockUncancelable(lp.io); defer self.mutex.unlock(lp.io); const b = self.browser orelse return; b.env.terminate(); self.browser = null; } }; fn fetchThread(app: *App, ft: *FetchTerminator, urls: []const [:0]const u8, fetch_opts: lp.FetchOpts) void { defer app.network.stop(); var browser: lp.Browser = undefined; browser.init(app, .{}, null) catch |err| { log.fatal(.app, "browser init error", .{ .err = err }); return; }; defer browser.deinit(); ft.storeBrowser(&browser); // if this exits normally, we want to disarm the FetchTerminator so that // any subsequent sighandlers don't try to shutdown an already (or in-the- // process-of) shutting down browser/env defer ft.releaseBrowser(); lp.fetch(app, &browser, urls, fetch_opts) catch |err| { log.fatal(.app, "fetch error", .{ .err = err, .url_count = urls.len }); }; } fn mcpThread(allocator: std.mem.Allocator, app: *App) void { defer app.network.stop(); var stdout = std.Io.File.stdout().writerStreaming(lp.io, &.{}); var mcp_server: *lp.mcp.Server = lp.mcp.Server.init(allocator, app, &stdout.interface) catch |err| { log.fatal(.mcp, "mcp init error", .{ .err = err }); return; }; defer mcp_server.deinit(); var stdin_buf: [64 * 1024]u8 = undefined; var stdin = std.Io.File.stdin().readerStreaming(lp.io, &stdin_buf); lp.mcp.router.processRequests(mcp_server, &stdin.interface, std.Io.File.stdin()) catch |err| { log.fatal(.mcp, "mcp error", .{ .err = err }); }; }