From b97ca18dcbfe6987b827588334969b35817a5a1f Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Thu, 25 Jun 2026 17:34:01 +0300 Subject: [PATCH 01/21] `Network`: mark `globalInit` and `globalDeinit` as `pub` --- src/network/Network.zig | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/network/Network.zig b/src/network/Network.zig index f637c872d..842ef9113 100644 --- a/src/network/Network.zig +++ b/src/network/Network.zig @@ -138,7 +138,9 @@ cdp_start: usize, /// Optional IP filter for blocking requests to private/internal networks (--block-private-networks). ip_filter: ?*IpFilter = null, -fn globalInit(allocator: Allocator) void { +/// Calling `init` also calls this function; only marked public for situations +/// networking is needed without `App`. +pub fn globalInit(allocator: Allocator) void { // Only route curl's own allocations through our allocator in Debug, so the // leak detector sees them. In Release it'd just wrap c_allocator (curl's // default malloc anyway) at the cost of a per-allocation header. @@ -152,7 +154,9 @@ fn globalInit(allocator: Allocator) void { }; } -fn globalDeinit() void { +/// Calling `deinit` also calls this function; only marked public for situations +/// networking is needed without `App`. +pub fn globalDeinit() void { libcurl.curl_global_cleanup(); } From 03c51052b02a93a0d9d3d39cb8bae093ff3c8584 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Thu, 25 Jun 2026 17:37:59 +0300 Subject: [PATCH 02/21] `Config`: add `update` command --- src/Config.zig | 36 ++++++++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/src/Config.zig b/src/Config.zig index c5c574f8b..992201a8e 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -18,6 +18,7 @@ const std = @import("std"); const lp = @import("lightpanda"); +const Updater = lp.Updater; const log = lp.log; const builtin = @import("builtin"); const zenai = @import("zenai"); @@ -188,6 +189,10 @@ fn injectScriptFileValidator( return list.append(allocator, bytes); } +fn nightlyChannelValidator(_: Allocator, _: *std.process.ArgIterator) !Updater.Channel { + return .nightly; +} + /// Definition for all the commands and its arguments. See @cli.zig for further. const Commands = cli.Builder(.{ .{ @@ -262,6 +267,20 @@ const Commands = cli.Builder(.{ }, .shared_options = CommonOptions, }, + .{ + .name = "update", + .options = .{ + .{ + .name = "channel", + .type = Updater.Channel, + .default = .stable, + .variants = .{ + .{ .name = "nightly", .validator = nightlyChannelValidator }, + }, + }, + }, + .shared_options = CommonOptions, + }, .{ .name = "version", .options = .{} }, }); @@ -355,7 +374,7 @@ pub fn v8MaxHeapMb(self: *const Config) ?u32 { 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, .update => |opts| opts.http_proxy, else => unreachable, }; } @@ -363,7 +382,7 @@ 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, - .help, .version => null, + else => null, }; } @@ -383,14 +402,14 @@ pub fn httpMaxHostOpen(self: *const Config) u8 { 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, .update => |opts| opts.http_connect_timeout orelse 0, else => unreachable, }; } 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, .update => |opts| opts.http_timeout orelse 5000, else => unreachable, }; } @@ -401,7 +420,7 @@ 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, .update => |opts| opts.http_max_response_size, else => unreachable, }; } @@ -465,14 +484,14 @@ pub fn logFilterScopes(self: *const Config) std.ArrayList(log.FilterRule) { pub fn userAgentSuffix(self: *const Config) ?[]const u8 { return switch (self.mode) { inline .serve, .fetch, .mcp, .agent => |opts| opts.user_agent_suffix, - .help, .version => null, + else => null, }; } pub fn userAgent(self: *const Config) ?[]const u8 { return switch (self.mode) { inline .serve, .fetch, .mcp, .agent => |opts| opts.user_agent, - .help, .version => null, + else => null, }; } @@ -520,7 +539,7 @@ pub fn webBotAuth(self: *const Config) ?WebBotAuthConfig { .keyid = opts.web_bot_auth_keyid orelse return null, .domain = opts.web_bot_auth_domain orelse return null, }, - .help, .version => null, + else => null, }; } @@ -700,6 +719,7 @@ pub fn printUsageAndExit(self: *const Config, help_for: RunMode, success: bool) , .{ @field(Help, @tagName(tag)), Help.common_options }); std.debug.print(template, .{ exec_name, info_or_warn, pretty_or_logfmt }); }, + .update => @panic("TODO"), .version => { const template = Help.version ++ "\n"; std.debug.print(template, .{exec_name}); From a716279e301b798c2b6773ab7fba3e0a39bda580 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Thu, 25 Jun 2026 17:39:03 +0300 Subject: [PATCH 03/21] `Updater`: introduce `Updater` Updater is a simple interface that does web requests for version comparison without needing `App` instance. --- src/Updater.zig | 148 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 src/Updater.zig diff --git a/src/Updater.zig b/src/Updater.zig new file mode 100644 index 000000000..01da2c165 --- /dev/null +++ b/src/Updater.zig @@ -0,0 +1,148 @@ +// 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 Network = @import("network/Network.zig"); +const http = @import("network/http.zig"); +const libcurl = @import("sys/libcurl.zig"); +const Config = @import("Config.zig"); +const log = @import("log.zig"); + +// TODO: Remove this. +const MockVersion: [:0]const u8 = "0.16.0"; + +/// Where to find versions JSON. +const VersionsUrl: [:0]const u8 = "https://ziglang.org/download/index.json"; + +pub const Channel = enum(u1) { stable, nightly }; + +/// Sole purpose of this client is to do updates; hence, its very minimal. +const Updater = @This(); +arena: std.heap.ArenaAllocator, +ca_blob: libcurl.CurlBlob, +/// Connection where client does all of its network I/O. +conn: http.Connection, +/// Read buffer for `conn`. +conn_read_buffer: std.ArrayList(u8) = .empty, +/// Needed to report OutOfMemory. +conn_err: enum(u1) { none, out_of_memory } = .none, +/// TODO: Come up with a solution where we don't have to embed this here? +config: *const Config, + +/// Initializes the update client; meant to be used as singleton. +pub fn init(allocator: std.mem.Allocator, config: *const Config) !Updater { + var arena = std.heap.ArenaAllocator.init(allocator); + errdefer arena.deinit(); + const arena_allocator = arena.allocator(); + + Network.globalInit(allocator); + errdefer Network.globalDeinit(); + // On error, will be deinitialized by arena. + const ca_blob = try Network.loadCerts(arena_allocator); + // Init connection. + const connection = try http.Connection.init(ca_blob, config, null); + errdefer connection.deinit(); + + return .{ + .arena = arena, + .ca_blob = ca_blob, + .conn = connection, + .config = config, + }; +} + +pub fn deinit(self: *Updater) void { + self.conn.deinit(); + Network.globalDeinit(); + self.arena.deinit(); +} + +// TODO: Update this with actual versions.json when ready. +const Versions = struct { + master: Entry, + @"0.16.0": Entry, // Consider this as stable. + + /// Single version record. + const Entry = struct { + version: []const u8, + src: struct { + tarball: []const u8, + shasum: []const u8, + size: u64, + }, + }; +}; + +/// Returns Lightpanda versions; call `resetConnection` after you're done with +/// `Versions` to do further requests. +fn getVersions(self: *Updater) !Versions { + try self.conn.setURL(VersionsUrl); + try self.conn.setGetMode(); + try self.conn.setFollowLocation(true); + try self.conn.setWriteCallback(onBytes); + + const status = self.conn.request(&self.config.http_headers) catch |err| { + switch (self.conn_err) { + .none => return err, + .out_of_memory => return error.OutOfMemory, + } + }; + if (status != 200) { + return error.UnexpectedStatus; + } + + return std.json.parseFromSliceLeaky( + Versions, + self.arena.allocator(), + self.conn_read_buffer.items, + .{ + .ignore_unknown_fields = true, + .allocate = .alloc_if_needed, + .parse_numbers = true, + }, + ); +} + +/// Resets everything related to `conn`. +fn resetConnection(self: *Updater) !void { + try self.conn.reset(self.config, self.ca_blob, null); + self.conn_read_buffer.clearRetainingCapacity(); + self.conn_err = .none; +} + +/// Informs about running version to given `Writer` by desired `Channel`. +pub fn inform(self: *Updater, channel: Channel, writer: std.Io.Writer) void { + const versions = try self.getVersions(); +} + +/// Invoked by `Connection` when there are body bytes. +fn onBytes(buffer: [*]const u8, buf_count: usize, buf_len: usize, raw_conn: ?*anyopaque) usize { + const conn: *http.Connection = @ptrCast(@alignCast(raw_conn)); + const self: *Updater = @fieldParentPtr("conn", conn); + + const chunk = buffer[0 .. buf_count * buf_len]; + self.conn_read_buffer.appendSlice(self.arena.allocator(), chunk) catch |err| { + if (err != error.OutOfMemory) unreachable; + // We have to do this in order to report errors from here. + self.conn_err = .out_of_memory; + return 0; + }; + return chunk.len; +} From 6919400c4272cc0e0b441f3c392703fffaf3d9f6 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Thu, 25 Jun 2026 17:40:44 +0300 Subject: [PATCH 04/21] add `update` command and make it runnable in main --- src/lightpanda.zig | 12 ++++++++++++ src/main.zig | 4 ++++ 2 files changed, 16 insertions(+) diff --git a/src/lightpanda.zig b/src/lightpanda.zig index 4dfdbc437..21840e2f4 100644 --- a/src/lightpanda.zig +++ b/src/lightpanda.zig @@ -56,6 +56,8 @@ pub const build_config = @import("build_config"); pub const crash_handler = @import("crash_handler.zig"); pub const core_dump = @import("core_dump.zig"); +pub const Updater = @import("Updater.zig"); + pub const FetchOpts = struct { wait_ms: u32 = 5000, wait_until: ?Config.WaitUntil = null, @@ -223,6 +225,16 @@ fn dumpContent(app: *App, mode: Config.DumpFormat, dump_opts: dump.Opts, frame: } } +/// Check `Config.zig` for `options`. +pub fn update(allocator: std.mem.Allocator, config: *const Config, options: anytype) !void { + if (options.channel == .nightly) @panic("TODO"); + + var client = try Updater.init(allocator, config); + defer client.deinit(); + + try client.getVersions(); +} + // Writes a single page's result object. Framing (the enclosing array and any // separators / trailing newline) is the caller's responsibility. fn writeJsonEnvelope(writer: *std.Io.Writer, frame: ?*Frame, dump_mode: ?Config.DumpFormat, content: []const u8) !void { diff --git a/src/main.zig b/src/main.zig index ca0b75325..a733b0ba6 100644 --- a/src/main.zig +++ b/src/main.zig @@ -80,6 +80,10 @@ fn run(allocator: Allocator, main_arena: Allocator) !void { try lp.Agent.listModels(allocator, opts); return std.process.cleanExit(); }, + .update => |options| { + try lp.update(allocator, &args, options); + return std.process.cleanExit(); + }, else => {}, } From 7bea3319e84ab06d91778432b5830027fc299aca Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Fri, 26 Jun 2026 02:29:13 +0300 Subject: [PATCH 05/21] `Updater`: update versions.json URL --- src/Updater.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Updater.zig b/src/Updater.zig index 01da2c165..9014f7097 100644 --- a/src/Updater.zig +++ b/src/Updater.zig @@ -29,7 +29,7 @@ const log = @import("log.zig"); const MockVersion: [:0]const u8 = "0.16.0"; /// Where to find versions JSON. -const VersionsUrl: [:0]const u8 = "https://ziglang.org/download/index.json"; +const VersionsUrl: [:0]const u8 = "https://get.lightpanda.io/versions.json"; pub const Channel = enum(u1) { stable, nightly }; From bbd53e4d834bbecc7bd88cccf56f6c754705b460 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Fri, 26 Jun 2026 02:30:14 +0300 Subject: [PATCH 06/21] `Updater`: prefer arbitrary JSON union for parsing versions.json --- src/Updater.zig | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/src/Updater.zig b/src/Updater.zig index 9014f7097..cc6e11be7 100644 --- a/src/Updater.zig +++ b/src/Updater.zig @@ -74,25 +74,9 @@ pub fn deinit(self: *Updater) void { self.arena.deinit(); } -// TODO: Update this with actual versions.json when ready. -const Versions = struct { - master: Entry, - @"0.16.0": Entry, // Consider this as stable. - - /// Single version record. - const Entry = struct { - version: []const u8, - src: struct { - tarball: []const u8, - shasum: []const u8, - size: u64, - }, - }; -}; - /// Returns Lightpanda versions; call `resetConnection` after you're done with /// `Versions` to do further requests. -fn getVersions(self: *Updater) !Versions { +fn getVersions(self: *Updater) !std.json.Value { try self.conn.setURL(VersionsUrl); try self.conn.setGetMode(); try self.conn.setFollowLocation(true); @@ -109,7 +93,7 @@ fn getVersions(self: *Updater) !Versions { } return std.json.parseFromSliceLeaky( - Versions, + std.json.Value, self.arena.allocator(), self.conn_read_buffer.items, .{ From 6acb58eff4d733bc1efff68d28c560b410e6e3d6 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Fri, 26 Jun 2026 02:30:41 +0300 Subject: [PATCH 07/21] `Updater`: continue on implementing `Updater.inform` --- src/Updater.zig | 36 +++++++++++++++++++++++++++++++++++- src/lightpanda.zig | 9 ++++++--- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/Updater.zig b/src/Updater.zig index cc6e11be7..cba547d20 100644 --- a/src/Updater.zig +++ b/src/Updater.zig @@ -111,9 +111,43 @@ fn resetConnection(self: *Updater) !void { self.conn_err = .none; } +const SortContext = struct { + object: std.json.ObjectMap, + + pub fn lessThan(ctx: SortContext, a_index: usize, b_index: usize) bool { + const keys = ctx.object.keys(); + const a_version = std.SemanticVersion.parse(keys[a_index]) catch unreachable; + const b_version = std.SemanticVersion.parse(keys[b_index]) catch unreachable; + return a_version.order(b_version).compare(.gt); + } +}; + /// Informs about running version to given `Writer` by desired `Channel`. -pub fn inform(self: *Updater, channel: Channel, writer: std.Io.Writer) void { +pub fn inform(self: *Updater, channel: Channel, writer: *std.Io.Writer) !void { const versions = try self.getVersions(); + defer self.resetConnection() catch {}; + + switch (channel) { + .stable => { + var stable = versions.object; + // Get rid of `nightly`. + lp.assert(stable.swapRemove("nightly"), "Updater.inform: incorrect JSON", .{}); + + // Sort and retrieve the newest. + stable.sort(SortContext{ .object = stable }); + // Newest is at the beginning. + const newest = stable.values()[0].object; + const version = (newest.get("version") orelse return error.IncorrectJson).string; + try writer.print("Latest stable Lightpanda version is {s}.\n", .{version}); + return writer.flush(); + }, + .nightly => { + const nightly = (versions.object.get("nightly") orelse return error.IncorrectJson).object; + const version = (nightly.get("version") orelse return error.IncorrectJson).string; + try writer.print("Latest nightly Lightpanda version is {s}.\n", .{version}); + return writer.flush(); + }, + } } /// Invoked by `Connection` when there are body bytes. diff --git a/src/lightpanda.zig b/src/lightpanda.zig index 21840e2f4..a6e3951bf 100644 --- a/src/lightpanda.zig +++ b/src/lightpanda.zig @@ -227,12 +227,15 @@ fn dumpContent(app: *App, mode: Config.DumpFormat, dump_opts: dump.Opts, frame: /// Check `Config.zig` for `options`. pub fn update(allocator: std.mem.Allocator, config: *const Config, options: anytype) !void { - if (options.channel == .nightly) @panic("TODO"); - var client = try Updater.init(allocator, config); defer client.deinit(); - try client.getVersions(); + var stdout = std.fs.File.stdout(); + var buf: [4096]u8 = undefined; + var writer = stdout.writer(&buf); + const w = &writer.interface; + // Inform to stdout about version. + try client.inform(options.channel, w); } // Writes a single page's result object. Framing (the enclosing array and any From 4c62337cfa2e92141ff5edf74d614745011a08e6 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Fri, 26 Jun 2026 16:14:05 +0300 Subject: [PATCH 08/21] `Updater`: prefer JSON structures specific to update channel --- src/Updater.zig | 109 +++++++++++++++++++++++++++++++++++------------- 1 file changed, 79 insertions(+), 30 deletions(-) diff --git a/src/Updater.zig b/src/Updater.zig index cba547d20..2c9e41ec5 100644 --- a/src/Updater.zig +++ b/src/Updater.zig @@ -17,6 +17,8 @@ // along with this program. If not, see . const std = @import("std"); +const json = std.json; +const SemanticVersion = std.SemanticVersion; const lp = @import("lightpanda"); const Network = @import("network/Network.zig"); @@ -74,26 +76,54 @@ pub fn deinit(self: *Updater) void { self.arena.deinit(); } -/// Returns Lightpanda versions; call `resetConnection` after you're done with -/// `Versions` to do further requests. -fn getVersions(self: *Updater) !std.json.Value { +const Version = struct { + @"aarch64-linux": Entry, + @"aarch64-macos": Entry, + date: []const u8, + version: []const u8, + @"x86_64-linux": Entry, + @"x86_64-macos": Entry, + + const Entry = struct { + download_url: []const u8, + shasum: []const u8, + size: u64, + }; +}; + +const Nightly = struct { nightly: Version }; + +/// Returns Lightpanda versions for a channel; call `resetConnection` after +/// you're done with `Versions` to do further requests. +fn getVersions( + self: *Updater, + comptime channel: Channel, +) !switch (channel) { + .stable => json.ArrayHashMap(Version), + .nightly => Nightly, // Only care about nightly record. +} { try self.conn.setURL(VersionsUrl); try self.conn.setGetMode(); try self.conn.setFollowLocation(true); try self.conn.setWriteCallback(onBytes); - const status = self.conn.request(&self.config.http_headers) catch |err| { + const status_int = self.conn.request(&self.config.http_headers) catch |err| { switch (self.conn_err) { .none => return err, .out_of_memory => return error.OutOfMemory, } }; - if (status != 200) { + const status: std.http.Status = @enumFromInt(status_int); + if (status != .ok) { return error.UnexpectedStatus; } - return std.json.parseFromSliceLeaky( - std.json.Value, + const Json = switch (channel) { + .stable => json.ArrayHashMap(Version), + .nightly => Nightly, + }; + return json.parseFromSliceLeaky( + Json, self.arena.allocator(), self.conn_read_buffer.items, .{ @@ -111,40 +141,59 @@ fn resetConnection(self: *Updater) !void { self.conn_err = .none; } -const SortContext = struct { - object: std.json.ObjectMap, +const SortCtx = struct { + keys: []const []const u8, + /// Carries the error that might happen while sorting. + err: anyerror!void = {}, - pub fn lessThan(ctx: SortContext, a_index: usize, b_index: usize) bool { - const keys = ctx.object.keys(); - const a_version = std.SemanticVersion.parse(keys[a_index]) catch unreachable; - const b_version = std.SemanticVersion.parse(keys[b_index]) catch unreachable; - return a_version.order(b_version).compare(.gt); + pub fn lessThan(ctx: *SortCtx, a_index: usize, b_index: usize) bool { + const keys = ctx.keys; + const a_version = SemanticVersion.parse(keys[a_index]) catch |err| { + ctx.err = err; + return false; + }; + const b_version = SemanticVersion.parse(keys[b_index]) catch |err| { + ctx.err = err; + return false; + }; + + return a_version.order(b_version).compare(.lt); } }; /// Informs about running version to given `Writer` by desired `Channel`. pub fn inform(self: *Updater, channel: Channel, writer: *std.Io.Writer) !void { - const versions = try self.getVersions(); - defer self.resetConnection() catch {}; - switch (channel) { .stable => { - var stable = versions.object; - // Get rid of `nightly`. - lp.assert(stable.swapRemove("nightly"), "Updater.inform: incorrect JSON", .{}); + var versions = (try self.getVersions(.stable)).map; + defer self.resetConnection() catch {}; - // Sort and retrieve the newest. - stable.sort(SortContext{ .object = stable }); - // Newest is at the beginning. - const newest = stable.values()[0].object; - const version = (newest.get("version") orelse return error.IncorrectJson).string; - try writer.print("Latest stable Lightpanda version is {s}.\n", .{version}); - return writer.flush(); + // Remove "nightly" entry. + lp.assert(versions.swapRemove("nightly"), "Updater.inform: \"nightly\" entry not found", .{}); + // Sorting is necessary. + var sort_ctx = SortCtx{ .keys = versions.keys() }; + versions.sort(&sort_ctx); + sort_ctx.err catch |err| return err; + + // Get the latest. + const values = versions.values(); + const top = values[values.len - 1]; + + std.debug.print("{s}\n", .{top.version}); }, .nightly => { - const nightly = (versions.object.get("nightly") orelse return error.IncorrectJson).object; - const version = (nightly.get("version") orelse return error.IncorrectJson).string; - try writer.print("Latest nightly Lightpanda version is {s}.\n", .{version}); + const versions = try self.getVersions(.nightly); + defer self.resetConnection() catch {}; + const nightly = versions.nightly; + // Parse to SemVer for comparison. + const semver_nightly = try SemanticVersion.parse(nightly.version); + const semver_current = try SemanticVersion.parse(lp.build_config.version); + + switch (semver_current.order(semver_nightly)) { + .lt => try writer.print("A new version of Lightpanda nightly ({s}) is available.\n", .{nightly.version}), + .eq => try writer.writeAll("You're up-to-date."), + .gt => unreachable, + } return writer.flush(); }, } From 32fefeaa7a61bdd6bba4b24f240a09be02a142fd Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Mon, 29 Jun 2026 13:57:50 +0300 Subject: [PATCH 09/21] `update`: remove nightly channel check --- src/Config.zig | 20 +------------------- src/lightpanda.zig | 4 ++-- src/main.zig | 4 ++-- 3 files changed, 5 insertions(+), 23 deletions(-) diff --git a/src/Config.zig b/src/Config.zig index 992201a8e..9c2051ebf 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -18,7 +18,6 @@ const std = @import("std"); const lp = @import("lightpanda"); -const Updater = lp.Updater; const log = lp.log; const builtin = @import("builtin"); const zenai = @import("zenai"); @@ -189,10 +188,6 @@ fn injectScriptFileValidator( return list.append(allocator, bytes); } -fn nightlyChannelValidator(_: Allocator, _: *std.process.ArgIterator) !Updater.Channel { - return .nightly; -} - /// Definition for all the commands and its arguments. See @cli.zig for further. const Commands = cli.Builder(.{ .{ @@ -267,20 +262,7 @@ const Commands = cli.Builder(.{ }, .shared_options = CommonOptions, }, - .{ - .name = "update", - .options = .{ - .{ - .name = "channel", - .type = Updater.Channel, - .default = .stable, - .variants = .{ - .{ .name = "nightly", .validator = nightlyChannelValidator }, - }, - }, - }, - .shared_options = CommonOptions, - }, + .{ .name = "update", .options = .{}, .shared_options = CommonOptions }, .{ .name = "version", .options = .{} }, }); diff --git a/src/lightpanda.zig b/src/lightpanda.zig index a6e3951bf..f7ec92c8b 100644 --- a/src/lightpanda.zig +++ b/src/lightpanda.zig @@ -226,7 +226,7 @@ fn dumpContent(app: *App, mode: Config.DumpFormat, dump_opts: dump.Opts, frame: } /// Check `Config.zig` for `options`. -pub fn update(allocator: std.mem.Allocator, config: *const Config, options: anytype) !void { +pub fn update(allocator: std.mem.Allocator, config: *const Config) !void { var client = try Updater.init(allocator, config); defer client.deinit(); @@ -235,7 +235,7 @@ pub fn update(allocator: std.mem.Allocator, config: *const Config, options: anyt var writer = stdout.writer(&buf); const w = &writer.interface; // Inform to stdout about version. - try client.inform(options.channel, w); + try client.inform(w); } // Writes a single page's result object. Framing (the enclosing array and any diff --git a/src/main.zig b/src/main.zig index a733b0ba6..8d8442f0b 100644 --- a/src/main.zig +++ b/src/main.zig @@ -80,8 +80,8 @@ fn run(allocator: Allocator, main_arena: Allocator) !void { try lp.Agent.listModels(allocator, opts); return std.process.cleanExit(); }, - .update => |options| { - try lp.update(allocator, &args, options); + .update => { + try lp.update(allocator, &args); return std.process.cleanExit(); }, else => {}, From 94239e123b70f4e2b392540f34ae5666f0c79ccf Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Mon, 29 Jun 2026 13:58:44 +0300 Subject: [PATCH 10/21] `Updater`: finalize `inform` --- src/Updater.zig | 108 ++++++++++++++++++++++++++---------------------- 1 file changed, 58 insertions(+), 50 deletions(-) diff --git a/src/Updater.zig b/src/Updater.zig index 2c9e41ec5..c2f7df69b 100644 --- a/src/Updater.zig +++ b/src/Updater.zig @@ -19,6 +19,7 @@ const std = @import("std"); const json = std.json; const SemanticVersion = std.SemanticVersion; +const Allocator = std.mem.Allocator; const lp = @import("lightpanda"); const Network = @import("network/Network.zig"); @@ -27,9 +28,6 @@ const libcurl = @import("sys/libcurl.zig"); const Config = @import("Config.zig"); const log = @import("log.zig"); -// TODO: Remove this. -const MockVersion: [:0]const u8 = "0.16.0"; - /// Where to find versions JSON. const VersionsUrl: [:0]const u8 = "https://get.lightpanda.io/versions.json"; @@ -44,12 +42,12 @@ conn: http.Connection, /// Read buffer for `conn`. conn_read_buffer: std.ArrayList(u8) = .empty, /// Needed to report OutOfMemory. -conn_err: enum(u1) { none, out_of_memory } = .none, +conn_err: Allocator.Error!void = {}, /// TODO: Come up with a solution where we don't have to embed this here? config: *const Config, /// Initializes the update client; meant to be used as singleton. -pub fn init(allocator: std.mem.Allocator, config: *const Config) !Updater { +pub fn init(allocator: Allocator, config: *const Config) !Updater { var arena = std.heap.ArenaAllocator.init(allocator); errdefer arena.deinit(); const arena_allocator = arena.allocator(); @@ -108,10 +106,8 @@ fn getVersions( try self.conn.setWriteCallback(onBytes); const status_int = self.conn.request(&self.config.http_headers) catch |err| { - switch (self.conn_err) { - .none => return err, - .out_of_memory => return error.OutOfMemory, - } + self.conn_err catch |conn_err| return conn_err; + return err; }; const status: std.http.Status = @enumFromInt(status_int); if (status != .ok) { @@ -135,10 +131,19 @@ fn getVersions( } /// Resets everything related to `conn`. -fn resetConnection(self: *Updater) !void { - try self.conn.reset(self.config, self.ca_blob, null); +fn resetConnection(self: *Updater) void { + self.conn.reset(self.config, self.ca_blob, null) catch {}; self.conn_read_buffer.clearRetainingCapacity(); - self.conn_err = .none; + self.conn_err = {}; +} + +fn versioning() []const u8 { + comptime { + const version = SemanticVersion.parse(lp.build_config.version) catch unreachable; + const pre = version.pre orelse return ""; + const index = std.mem.indexOfScalar(u8, pre, '.') orelse pre.len; + return pre[0..index]; + } } const SortCtx = struct { @@ -161,42 +166,47 @@ const SortCtx = struct { } }; -/// Informs about running version to given `Writer` by desired `Channel`. -pub fn inform(self: *Updater, channel: Channel, writer: *std.Io.Writer) !void { - switch (channel) { - .stable => { - var versions = (try self.getVersions(.stable)).map; - defer self.resetConnection() catch {}; - - // Remove "nightly" entry. - lp.assert(versions.swapRemove("nightly"), "Updater.inform: \"nightly\" entry not found", .{}); - // Sorting is necessary. - var sort_ctx = SortCtx{ .keys = versions.keys() }; - versions.sort(&sort_ctx); - sort_ctx.err catch |err| return err; - - // Get the latest. - const values = versions.values(); - const top = values[values.len - 1]; - - std.debug.print("{s}\n", .{top.version}); - }, - .nightly => { - const versions = try self.getVersions(.nightly); - defer self.resetConnection() catch {}; - const nightly = versions.nightly; - // Parse to SemVer for comparison. - const semver_nightly = try SemanticVersion.parse(nightly.version); - const semver_current = try SemanticVersion.parse(lp.build_config.version); - - switch (semver_current.order(semver_nightly)) { - .lt => try writer.print("A new version of Lightpanda nightly ({s}) is available.\n", .{nightly.version}), - .eq => try writer.writeAll("You're up-to-date."), - .gt => unreachable, - } - return writer.flush(); - }, +/// Informs about running version to given `Writer`. +pub fn inform(self: *Updater, writer: *std.Io.Writer) !void { + const kind = comptime versioning(); + if (comptime std.mem.eql(u8, "dev", kind)) { + try writer.print("Running a development version of Lightpanda ({s}).\n", .{lp.build_config.version}); + return writer.flush(); } + if (comptime std.mem.eql(u8, "nightly", kind)) { + try writer.print("Running a nightly version of Lightpanda ({s}).\n", .{lp.build_config.version}); + return writer.flush(); + } + + var versions = (try self.getVersions(.stable)).map; + defer self.resetConnection(); + + // Remove "nightly" entry. + lp.assert(versions.swapRemove("nightly"), "Updater.inform: \"nightly\" entry not found", .{}); + // Sorting is necessary. + var sort_ctx = SortCtx{ .keys = versions.keys() }; + versions.sort(&sort_ctx); + sort_ctx.err catch |err| return err; + + // Get the latest. + const values = versions.values(); + const top = values[values.len - 1]; + + const latest = try std.SemanticVersion.parse(top.version); + const current = try std.SemanticVersion.parse(lp.build_config.version); + + switch (current.order(latest)) { + .lt => try writer.print( + \\Running an older version of Lightpanda ({s}), latest release is {s}. + \\ + \\Update via one-liner: + \\curl -fsSL https://pkg.lightpanda.io/install.sh | bash + \\ + , .{ lp.build_config.version, top.version }), + .gt, .eq => try writer.writeAll("Lightpanda is up-to-date.\n"), + } + + return writer.flush(); } /// Invoked by `Connection` when there are body bytes. @@ -206,9 +216,7 @@ fn onBytes(buffer: [*]const u8, buf_count: usize, buf_len: usize, raw_conn: ?*an const chunk = buffer[0 .. buf_count * buf_len]; self.conn_read_buffer.appendSlice(self.arena.allocator(), chunk) catch |err| { - if (err != error.OutOfMemory) unreachable; - // We have to do this in order to report errors from here. - self.conn_err = .out_of_memory; + self.conn_err = err; return 0; }; return chunk.len; From be8628f235dfccedf268d599ef4f61a89cde0ac5 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Mon, 29 Jun 2026 13:59:05 +0300 Subject: [PATCH 11/21] `update`: add `help` command entry for `update` --- src/Config.zig | 3 +-- src/help.zon | 6 ++++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/Config.zig b/src/Config.zig index 9c2051ebf..2ffa5fec3 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -692,7 +692,7 @@ pub fn printUsageAndExit(self: *const Config, help_for: RunMode, success: bool) , .{Help.general}); std.debug.print(template, .{exec_name}); }, - inline .fetch, .serve, .mcp, .agent => |tag| { + inline .fetch, .serve, .mcp, .agent, .update => |tag| { const template = comptimePrint( \\{s} \\ @@ -701,7 +701,6 @@ pub fn printUsageAndExit(self: *const Config, help_for: RunMode, success: bool) , .{ @field(Help, @tagName(tag)), Help.common_options }); std.debug.print(template, .{ exec_name, info_or_warn, pretty_or_logfmt }); }, - .update => @panic("TODO"), .version => { const template = Help.version ++ "\n"; std.debug.print(template, .{exec_name}); diff --git a/src/help.zon b/src/help.zon index 2be7e812f..2622c655e 100644 --- a/src/help.zon +++ b/src/help.zon @@ -237,6 +237,12 @@ \\MISTRAL_API_KEY. The local servers (Ollama, llama.cpp) do not require an \\API key. , + .update = + \\usage: {0s} update [COMMON_OPTIONS] + \\ + \\Checks whether a newer release of {0s} is available. When one is found, + \\prints the running version, the latest release, and how to update. + , .version = \\usage: {0s} version \\ From d8184dc462f0a2c3114f41c697b889b56ec364d4 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Fri, 3 Jul 2026 12:42:43 +0300 Subject: [PATCH 12/21] move `update` command to `version --check` --- src/Config.zig | 26 ++++++++++++++++++-------- src/help.zon | 14 +++++++------- src/main.zig | 4 ---- 3 files changed, 25 insertions(+), 19 deletions(-) diff --git a/src/Config.zig b/src/Config.zig index 2ffa5fec3..cc2a0e5ad 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -262,8 +262,9 @@ const Commands = cli.Builder(.{ }, .shared_options = CommonOptions, }, - .{ .name = "update", .options = .{}, .shared_options = CommonOptions }, - .{ .name = "version", .options = .{} }, + .{ .name = "version", .options = .{ + .{ .name = "check", .type = bool }, + } }, }); pub const RunMode = Commands.Enum; @@ -275,7 +276,11 @@ exec_name: []const u8, http_headers: HttpHeaders, fn modeNeedsHttp(mode: Mode) bool { - return mode != .help and mode != .version; + return switch (mode) { + .help => false, + .version => |opts| opts.check, + else => true, + }; } pub fn init(allocator: Allocator, exec_name: []const u8, mode: Mode) !Config { @@ -308,6 +313,8 @@ 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, + // `version --check` talks to the release endpoint; always verify. + .version => true, else => unreachable, }; } @@ -356,7 +363,8 @@ pub fn v8MaxHeapMb(self: *const Config) ?u32 { pub fn httpProxy(self: *const Config) ?[:0]const u8 { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent, .update => |opts| opts.http_proxy, + inline .serve, .fetch, .mcp, .agent => |opts| opts.http_proxy, + .version => null, else => unreachable, }; } @@ -384,14 +392,16 @@ pub fn httpMaxHostOpen(self: *const Config) u8 { pub fn httpConnectTimeout(self: *const Config) u31 { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent, .update => |opts| opts.http_connect_timeout orelse 0, + inline .serve, .fetch, .mcp, .agent => |opts| opts.http_connect_timeout orelse 0, + .version => 0, else => unreachable, }; } pub fn httpTimeout(self: *const Config) u31 { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent, .update => |opts| opts.http_timeout orelse 5000, + inline .serve, .fetch, .mcp, .agent => |opts| opts.http_timeout orelse 5000, + .version => 5000, else => unreachable, }; } @@ -402,7 +412,7 @@ pub fn httpMaxRedirects(_: *const Config) u8 { pub fn httpMaxResponseSize(self: *const Config) ?usize { return switch (self.mode) { - inline .serve, .fetch, .mcp, .agent, .update => |opts| opts.http_max_response_size, + inline .serve, .fetch, .mcp, .agent => |opts| opts.http_max_response_size, else => unreachable, }; } @@ -692,7 +702,7 @@ pub fn printUsageAndExit(self: *const Config, help_for: RunMode, success: bool) , .{Help.general}); std.debug.print(template, .{exec_name}); }, - inline .fetch, .serve, .mcp, .agent, .update => |tag| { + inline .fetch, .serve, .mcp, .agent => |tag| { const template = comptimePrint( \\{s} \\ diff --git a/src/help.zon b/src/help.zon index 2622c655e..08eb8c7fc 100644 --- a/src/help.zon +++ b/src/help.zon @@ -237,16 +237,16 @@ \\MISTRAL_API_KEY. The local servers (Ollama, llama.cpp) do not require an \\API key. , - .update = - \\usage: {0s} update [COMMON_OPTIONS] - \\ - \\Checks whether a newer release of {0s} is available. When one is found, - \\prints the running version, the latest release, and how to update. - , .version = - \\usage: {0s} version + \\usage: {0s} version [OPTIONS] \\ \\Displays the version of {0s}. + \\ + \\options: + \\ --check + \\ Checks whether a newer release of {0s} is available. When one is + \\ found, prints the running version, the latest release, and how to + \\ update. , .help = \\usage: {0s} help diff --git a/src/main.zig b/src/main.zig index 8d8442f0b..ca0b75325 100644 --- a/src/main.zig +++ b/src/main.zig @@ -80,10 +80,6 @@ fn run(allocator: Allocator, main_arena: Allocator) !void { try lp.Agent.listModels(allocator, opts); return std.process.cleanExit(); }, - .update => { - try lp.update(allocator, &args); - return std.process.cleanExit(); - }, else => {}, } From 8673fbb489ffb0cf7b8454708685b8764017aecb Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Fri, 3 Jul 2026 12:44:18 +0300 Subject: [PATCH 13/21] `lp.update` -> `lp.checkVersion` + honor `--check` in `version` --- src/lightpanda.zig | 4 +--- src/main.zig | 10 +++++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/lightpanda.zig b/src/lightpanda.zig index f7ec92c8b..b9e9d9cc6 100644 --- a/src/lightpanda.zig +++ b/src/lightpanda.zig @@ -225,8 +225,7 @@ fn dumpContent(app: *App, mode: Config.DumpFormat, dump_opts: dump.Opts, frame: } } -/// Check `Config.zig` for `options`. -pub fn update(allocator: std.mem.Allocator, config: *const Config) !void { +pub fn checkVersion(allocator: std.mem.Allocator, config: *const Config) !void { var client = try Updater.init(allocator, config); defer client.deinit(); @@ -234,7 +233,6 @@ pub fn update(allocator: std.mem.Allocator, config: *const Config) !void { var buf: [4096]u8 = undefined; var writer = stdout.writer(&buf); const w = &writer.interface; - // Inform to stdout about version. try client.inform(w); } diff --git a/src/main.zig b/src/main.zig index ca0b75325..4799eb3ef 100644 --- a/src/main.zig +++ b/src/main.zig @@ -71,9 +71,13 @@ fn run(allocator: Allocator, main_arena: Allocator) !void { switch (args.mode) { .help => |tag| return args.printUsageAndExit(tag, true), - .version => { - var stdout = std.fs.File.stdout().writer(&.{}); - try stdout.interface.print("{s}\n", .{lp.build_config.version}); + .version => |opts| { + if (opts.check) { + try lp.checkVersion(allocator, &args); + } else { + var stdout = std.fs.File.stdout().writer(&.{}); + try stdout.interface.print("{s}\n", .{lp.build_config.version}); + } return std.process.cleanExit(); }, .agent => |opts| if (opts.list_models) { From cf00ef100c3274b7206626ca08280b94431aac02 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Fri, 3 Jul 2026 12:44:34 +0300 Subject: [PATCH 14/21] `Network`: mark `createX509Store` as `pub` --- src/network/Network.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/network/Network.zig b/src/network/Network.zig index 842ef9113..3b5d27b90 100644 --- a/src/network/Network.zig +++ b/src/network/Network.zig @@ -724,7 +724,7 @@ const CreateX509StoreError = std.crypto.Certificate.Bundle.RescanError || error{ /// NEVER give full ownership of store to `SSL_CTX`, always rely on ref counting. /// Allocations made through passed `allocator` are freed before this function returns. -fn createX509Store(allocator: Allocator) CreateX509StoreError!*crypto.X509_STORE { +pub fn createX509Store(allocator: Allocator) CreateX509StoreError!*crypto.X509_STORE { const store = crypto.X509_STORE_new() orelse return error.FailedToCreateX509Store; errdefer crypto.X509_STORE_free(store); From affd1c279a2c584ebcdd7a9e311628a0c90546e3 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Fri, 3 Jul 2026 12:44:53 +0300 Subject: [PATCH 15/21] `Updater`: refactor `Updator` for new version check endpoint --- src/Updater.zig | 191 +++++++++++++----------------------------------- 1 file changed, 50 insertions(+), 141 deletions(-) diff --git a/src/Updater.zig b/src/Updater.zig index c2f7df69b..ab5ebd9bd 100644 --- a/src/Updater.zig +++ b/src/Updater.zig @@ -25,118 +25,37 @@ const lp = @import("lightpanda"); const Network = @import("network/Network.zig"); const http = @import("network/http.zig"); const libcurl = @import("sys/libcurl.zig"); +const crypto = @import("sys/libcrypto.zig"); const Config = @import("Config.zig"); const log = @import("log.zig"); -/// Where to find versions JSON. -const VersionsUrl: [:0]const u8 = "https://get.lightpanda.io/versions.json"; - -pub const Channel = enum(u1) { stable, nightly }; - /// Sole purpose of this client is to do updates; hence, its very minimal. const Updater = @This(); arena: std.heap.ArenaAllocator, -ca_blob: libcurl.CurlBlob, -/// Connection where client does all of its network I/O. -conn: http.Connection, -/// Read buffer for `conn`. -conn_read_buffer: std.ArrayList(u8) = .empty, -/// Needed to report OutOfMemory. -conn_err: Allocator.Error!void = {}, -/// TODO: Come up with a solution where we don't have to embed this here? +x509_store: *crypto.X509_STORE, config: *const Config, /// Initializes the update client; meant to be used as singleton. pub fn init(allocator: Allocator, config: *const Config) !Updater { var arena = std.heap.ArenaAllocator.init(allocator); errdefer arena.deinit(); - const arena_allocator = arena.allocator(); Network.globalInit(allocator); errdefer Network.globalDeinit(); - // On error, will be deinitialized by arena. - const ca_blob = try Network.loadCerts(arena_allocator); - // Init connection. - const connection = try http.Connection.init(ca_blob, config, null); - errdefer connection.deinit(); + const x509_store = try Network.createX509Store(arena.allocator()); return .{ .arena = arena, - .ca_blob = ca_blob, - .conn = connection, + .x509_store = x509_store, .config = config, }; } pub fn deinit(self: *Updater) void { - self.conn.deinit(); Network.globalDeinit(); self.arena.deinit(); } -const Version = struct { - @"aarch64-linux": Entry, - @"aarch64-macos": Entry, - date: []const u8, - version: []const u8, - @"x86_64-linux": Entry, - @"x86_64-macos": Entry, - - const Entry = struct { - download_url: []const u8, - shasum: []const u8, - size: u64, - }; -}; - -const Nightly = struct { nightly: Version }; - -/// Returns Lightpanda versions for a channel; call `resetConnection` after -/// you're done with `Versions` to do further requests. -fn getVersions( - self: *Updater, - comptime channel: Channel, -) !switch (channel) { - .stable => json.ArrayHashMap(Version), - .nightly => Nightly, // Only care about nightly record. -} { - try self.conn.setURL(VersionsUrl); - try self.conn.setGetMode(); - try self.conn.setFollowLocation(true); - try self.conn.setWriteCallback(onBytes); - - const status_int = self.conn.request(&self.config.http_headers) catch |err| { - self.conn_err catch |conn_err| return conn_err; - return err; - }; - const status: std.http.Status = @enumFromInt(status_int); - if (status != .ok) { - return error.UnexpectedStatus; - } - - const Json = switch (channel) { - .stable => json.ArrayHashMap(Version), - .nightly => Nightly, - }; - return json.parseFromSliceLeaky( - Json, - self.arena.allocator(), - self.conn_read_buffer.items, - .{ - .ignore_unknown_fields = true, - .allocate = .alloc_if_needed, - .parse_numbers = true, - }, - ); -} - -/// Resets everything related to `conn`. -fn resetConnection(self: *Updater) void { - self.conn.reset(self.config, self.ca_blob, null) catch {}; - self.conn_read_buffer.clearRetainingCapacity(); - self.conn_err = {}; -} - fn versioning() []const u8 { comptime { const version = SemanticVersion.parse(lp.build_config.version) catch unreachable; @@ -146,27 +65,7 @@ fn versioning() []const u8 { } } -const SortCtx = struct { - keys: []const []const u8, - /// Carries the error that might happen while sorting. - err: anyerror!void = {}, - - pub fn lessThan(ctx: *SortCtx, a_index: usize, b_index: usize) bool { - const keys = ctx.keys; - const a_version = SemanticVersion.parse(keys[a_index]) catch |err| { - ctx.err = err; - return false; - }; - const b_version = SemanticVersion.parse(keys[b_index]) catch |err| { - ctx.err = err; - return false; - }; - - return a_version.order(b_version).compare(.lt); - } -}; - -/// Informs about running version to given `Writer`. +/// Sends running Lightpanda version to remote to get update information. pub fn inform(self: *Updater, writer: *std.Io.Writer) !void { const kind = comptime versioning(); if (comptime std.mem.eql(u8, "dev", kind)) { @@ -178,46 +77,56 @@ pub fn inform(self: *Updater, writer: *std.Io.Writer) !void { return writer.flush(); } - var versions = (try self.getVersions(.stable)).map; - defer self.resetConnection(); + const conn = try http.Connection.init(self.x509_store, self.config, null); + defer conn.deinit(); - // Remove "nightly" entry. - lp.assert(versions.swapRemove("nightly"), "Updater.inform: \"nightly\" entry not found", .{}); - // Sorting is necessary. - var sort_ctx = SortCtx{ .keys = versions.keys() }; - versions.sort(&sort_ctx); - sort_ctx.err catch |err| return err; + const allocator = self.arena.allocator(); + const url = try std.fmt.allocPrintSentinel( + allocator, + "https://telemetry.lightpanda.io/v/{s}", + .{lp.build_config.version}, + 0, + ); + defer allocator.free(url); - // Get the latest. - const values = versions.values(); - const top = values[values.len - 1]; + // Prepare the request. + try conn.setURL(url); + try conn.setGetMode(); + try conn.setFollowLocation(true); - const latest = try std.SemanticVersion.parse(top.version); - const current = try std.SemanticVersion.parse(lp.build_config.version); + // Wraps everything needed to receive bytes. + const ReceiverContext = struct { + allocator: std.mem.Allocator, + buffer: std.ArrayList(u8) = .empty, + err: Allocator.Error!void = {}, - switch (current.order(latest)) { - .lt => try writer.print( - \\Running an older version of Lightpanda ({s}), latest release is {s}. - \\ - \\Update via one-liner: - \\curl -fsSL https://pkg.lightpanda.io/install.sh | bash - \\ - , .{ lp.build_config.version, top.version }), - .gt, .eq => try writer.writeAll("Lightpanda is up-to-date.\n"), + fn onBytes(buffer: [*]const u8, buf_count: usize, buf_len: usize, raw_ctx: ?*anyopaque) usize { + const ctx: *@This() = @ptrCast(@alignCast(raw_ctx)); + const chunk = buffer[0 .. buf_count * buf_len]; + ctx.buffer.appendSlice(ctx.allocator, chunk) catch |err| { + ctx.err = err; + return 0; + }; + return chunk.len; + } + }; + + try libcurl.curl_easy_setopt(conn._easy, .write_function, ReceiverContext.onBytes); + // Set receiver context. + var ctx = ReceiverContext{ .allocator = allocator }; + defer ctx.buffer.deinit(allocator); + try libcurl.curl_easy_setopt(conn._easy, .write_data, &ctx); + + // Make a request. + const status_int = conn.request(&self.config.http_headers) catch |err| { + ctx.err catch |ctx_err| return ctx_err; + return err; + }; + const status: std.http.Status = @enumFromInt(status_int); + if (status != .ok) { + return error.UnexpectedStatus; } + try writer.writeAll(ctx.buffer.items); return writer.flush(); } - -/// Invoked by `Connection` when there are body bytes. -fn onBytes(buffer: [*]const u8, buf_count: usize, buf_len: usize, raw_conn: ?*anyopaque) usize { - const conn: *http.Connection = @ptrCast(@alignCast(raw_conn)); - const self: *Updater = @fieldParentPtr("conn", conn); - - const chunk = buffer[0 .. buf_count * buf_len]; - self.conn_read_buffer.appendSlice(self.arena.allocator(), chunk) catch |err| { - self.conn_err = err; - return 0; - }; - return chunk.len; -} From 3b73a5e0e7fa495074cd4e7968bdb912d928fd21 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Fri, 3 Jul 2026 15:52:53 +0300 Subject: [PATCH 16/21] `Updater`: free `X509_STORE` in deinitializer --- src/Updater.zig | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Updater.zig b/src/Updater.zig index ab5ebd9bd..3f5d7c924 100644 --- a/src/Updater.zig +++ b/src/Updater.zig @@ -53,6 +53,7 @@ pub fn init(allocator: Allocator, config: *const Config) !Updater { pub fn deinit(self: *Updater) void { Network.globalDeinit(); + crypto.X509_STORE_free(self.x509_store); self.arena.deinit(); } From 7a4e21cc8229b5471a5a75d49bbd19db78da4e33 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Fri, 3 Jul 2026 15:53:19 +0300 Subject: [PATCH 17/21] `Updater`: gracefully handle known status codes --- src/Updater.zig | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/Updater.zig b/src/Updater.zig index 3f5d7c924..4eb5277a9 100644 --- a/src/Updater.zig +++ b/src/Updater.zig @@ -119,15 +119,22 @@ pub fn inform(self: *Updater, writer: *std.Io.Writer) !void { try libcurl.curl_easy_setopt(conn._easy, .write_data, &ctx); // Make a request. - const status_int = conn.request(&self.config.http_headers) catch |err| { + const status_int = conn.perform() catch |err| { ctx.err catch |ctx_err| return ctx_err; return err; }; const status: std.http.Status = @enumFromInt(status_int); - if (status != .ok) { - return error.UnexpectedStatus; + switch (status) { + // Write what's received. + .ok => try writer.writeAll(ctx.buffer.items), + // Client failed. + .bad_request => try writer.writeAll("Versions format is invalid.\n"), + // Server failed. + .internal_server_error, + .service_unavailable, + => try writer.writeAll("Couldn't get the versions list from remote.\n"), + else => return error.UnexpectedStatus, } - try writer.writeAll(ctx.buffer.items); return writer.flush(); } From 78aa2a12aa99057a86956a3420104a12c392b114 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Mon, 6 Jul 2026 14:17:15 +0300 Subject: [PATCH 18/21] `Updater`: remove arena, stream received bytes directly to stdout --- src/Updater.zig | 53 +++++++++++++++++-------------------------------- 1 file changed, 18 insertions(+), 35 deletions(-) diff --git a/src/Updater.zig b/src/Updater.zig index 4eb5277a9..90d9b54d8 100644 --- a/src/Updater.zig +++ b/src/Updater.zig @@ -31,21 +31,16 @@ const log = @import("log.zig"); /// Sole purpose of this client is to do updates; hence, its very minimal. const Updater = @This(); -arena: std.heap.ArenaAllocator, x509_store: *crypto.X509_STORE, config: *const Config, /// Initializes the update client; meant to be used as singleton. pub fn init(allocator: Allocator, config: *const Config) !Updater { - var arena = std.heap.ArenaAllocator.init(allocator); - errdefer arena.deinit(); - Network.globalInit(allocator); errdefer Network.globalDeinit(); - const x509_store = try Network.createX509Store(arena.allocator()); + const x509_store = try Network.createX509Store(allocator); return .{ - .arena = arena, .x509_store = x509_store, .config = config, }; @@ -54,7 +49,6 @@ pub fn init(allocator: Allocator, config: *const Config) !Updater { pub fn deinit(self: *Updater) void { Network.globalDeinit(); crypto.X509_STORE_free(self.x509_store); - self.arena.deinit(); } fn versioning() []const u8 { @@ -67,6 +61,7 @@ fn versioning() []const u8 { } /// Sends running Lightpanda version to remote to get update information. +/// Outputs directly to given `Writer`. pub fn inform(self: *Updater, writer: *std.Io.Writer) !void { const kind = comptime versioning(); if (comptime std.mem.eql(u8, "dev", kind)) { @@ -81,15 +76,7 @@ pub fn inform(self: *Updater, writer: *std.Io.Writer) !void { const conn = try http.Connection.init(self.x509_store, self.config, null); defer conn.deinit(); - const allocator = self.arena.allocator(); - const url = try std.fmt.allocPrintSentinel( - allocator, - "https://telemetry.lightpanda.io/v/{s}", - .{lp.build_config.version}, - 0, - ); - defer allocator.free(url); - + const url = std.fmt.comptimePrint("https://telemetry.lightpanda.io/v/{s}", .{lp.build_config.version}); // Prepare the request. try conn.setURL(url); try conn.setGetMode(); @@ -97,26 +84,26 @@ pub fn inform(self: *Updater, writer: *std.Io.Writer) !void { // Wraps everything needed to receive bytes. const ReceiverContext = struct { - allocator: std.mem.Allocator, - buffer: std.ArrayList(u8) = .empty, - err: Allocator.Error!void = {}, + writer: *std.Io.Writer, + err: std.Io.Writer.Error!void = {}, - fn onBytes(buffer: [*]const u8, buf_count: usize, buf_len: usize, raw_ctx: ?*anyopaque) usize { + /// curl -> writer. + fn drain(buffer: [*]const u8, buf_count: usize, buf_len: usize, raw_ctx: ?*anyopaque) usize { const ctx: *@This() = @ptrCast(@alignCast(raw_ctx)); const chunk = buffer[0 .. buf_count * buf_len]; - ctx.buffer.appendSlice(ctx.allocator, chunk) catch |err| { + ctx.writer.writeAll(chunk) catch |err| { ctx.err = err; return 0; }; + return chunk.len; } }; - try libcurl.curl_easy_setopt(conn._easy, .write_function, ReceiverContext.onBytes); // Set receiver context. - var ctx = ReceiverContext{ .allocator = allocator }; - defer ctx.buffer.deinit(allocator); + var ctx = ReceiverContext{ .writer = writer }; try libcurl.curl_easy_setopt(conn._easy, .write_data, &ctx); + try libcurl.curl_easy_setopt(conn._easy, .write_function, ReceiverContext.drain); // Make a request. const status_int = conn.perform() catch |err| { @@ -124,17 +111,13 @@ pub fn inform(self: *Updater, writer: *std.Io.Writer) !void { return err; }; const status: std.http.Status = @enumFromInt(status_int); - switch (status) { - // Write what's received. - .ok => try writer.writeAll(ctx.buffer.items), - // Client failed. - .bad_request => try writer.writeAll("Versions format is invalid.\n"), - // Server failed. + return switch (status) { + // We expect any of those. + .ok, + .bad_request, .internal_server_error, .service_unavailable, - => try writer.writeAll("Couldn't get the versions list from remote.\n"), - else => return error.UnexpectedStatus, - } - - return writer.flush(); + => writer.flush(), + else => error.UnexpectedStatus, + }; } From 847c28657df10289a4769fe9e9f4b6c8a942a24f Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Wed, 8 Jul 2026 13:06:12 +0300 Subject: [PATCH 19/21] `Updater.inform`: flush no matter the result --- src/Updater.zig | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/src/Updater.zig b/src/Updater.zig index 90d9b54d8..213ee87cc 100644 --- a/src/Updater.zig +++ b/src/Updater.zig @@ -110,14 +110,6 @@ pub fn inform(self: *Updater, writer: *std.Io.Writer) !void { ctx.err catch |ctx_err| return ctx_err; return err; }; - const status: std.http.Status = @enumFromInt(status_int); - return switch (status) { - // We expect any of those. - .ok, - .bad_request, - .internal_server_error, - .service_unavailable, - => writer.flush(), - else => error.UnexpectedStatus, - }; + _ = status_int; + return writer.flush(); } From 9488a74b50d940d3848aa7c57300a216a7460f84 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Wed, 8 Jul 2026 13:06:58 +0300 Subject: [PATCH 20/21] `Updater.inform`: always receive information from remote --- src/Updater.zig | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/src/Updater.zig b/src/Updater.zig index 213ee87cc..fdd53722a 100644 --- a/src/Updater.zig +++ b/src/Updater.zig @@ -51,28 +51,9 @@ pub fn deinit(self: *Updater) void { crypto.X509_STORE_free(self.x509_store); } -fn versioning() []const u8 { - comptime { - const version = SemanticVersion.parse(lp.build_config.version) catch unreachable; - const pre = version.pre orelse return ""; - const index = std.mem.indexOfScalar(u8, pre, '.') orelse pre.len; - return pre[0..index]; - } -} - /// Sends running Lightpanda version to remote to get update information. /// Outputs directly to given `Writer`. pub fn inform(self: *Updater, writer: *std.Io.Writer) !void { - const kind = comptime versioning(); - if (comptime std.mem.eql(u8, "dev", kind)) { - try writer.print("Running a development version of Lightpanda ({s}).\n", .{lp.build_config.version}); - return writer.flush(); - } - if (comptime std.mem.eql(u8, "nightly", kind)) { - try writer.print("Running a nightly version of Lightpanda ({s}).\n", .{lp.build_config.version}); - return writer.flush(); - } - const conn = try http.Connection.init(self.x509_store, self.config, null); defer conn.deinit(); From 73558b380e88ef30e83889dabf0095f8a967374d Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Wed, 8 Jul 2026 13:07:37 +0300 Subject: [PATCH 21/21] `Updater.inform`: fix broken function signature --- src/Updater.zig | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Updater.zig b/src/Updater.zig index fdd53722a..97bb50a2a 100644 --- a/src/Updater.zig +++ b/src/Updater.zig @@ -69,7 +69,12 @@ pub fn inform(self: *Updater, writer: *std.Io.Writer) !void { err: std.Io.Writer.Error!void = {}, /// curl -> writer. - fn drain(buffer: [*]const u8, buf_count: usize, buf_len: usize, raw_ctx: ?*anyopaque) usize { + fn drain( + buffer: [*]const u8, + buf_count: usize, + buf_len: usize, + raw_ctx: *anyopaque, + ) callconv(.c) usize { const ctx: *@This() = @ptrCast(@alignCast(raw_ctx)); const chunk = buffer[0 .. buf_count * buf_len]; ctx.writer.writeAll(chunk) catch |err| {