diff --git a/.github/scripts/install-zig.sh b/.github/scripts/install-zig.sh new file mode 100644 index 0000000..8410fee --- /dev/null +++ b/.github/scripts/install-zig.sh @@ -0,0 +1,166 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "$#" -ne 1 ]; then + echo "usage: $0 " >&2 + exit 1 +fi + +version="$1" + +python_bin="${PYTHON:-python3}" +if ! command -v "$python_bin" >/dev/null 2>&1; then + python_bin="python" +fi +if ! command -v "$python_bin" >/dev/null 2>&1; then + echo "python is required to install Zig" >&2 + exit 1 +fi + +runner_os="${RUNNER_OS:-$(uname -s)}" +runner_arch="${RUNNER_ARCH:-$(uname -m)}" + +case "$runner_os" in + Linux | linux) + zig_os="linux" + ;; + Darwin | macOS) + zig_os="macos" + ;; + Windows | MINGW* | MSYS* | CYGWIN*) + zig_os="windows" + ;; + *) + echo "unsupported runner OS: $runner_os" >&2 + exit 1 + ;; +esac + +case "$runner_arch" in + X64 | x86_64 | amd64) + zig_arch="x86_64" + ;; + ARM64 | arm64 | aarch64) + zig_arch="aarch64" + ;; + *) + echo "unsupported runner architecture: $runner_arch" >&2 + exit 1 + ;; +esac + +host_key="${zig_arch}-${zig_os}" +tool_root="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/nullwatch-zig" +install_dir="${tool_root}/${version}/${host_key}" +zig_bin="zig" +if [ "$zig_os" = "windows" ]; then + zig_bin="zig.exe" +fi + +if [ ! -x "${install_dir}/${zig_bin}" ]; then + mkdir -p "$(dirname "$install_dir")" + + zig_metadata="$( + "$python_bin" - "$version" "$host_key" <<'PY' +import json +import sys +import urllib.request + +version = sys.argv[1] +host_key = sys.argv[2] + +with urllib.request.urlopen("https://ziglang.org/download/index.json") as response: + data = json.load(response) + +host = data.get(version, {}).get(host_key) +if not host: + raise SystemExit(f"missing Zig download metadata for version={version!r} host={host_key!r}") + +archive_url = host.get("tarball") or host.get("zip") +checksum = host.get("shasum") or "" +if not archive_url: + raise SystemExit(f"missing archive URL for version={version!r} host={host_key!r}") + +print(archive_url) +print(checksum) +PY + )" + + archive_url="$(printf '%s\n' "$zig_metadata" | sed -n '1p')" + expected_sha="$(printf '%s\n' "$zig_metadata" | sed -n '2p')" + if [ -z "$archive_url" ]; then + echo "failed to resolve Zig download URL" >&2 + exit 1 + fi + + archive_name="${archive_url##*/}" + archive_dir="$(mktemp -d "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/zig-archive.XXXXXX")" + archive_path="${archive_dir}/${archive_name}" + extract_dir="$(mktemp -d "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/zig-extract.XXXXXX")" + trap 'rm -rf "$archive_dir"; rm -rf "$extract_dir"' EXIT + + curl -fsSL --retry 3 --retry-all-errors "$archive_url" -o "$archive_path" + + "$python_bin" - "$archive_path" "$expected_sha" <<'PY' +import hashlib +import sys + +path = sys.argv[1] +expected = sys.argv[2].strip().lower() +if not expected: + raise SystemExit(0) + +digest = hashlib.sha256() +with open(path, "rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + +actual = digest.hexdigest().lower() +if actual != expected: + raise SystemExit(f"checksum mismatch for {path}: expected {expected}, got {actual}") +PY + + "$python_bin" - "$archive_path" "$extract_dir" <<'PY' +import pathlib +import sys +import tarfile +import zipfile + +archive = pathlib.Path(sys.argv[1]) +destination = pathlib.Path(sys.argv[2]) +destination.mkdir(parents=True, exist_ok=True) + +def ensure_within_destination(relative_name: str) -> None: + target = (destination / relative_name).resolve() + if destination.resolve() not in target.parents and target != destination.resolve(): + raise SystemExit(f"archive entry escapes destination: {relative_name}") + +if archive.suffix == ".zip": + with zipfile.ZipFile(archive) as handle: + for member in handle.namelist(): + ensure_within_destination(member) + handle.extractall(destination) +else: + with tarfile.open(archive, "r:*") as handle: + for member in handle.getnames(): + ensure_within_destination(member) + handle.extractall(destination) +PY + + extracted_dir="$(find "$extract_dir" -mindepth 1 -maxdepth 1 -type d | head -n 1)" + if [ -z "$extracted_dir" ]; then + echo "failed to extract Zig archive: $archive_url" >&2 + exit 1 + fi + + rm -rf "$install_dir" + mv "$extracted_dir" "$install_dir" +fi + +if [ -n "${GITHUB_PATH:-}" ]; then + printf '%s\n' "$install_dir" >> "$GITHUB_PATH" +else + echo "GITHUB_PATH is not set; add this directory to PATH manually: $install_dir" >&2 +fi + +"${install_dir}/${zig_bin}" version diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72eb5e5..2df5e36 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,9 @@ name: CI +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + ZIG_VERSION: "0.16.0" + on: push: branches: [main] @@ -28,15 +32,13 @@ jobs: zig_target: x86_64-windows steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - name: Install Zig 0.15.2 - uses: mlugg/setup-zig@v2 - with: - version: 0.15.2 + - name: Install Zig 0.16.0 + run: bash .github/scripts/install-zig.sh "${ZIG_VERSION}" - name: Cache Zig build outputs - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | .zig-cache @@ -84,7 +86,7 @@ jobs: - name: Upload binary if: success() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: nullwatch-${{ matrix.target }} path: ci-artifacts/nullwatch${{ runner.os == 'Windows' && '.exe' || '' }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6bdeb26..f9a20d0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,5 +1,9 @@ name: Release +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + ZIG_VERSION: "0.16.0" + on: push: tags: ['v*'] @@ -45,12 +49,10 @@ jobs: ext: ".exe" steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - name: Install Zig 0.15.2 - uses: mlugg/setup-zig@v2 - with: - version: 0.15.2 + - name: Install Zig 0.16.0 + run: bash .github/scripts/install-zig.sh "${ZIG_VERSION}" - name: Resolve build version id: version @@ -65,7 +67,7 @@ jobs: run: zig build -Doptimize=ReleaseSmall -Dversion=${{ steps.version.outputs.value }} -Dtarget=${{ matrix.zig_target }} - name: Upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: nullwatch-${{ matrix.target }} path: zig-out/bin/nullwatch${{ matrix.ext }} @@ -78,7 +80,7 @@ jobs: shell: bash steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Create source archive run: | @@ -92,7 +94,7 @@ jobs: echo "ARCHIVE_NAME=${archive_name}" >> "$GITHUB_ENV" - name: Upload source archive - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: nullwatch-source path: ${{ env.ARCHIVE_NAME }} @@ -105,7 +107,7 @@ jobs: contents: write steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 - name: Rename binaries run: | diff --git a/build.zig.zon b/build.zig.zon index 6e7dc18..65485c3 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -2,7 +2,7 @@ .name = .nullwatch, .version = "2026.3.16", .fingerprint = 0x331185e774b4ae7a, - .minimum_zig_version = "0.15.2", + .minimum_zig_version = "0.16.0", .paths = .{ ".github", "build.zig", diff --git a/src/api.zig b/src/api.zig index c6a46da..4145a4d 100644 --- a/src/api.zig +++ b/src/api.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const std_compat = @import("compat.zig"); const domain = @import("domain.zig"); const Store = @import("store.zig").Store; const version = @import("version.zig"); @@ -365,14 +366,14 @@ fn ingestOtlpScopeSpans( const run_id = (try firstOtlpAttributeText(ctx.allocator, attrs, &.{ "nullwatch.run_id", "nulltickets.run_id", "run_id" })) orelse (try firstOtlpAttributeText(ctx.allocator, resource_attributes, &.{ "nullwatch.run_id", "nulltickets.run_id", "run_id" })) orelse trace_id; - const source = (try firstOtlpAttributeText(ctx.allocator, resource_attributes, &.{ "service.name" })) orelse + const source = (try firstOtlpAttributeText(ctx.allocator, resource_attributes, &.{"service.name"})) orelse scope_name orelse "otlp"; const operation = span.name orelse "unnamed"; - const started_at_ms = parseUnixNanoMs(span.startTimeUnixNano) orelse std.time.milliTimestamp(); + const started_at_ms = parseUnixNanoMs(span.startTimeUnixNano) orelse std_compat.time.milliTimestamp(); const ended_at_ms = parseUnixNanoMs(span.endTimeUnixNano); const attributes_json = try otlpAttributesJson(ctx.allocator, attrs); - const success_text = try firstOtlpAttributeText(ctx.allocator, attrs, &.{ "success" }); + const success_text = try firstOtlpAttributeText(ctx.allocator, attrs, &.{"success"}); const status = determineOtlpStatus(span, success_text); const error_message = if (span.status) |status_payload| status_payload.message orelse (try firstOtlpAttributeText(ctx.allocator, attrs, &.{ "error_message", "message", "detail" })) @@ -626,10 +627,7 @@ fn jsonResponse(allocator: std.mem.Allocator, status_code: u16, value: anytype) } fn encodeJson(allocator: std.mem.Allocator, value: anytype) ![]u8 { - var out = std.io.Writer.Allocating.init(allocator); - defer out.deinit(); - try std.json.Stringify.value(value, .{}, &out.writer); - return try out.toOwnedSlice(); + return try std.json.Stringify.valueAlloc(allocator, value, .{}); } fn statusTextFromCode(status_code: u16) []const u8 { diff --git a/src/compat.zig b/src/compat.zig new file mode 100644 index 0000000..8c6339a --- /dev/null +++ b/src/compat.zig @@ -0,0 +1,198 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const shared = @import("compat/shared.zig"); +const Allocator = std.mem.Allocator; + +pub fn io() std.Io { + return shared.io(); +} + +pub fn initProcess(init: std.process.Init) void { + shared.initProcess(init); +} + +pub fn initProcessMinimal(init: std.process.Init.Minimal) void { + shared.initProcessMinimal(init); +} + +pub const fs = @import("compat/fs.zig"); + +pub const process = struct { + pub const EnvMap = std.process.Environ.Map; + pub const GetEnvVarOwnedError = error{ + EnvironmentVariableNotFound, + } || Allocator.Error || error{ InvalidWtf8, Unexpected }; + + pub fn getEnvVarOwned(allocator: Allocator, name: []const u8) GetEnvVarOwnedError![]u8 { + return shared.environ().getAlloc(allocator, name) catch |err| switch (err) { + error.EnvironmentVariableMissing => error.EnvironmentVariableNotFound, + else => |e| e, + }; + } + + pub fn argsAlloc(allocator: Allocator) ![]const [:0]const u8 { + return shared.argsAlloc(allocator); + } + + pub fn argsFree(allocator: Allocator, args: []const [:0]const u8) void { + shared.argsFree(allocator, args); + } + + pub const Child = struct { + allocator: Allocator, + argv: []const []const u8, + env_map: ?*const EnvMap = null, + cwd: ?[]const u8 = null, + stdin_behavior: StdIo = .Inherit, + stdout_behavior: StdIo = .Inherit, + stderr_behavior: StdIo = .Inherit, + request_resource_usage_statistics: bool = false, + pgid: ?std.posix.pid_t = null, + create_no_window: bool = true, + id: Id = undefined, + thread_handle: if (builtin.os.tag == .windows) std.os.windows.HANDLE else void = if (builtin.os.tag == .windows) undefined else {}, + stdin: ?fs.File = null, + stdout: ?fs.File = null, + stderr: ?fs.File = null, + term: ?Term = null, + + pub const Id = std.process.Child.Id; + pub const Term = std.process.Child.Term; + pub const StdIo = enum { Inherit, Ignore, Pipe, Close }; + + pub fn init(argv: []const []const u8, allocator: Allocator) Child { + return .{ + .allocator = allocator, + .argv = argv, + }; + } + + fn mapStdIo(kind: StdIo) std.process.SpawnOptions.StdIo { + return switch (kind) { + .Inherit => .inherit, + .Ignore => .ignore, + .Pipe => .pipe, + .Close => .close, + }; + } + + fn spawnCwd(self: *const Child) std.process.Child.Cwd { + return if (self.cwd) |path_value| .{ .path = path_value } else .inherit; + } + + fn toInner(self: *const Child) std.process.Child { + return .{ + .id = self.id, + .thread_handle = self.thread_handle, + .stdin = if (self.stdin) |file| file.toInner() else null, + .stdout = if (self.stdout) |file| file.toInner() else null, + .stderr = if (self.stderr) |file| file.toInner() else null, + .resource_usage_statistics = .{}, + .request_resource_usage_statistics = self.request_resource_usage_statistics, + }; + } + + fn syncFromInner(self: *Child, inner: std.process.Child) void { + self.stdin = if (inner.stdin) |file| fs.File.wrap(file) else null; + self.stdout = if (inner.stdout) |file| fs.File.wrap(file) else null; + self.stderr = if (inner.stderr) |file| fs.File.wrap(file) else null; + } + + pub fn spawn(self: *Child) !void { + const inner = try std.process.spawn(io(), .{ + .argv = self.argv, + .cwd = self.spawnCwd(), + .environ_map = self.env_map, + .stdin = mapStdIo(self.stdin_behavior), + .stdout = mapStdIo(self.stdout_behavior), + .stderr = mapStdIo(self.stderr_behavior), + .request_resource_usage_statistics = self.request_resource_usage_statistics, + .pgid = self.pgid, + .create_no_window = self.create_no_window, + }); + self.id = inner.id.?; + self.thread_handle = inner.thread_handle; + self.syncFromInner(inner); + self.term = null; + } + + pub fn wait(self: *Child) !Term { + if (self.term) |term| return term; + + var inner = self.toInner(); + const term = try inner.wait(io()); + self.syncFromInner(inner); + self.term = term; + return term; + } + + pub fn kill(self: *Child) !Term { + if (self.term) |term| return term; + + var inner = self.toInner(); + inner.kill(io()); + self.syncFromInner(inner); + + const term: Term = if (builtin.os.tag == .windows) + .{ .exited = 1 } + else + .{ .signal = std.posix.SIG.KILL }; + + self.term = term; + return term; + } + }; +}; + +pub const mem = struct { + pub fn trimLeft(comptime T: type, slice: []const T, values_to_strip: []const T) []const T { + return std.mem.trimStart(T, slice, values_to_strip); + } + + pub fn trimRight(comptime T: type, slice: []const T, values_to_strip: []const T) []const T { + return std.mem.trimEnd(T, slice, values_to_strip); + } +}; + +pub const thread = struct { + pub fn sleep(nanoseconds: u64) void { + std.Io.sleep(io(), .fromNanoseconds(@intCast(nanoseconds)), .awake) catch {}; + } +}; + +pub const crypto = struct { + pub const random = struct { + pub fn bytes(buffer: []u8) void { + std.Io.randomSecure(io(), buffer) catch std.Io.random(io(), buffer); + } + }; +}; + +pub const time = struct { + fn nowNanoseconds() i128 { + return switch (builtin.os.tag) { + .windows => blk: { + const epoch_ns = std.time.epoch.windows * std.time.ns_per_s; + break :blk @as(i128, std.os.windows.ntdll.RtlGetSystemTimePrecise()) * 100 + epoch_ns; + }, + .wasi => blk: { + var ts: std.os.wasi.timestamp_t = undefined; + if (std.os.wasi.clock_time_get(.REALTIME, 1, &ts) == .SUCCESS) { + break :blk @intCast(ts); + } + break :blk 0; + }, + else => blk: { + var ts: std.posix.timespec = undefined; + switch (std.posix.errno(std.posix.system.clock_gettime(.REALTIME, &ts))) { + .SUCCESS => break :blk @as(i128, ts.sec) * std.time.ns_per_s + ts.nsec, + else => break :blk 0, + } + }, + }; + } + + pub fn milliTimestamp() i64 { + return @intCast(@divTrunc(nowNanoseconds(), std.time.ns_per_ms)); + } +}; diff --git a/src/compat/fs.zig b/src/compat/fs.zig new file mode 100644 index 0000000..2be980d --- /dev/null +++ b/src/compat/fs.zig @@ -0,0 +1,307 @@ +const std = @import("std"); +const shared = @import("shared.zig"); + +const Io = std.Io; +const Allocator = std.mem.Allocator; + +pub const path = struct { + pub const basename = std.fs.path.basename; + pub const delimiter = std.fs.path.delimiter; + pub const dirname = std.fs.path.dirname; + pub const extension = std.fs.path.extension; + pub const isAbsolute = std.fs.path.isAbsolute; + pub const isSep = std.fs.path.isSep; + pub const join = std.fs.path.join; + pub const sep = std.fs.path.sep; + pub const sep_str = std.fs.path.sep_str; +}; + +pub const File = struct { + handle: Io.File.Handle, + flags: Io.File.Flags, + + pub const Reader = Io.File.Reader; + pub const Writer = Io.File.Writer; + pub const Mode = if (@import("builtin").os.tag == .windows) u32 else std.posix.mode_t; + pub const Stat = struct { + inode: Io.File.INode, + nlink: Io.File.NLink, + size: u64, + mode: Mode, + kind: Io.File.Kind, + atime: ?i128, + mtime: i128, + ctime: i128, + block_size: Io.File.BlockSize, + }; + + pub fn wrap(inner: Io.File) File { + return .{ + .handle = inner.handle, + .flags = inner.flags, + }; + } + + pub fn toInner(self: File) Io.File { + return .{ + .handle = self.handle, + .flags = self.flags, + }; + } + + fn convertStat(inner: Io.File.Stat) Stat { + return .{ + .inode = inner.inode, + .nlink = inner.nlink, + .size = inner.size, + .mode = if (@hasDecl(@TypeOf(inner.permissions), "toMode")) inner.permissions.toMode() else 0, + .kind = inner.kind, + .atime = if (inner.atime) |ts| ts.nanoseconds else null, + .mtime = inner.mtime.nanoseconds, + .ctime = inner.ctime.nanoseconds, + .block_size = inner.block_size, + }; + } + + pub fn stdout() File { + return wrap(Io.File.stdout()); + } + + pub fn stderr() File { + return wrap(Io.File.stderr()); + } + + pub fn stdin() File { + return wrap(Io.File.stdin()); + } + + pub fn close(self: File) void { + self.toInner().close(shared.io()); + } + + pub fn stat(self: File) Io.File.StatError!Stat { + return convertStat(try self.toInner().stat(shared.io())); + } + + pub fn seekTo(self: File, offset: u64) Io.File.SeekError!void { + try shared.io().vtable.fileSeekTo(shared.io().userdata, self.toInner(), offset); + } + + pub fn seekFromEnd(self: File, offset: i64) !void { + const file_stat = try self.stat(); + const end_offset = @as(i128, @intCast(file_stat.size)) + offset; + if (end_offset < 0) return error.Unseekable; + try self.seekTo(@intCast(end_offset)); + } + + pub fn writer(self: File, buffer: []u8) Writer { + return self.toInner().writer(shared.io(), buffer); + } + + pub fn reader(self: File, buffer: []u8) Reader { + return self.toInner().reader(shared.io(), buffer); + } + + pub fn read(self: File, buffer: []u8) Io.File.ReadStreamingError!usize { + return self.toInner().readStreaming(shared.io(), &.{buffer}) catch |err| switch (err) { + error.EndOfStream => 0, + else => |e| return e, + }; + } + + pub fn readAll(self: File, buffer: []u8) Io.File.ReadStreamingError!usize { + var filled: usize = 0; + while (filled < buffer.len) { + const amt = try self.read(buffer[filled..]); + if (amt == 0) break; + filled += amt; + } + return filled; + } + + pub fn writeAll(self: File, bytes: []const u8) Io.File.Writer.Error!void { + try self.toInner().writeStreamingAll(shared.io(), bytes); + } + + pub fn readToEndAlloc(self: File, allocator: Allocator, max_bytes: usize) ![]u8 { + var stream_buf: [4096]u8 = undefined; + var file_reader = self.toInner().readerStreaming(shared.io(), &stream_buf); + return try file_reader.interface.allocRemaining(allocator, .limited(max_bytes)); + } +}; + +pub const Dir = struct { + handle: Io.Dir.Handle, + + pub const OpenDirOptions = Io.Dir.OpenOptions; + pub const OpenFileOptions = Io.Dir.OpenFileOptions; + pub const CreateFileOptions = Io.Dir.CreateFileOptions; + pub const WriteFileOptions = Io.Dir.WriteFileOptions; + pub const AccessOptions = Io.Dir.AccessOptions; + pub const CopyFileOptions = Io.Dir.CopyFileOptions; + pub const SymLinkFlags = Io.Dir.SymLinkFlags; + pub const Entry = Io.Dir.Entry; + pub const Iterator = struct { + inner: Io.Dir.Iterator, + + pub fn next(self: *Iterator) Io.Dir.Iterator.Error!?Entry { + return self.inner.next(shared.io()); + } + }; + + pub fn wrap(inner: Io.Dir) Dir { + return .{ .handle = inner.handle }; + } + + fn toInner(self: Dir) Io.Dir { + return .{ .handle = self.handle }; + } + + pub fn cwd() Dir { + return wrap(Io.Dir.cwd()); + } + + pub fn close(self: Dir) void { + self.toInner().close(shared.io()); + } + + pub fn iterate(self: Dir) Iterator { + return .{ .inner = self.toInner().iterate() }; + } + + pub fn openDir(self: Dir, sub_path: []const u8, options: OpenDirOptions) Io.Dir.OpenError!Dir { + return wrap(try self.toInner().openDir(shared.io(), sub_path, options)); + } + + pub fn openFile(self: Dir, sub_path: []const u8, options: OpenFileOptions) Io.File.OpenError!File { + return File.wrap(try self.toInner().openFile(shared.io(), sub_path, options)); + } + + pub fn createFile(self: Dir, sub_path: []const u8, options: CreateFileOptions) Io.File.OpenError!File { + return File.wrap(try self.toInner().createFile(shared.io(), sub_path, options)); + } + + pub fn writeFile(self: Dir, options: WriteFileOptions) Io.Dir.WriteFileError!void { + try self.toInner().writeFile(shared.io(), options); + } + + pub fn readFileAlloc(self: Dir, allocator: Allocator, sub_path: []const u8, max_bytes: usize) ![]u8 { + return try self.toInner().readFileAlloc(shared.io(), sub_path, allocator, .limited(max_bytes)); + } + + pub fn access(self: Dir, sub_path: []const u8, options: AccessOptions) Io.Dir.AccessError!void { + try self.toInner().access(shared.io(), sub_path, options); + } + + pub fn makeDir(self: Dir, sub_path: []const u8) Io.Dir.CreateDirError!void { + try self.toInner().createDir(shared.io(), sub_path, .default_dir); + } + + pub fn deleteFile(self: Dir, sub_path: []const u8) Io.Dir.DeleteFileError!void { + try self.toInner().deleteFile(shared.io(), sub_path); + } + + pub fn deleteTree(self: Dir, sub_path: []const u8) Io.Dir.DeleteTreeError!void { + try self.toInner().deleteTree(shared.io(), sub_path); + } + + pub fn rename(self: Dir, old_sub_path: []const u8, new_sub_path: []const u8) Io.Dir.RenameError!void { + try self.toInner().rename(old_sub_path, self.toInner(), new_sub_path, shared.io()); + } + + pub fn realpathAlloc(self: Dir, allocator: Allocator, sub_path: []const u8) Io.Dir.RealPathFileAllocError![]u8 { + const path_z = try self.toInner().realPathFileAlloc(shared.io(), sub_path, allocator); + defer allocator.free(path_z); + return try allocator.dupe(u8, path_z); + } + + pub fn statFile(self: Dir, sub_path: []const u8) !File.Stat { + return File.convertStat(try self.toInner().statFile(shared.io(), sub_path, .{})); + } + + pub fn makePath(self: Dir, sub_path: []const u8) !void { + if (sub_path.len == 0) return; + if (path.isAbsolute(sub_path)) { + makeDirAbsolute(sub_path) catch |err| switch (err) { + error.PathAlreadyExists => return, + else => |e| return e, + }; + return; + } + + var cursor = self; + var opened: ?Dir = null; + defer if (opened) |dir| dir.close(); + + var index: usize = 0; + while (index < sub_path.len) { + while (index < sub_path.len and path.isSep(sub_path[index])) : (index += 1) {} + if (index >= sub_path.len) break; + + const start = index; + while (index < sub_path.len and !path.isSep(sub_path[index])) : (index += 1) {} + const component = sub_path[start..index]; + if (component.len == 0 or std.mem.eql(u8, component, ".")) continue; + if (std.mem.eql(u8, component, "..")) return error.BadPathName; + + cursor.makeDir(component) catch |err| switch (err) { + error.PathAlreadyExists => {}, + else => |e| return e, + }; + + const next = try cursor.openDir(component, .{}); + if (opened) |dir| dir.close(); + opened = next; + cursor = next; + } + } +}; + +pub fn cwd() Dir { + return Dir.cwd(); +} + +pub fn openDirAbsolute(absolute_path: []const u8, options: Dir.OpenDirOptions) Io.Dir.OpenError!Dir { + return Dir.wrap(try Io.Dir.openDirAbsolute(shared.io(), absolute_path, options)); +} + +pub fn openFileAbsolute(absolute_path: []const u8, options: Dir.OpenFileOptions) Io.File.OpenError!File { + return File.wrap(try Io.Dir.openFileAbsolute(shared.io(), absolute_path, options)); +} + +pub fn createFileAbsolute(absolute_path: []const u8, options: Dir.CreateFileOptions) Io.File.OpenError!File { + return File.wrap(try Io.Dir.createFileAbsolute(shared.io(), absolute_path, options)); +} + +pub fn accessAbsolute(absolute_path: []const u8, options: Dir.AccessOptions) Io.Dir.AccessError!void { + try Io.Dir.accessAbsolute(shared.io(), absolute_path, options); +} + +pub fn makeDirAbsolute(absolute_path: []const u8) Io.Dir.CreateDirError!void { + try Io.Dir.createDirAbsolute(shared.io(), absolute_path, .default_dir); +} + +pub fn deleteFileAbsolute(absolute_path: []const u8) Io.Dir.DeleteFileError!void { + try Io.Dir.deleteFileAbsolute(shared.io(), absolute_path); +} + +pub fn deleteTreeAbsolute(absolute_path: []const u8) (Io.Dir.DeleteTreeError || error{FileNotFound})!void { + const dir_path = path.dirname(absolute_path) orelse return error.FileNotFound; + const base_name = path.basename(absolute_path); + var dir = try openDirAbsolute(dir_path, .{}); + defer dir.close(); + try dir.deleteTree(base_name); +} + +pub fn renameAbsolute(old_path: []const u8, new_path: []const u8) Io.Dir.RenameError!void { + try Io.Dir.renameAbsolute(old_path, new_path, shared.io()); +} + +pub fn realpathAlloc(allocator: Allocator, file_path: []const u8) ![]u8 { + if (path.isAbsolute(file_path)) { + const path_z = try Io.Dir.realPathFileAbsoluteAlloc(shared.io(), file_path, allocator); + defer allocator.free(path_z); + return try allocator.dupe(u8, path_z); + } + return try cwd().realpathAlloc(allocator, file_path); +} diff --git a/src/compat/shared.zig b/src/compat/shared.zig new file mode 100644 index 0000000..713111d --- /dev/null +++ b/src/compat/shared.zig @@ -0,0 +1,55 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const Allocator = std.mem.Allocator; + +pub const Io = std.Io; + +var fallback_threaded: Io.Threaded = .init_single_threaded; +var process_io: ?Io = null; +var process_args: ?std.process.Args = null; +var process_environ: ?std.process.Environ = null; + +pub fn initProcess(init: std.process.Init) void { + process_io = init.io; + process_args = init.minimal.args; + process_environ = init.minimal.environ; +} + +pub fn initProcessMinimal(init: std.process.Init.Minimal) void { + process_args = init.args; + process_environ = init.environ; +} + +pub fn io() Io { + if (builtin.is_test) return std.testing.io; + if (process_io) |current| return current; + return fallback_threaded.io(); +} + +pub fn environ() std.process.Environ { + if (process_environ) |env| return env; + return .empty; +} + +pub fn argsAlloc(allocator: Allocator) ![]const [:0]const u8 { + const args = process_args orelse return error.MissingProcessContext; + var iter = try args.iterateAllocator(allocator); + defer iter.deinit(); + + var list: std.ArrayList([:0]const u8) = .empty; + errdefer { + for (list.items) |arg| allocator.free(arg); + list.deinit(allocator); + } + + while (iter.next()) |arg| { + try list.append(allocator, try allocator.dupeZ(u8, arg)); + } + + return try list.toOwnedSlice(allocator); +} + +pub fn argsFree(allocator: Allocator, args: []const [:0]const u8) void { + for (args) |arg| allocator.free(arg); + allocator.free(args); +} diff --git a/src/config.zig b/src/config.zig index db44dc3..3afada4 100644 --- a/src/config.zig +++ b/src/config.zig @@ -1,5 +1,6 @@ const std = @import("std"); const builtin = @import("builtin"); +const std_compat = @import("compat.zig"); pub const home_env_var = "NULLWATCH_HOME"; pub const home_dir_name = ".nullwatch"; @@ -20,7 +21,7 @@ pub fn resolveConfigPath(allocator: std.mem.Allocator, override_path: ?[]const u } pub fn resolveHomeDir(allocator: std.mem.Allocator) ![]const u8 { - if (std.process.getEnvVarOwned(allocator, home_env_var)) |env_home| { + if (std_compat.process.getEnvVarOwned(allocator, home_env_var)) |env_home| { return env_home; } else |err| switch (err) { error.EnvironmentVariableNotFound => {}, @@ -33,7 +34,7 @@ pub fn resolveHomeDir(allocator: std.mem.Allocator) ![]const u8 { } pub fn loadFromFile(allocator: std.mem.Allocator, path: []const u8) !Config { - const file = std.fs.cwd().openFile(path, .{}) catch |err| { + const file = std_compat.fs.cwd().openFile(path, .{}) catch |err| { if (err == error.FileNotFound) return Config{}; return err; }; @@ -56,10 +57,10 @@ fn resolveRelativePath(allocator: std.mem.Allocator, config_path: []const u8, va } fn getHomeDirOwned(allocator: std.mem.Allocator) ![]u8 { - return std.process.getEnvVarOwned(allocator, "HOME") catch |err| switch (err) { + return std_compat.process.getEnvVarOwned(allocator, "HOME") catch |err| switch (err) { error.EnvironmentVariableNotFound => { if (builtin.os.tag == .windows) { - return std.process.getEnvVarOwned(allocator, "USERPROFILE") catch error.HomeNotSet; + return std_compat.process.getEnvVarOwned(allocator, "USERPROFILE") catch error.HomeNotSet; } return error.HomeNotSet; }, @@ -79,8 +80,9 @@ test "resolveRelativePaths anchors data dir to config directory" { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); - try tmp.dir.makePath("configs"); - try tmp.dir.writeFile(.{ + const tmp_dir = std_compat.fs.Dir.wrap(tmp.dir); + try tmp_dir.makePath("configs"); + try tmp_dir.writeFile(.{ .sub_path = "configs/config.json", .data = \\{ @@ -89,7 +91,7 @@ test "resolveRelativePaths anchors data dir to config directory" { , }); - const cfg_path = try tmp.dir.realpathAlloc(std.testing.allocator, "configs/config.json"); + const cfg_path = try tmp_dir.realpathAlloc(std.testing.allocator, "configs/config.json"); defer std.testing.allocator.free(cfg_path); var arena = std.heap.ArenaAllocator.init(std.testing.allocator); diff --git a/src/export_manifest.zig b/src/export_manifest.zig index fb992a9..24360d9 100644 --- a/src/export_manifest.zig +++ b/src/export_manifest.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const std_compat = @import("compat.zig"); pub fn run() !void { const manifest = @@ -19,7 +20,7 @@ pub fn run() !void { \\ "aarch64-windows": { "asset": "nullwatch-windows-aarch64.exe", "binary": "nullwatch.exe" } \\ }, \\ "build_from_source": { - \\ "zig_version": "0.15.2", + \\ "zig_version": "0.16.0", \\ "command": "zig build -Doptimize=ReleaseSmall", \\ "output": "zig-out/bin/nullwatch" \\ }, @@ -41,7 +42,7 @@ pub fn run() !void { \\} ; - const stdout = std.fs.File.stdout(); + const stdout = std_compat.fs.File.stdout(); try stdout.writeAll(manifest); try stdout.writeAll("\n"); } diff --git a/src/from_json.zig b/src/from_json.zig index 71c7d45..f37b6d8 100644 --- a/src/from_json.zig +++ b/src/from_json.zig @@ -1,5 +1,6 @@ const std = @import("std"); const builtin = @import("builtin"); +const std_compat = @import("compat.zig"); const config_mod = @import("config.zig"); pub fn run(allocator: std.mem.Allocator, json_str: []const u8) !void { @@ -40,7 +41,7 @@ pub fn run(allocator: std.mem.Allocator, json_str: []const u8) !void { try writeFileAtHome(allocator, home, "config.json", config_json); if (!builtin.is_test) { - const stdout = std.fs.File.stdout(); + const stdout = std_compat.fs.File.stdout(); try stdout.writeAll("{\"status\":\"ok\"}\n"); } } @@ -61,17 +62,14 @@ fn getU16(obj: std.json.ObjectMap, key: []const u8) ?u16 { fn ensureHome(home: []const u8) !void { if (std.fs.path.isAbsolute(home)) { - std.fs.makeDirAbsolute(home) catch |err| switch (err) { + std_compat.fs.makeDirAbsolute(home) catch |err| switch (err) { error.PathAlreadyExists => {}, else => return err, }; return; } - std.fs.cwd().makePath(home) catch |err| switch (err) { - error.PathAlreadyExists => {}, - else => return err, - }; + try std_compat.fs.cwd().makePath(home); } fn writeFileAtHome(allocator: std.mem.Allocator, home: []const u8, name: []const u8, contents: []const u8) !void { @@ -79,14 +77,14 @@ fn writeFileAtHome(allocator: std.mem.Allocator, home: []const u8, name: []const defer allocator.free(path); if (std.fs.path.isAbsolute(home)) { - const file = try std.fs.createFileAbsolute(path, .{}); + const file = try std_compat.fs.createFileAbsolute(path, .{}); defer file.close(); try file.writeAll(contents); try file.writeAll("\n"); return; } - const file = try std.fs.cwd().createFile(path, .{}); + const file = try std_compat.fs.cwd().createFile(path, .{}); defer file.close(); try file.writeAll(contents); try file.writeAll("\n"); diff --git a/src/main.zig b/src/main.zig index 562e700..f82a130 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const std_compat = @import("compat.zig"); const api = @import("api.zig"); const config = @import("config.zig"); const domain = @import("domain.zig"); @@ -6,6 +7,7 @@ const Store = @import("store.zig").Store; const version = @import("version.zig"); const max_request_size: usize = 256 * 1024; +const request_read_chunk: usize = 4096; const RuntimeConfig = struct { host: []const u8, @@ -28,22 +30,33 @@ const RuntimeOverrides = struct { config_path: ?[]const u8 = null, }; -pub fn main() !void { - var gpa = std.heap.GeneralPurposeAllocator(.{}){}; - defer _ = gpa.deinit(); - const allocator = gpa.allocator(); +const ArgCursor = struct { + args: []const [:0]const u8, + index: usize = 0, - var args = try std.process.argsWithAllocator(allocator); - defer args.deinit(); - _ = args.next(); + fn next(self: *ArgCursor) ?[]const u8 { + if (self.index >= self.args.len) return null; + defer self.index += 1; + return self.args[self.index]; + } +}; + +pub fn main(init: std.process.Init) !void { + std_compat.initProcess(init); + const allocator = std.heap.smp_allocator; + + const args = try std_compat.process.argsAlloc(allocator); + defer std_compat.process.argsFree(allocator, args); - if (args.next()) |first_arg| { + if (args.len > 1) { + const first_arg = args[1]; if (std.mem.eql(u8, first_arg, "--export-manifest")) { try @import("export_manifest.zig").run(); return; } if (std.mem.eql(u8, first_arg, "--from-json")) { - if (args.next()) |json_str| { + if (args.len > 2) { + const json_str = args[2]; try @import("from_json.zig").run(allocator, json_str); } else { std.debug.print("error: --from-json requires a JSON argument\n", .{}); @@ -53,11 +66,11 @@ pub fn main() !void { } } - var args2 = try std.process.argsWithAllocator(allocator); - defer args2.deinit(); - _ = args2.next(); - - const command = args2.next() orelse "serve"; + var cursor = ArgCursor{ + .args = args, + .index = 1, + }; + const command = cursor.next() orelse "serve"; if (std.mem.eql(u8, command, "--version") or std.mem.eql(u8, command, "version")) { std.debug.print("nullwatch v{s}\n", .{version.string}); @@ -70,56 +83,56 @@ pub fn main() !void { } if (std.mem.eql(u8, command, "serve")) { - var parsed = try parseServeArgs(allocator, &args2); + var parsed = try parseServeArgs(allocator, &cursor); defer parsed.runtime.deinit(allocator); try runServer(allocator, parsed.runtime); return; } if (std.mem.eql(u8, command, "summary")) { - var parsed = try parseCommonArgs(allocator, &args2); + var parsed = try parseCommonArgs(allocator, &cursor); defer parsed.deinit(allocator); try runSummaryCommand(allocator, parsed.runtime); return; } if (std.mem.eql(u8, command, "runs")) { - var parsed = try parseRunsArgs(allocator, &args2); + var parsed = try parseRunsArgs(allocator, &cursor); defer parsed.common.runtime.deinit(allocator); try runRunsCommand(allocator, parsed.common.runtime, parsed.filter); return; } if (std.mem.eql(u8, command, "run")) { - var parsed = try parseRunDetailArgs(allocator, &args2); + var parsed = try parseRunDetailArgs(allocator, &cursor); defer parsed.common.runtime.deinit(allocator); try runDetailCommand(allocator, parsed.common.runtime, parsed.run_id); return; } if (std.mem.eql(u8, command, "spans")) { - var parsed = try parseSpansArgs(allocator, &args2); + var parsed = try parseSpansArgs(allocator, &cursor); defer parsed.common.runtime.deinit(allocator); try runSpansCommand(allocator, parsed.common.runtime, parsed.filter); return; } if (std.mem.eql(u8, command, "evals")) { - var parsed = try parseEvalsArgs(allocator, &args2); + var parsed = try parseEvalsArgs(allocator, &cursor); defer parsed.common.runtime.deinit(allocator); try runEvalsCommand(allocator, parsed.common.runtime, parsed.filter); return; } if (std.mem.eql(u8, command, "ingest-span")) { - var parsed = try parseJsonIngestArgs(allocator, &args2); + var parsed = try parseJsonIngestArgs(allocator, &cursor); defer parsed.common.runtime.deinit(allocator); try runSpanIngestCommand(allocator, parsed.common.runtime, parsed.json_payload); return; } if (std.mem.eql(u8, command, "ingest-eval")) { - var parsed = try parseJsonIngestArgs(allocator, &args2); + var parsed = try parseJsonIngestArgs(allocator, &cursor); defer parsed.common.runtime.deinit(allocator); try runEvalIngestCommand(allocator, parsed.common.runtime, parsed.json_payload); return; @@ -134,59 +147,36 @@ fn runServer(allocator: std.mem.Allocator, runtime: RuntimeConfig) !void { var store = try Store.init(allocator, runtime.data_dir); defer store.deinit(); - const addr = try std.net.Address.resolveIp(runtime.host, runtime.port); - var server = try addr.listen(.{ .reuse_address = true }); - defer server.deinit(); + const addr = try std.Io.net.IpAddress.resolve(std_compat.io(), runtime.host, runtime.port); + var server = try addr.listen(std_compat.io(), .{ .reuse_address = true }); + defer server.deinit(std_compat.io()); std.debug.print("nullwatch v{s}\n", .{version.string}); std.debug.print("data dir: {s}\n", .{runtime.data_dir}); std.debug.print("listening on http://{s}:{d}\n", .{ runtime.host, runtime.port }); while (true) { - const conn = server.accept() catch |err| { + var conn = server.accept(std_compat.io()) catch |err| { std.debug.print("accept error: {}\n", .{err}); continue; }; - defer conn.stream.close(); + defer conn.close(std_compat.io()); var arena = std.heap.ArenaAllocator.init(allocator); defer arena.deinit(); const req_alloc = arena.allocator(); - var req_buf: [max_request_size]u8 = undefined; - const n = conn.stream.read(&req_buf) catch continue; - if (n == 0) continue; - const raw = req_buf[0..n]; + const full_request = readHttpRequest(req_alloc, &conn, max_request_size) catch |err| { + std.debug.print("read error: {}\n", .{err}); + continue; + } orelse continue; - const first_line_end = std.mem.indexOf(u8, raw, "\r\n") orelse continue; - const first_line = raw[0..first_line_end]; + const first_line_end = std.mem.indexOf(u8, full_request, "\r\n") orelse continue; + const first_line = full_request[0..first_line_end]; var parts = std.mem.splitScalar(u8, first_line, ' '); const method = parts.next() orelse continue; const target = parts.next() orelse continue; - var full_request = raw; - if (api.extractHeader(raw, "Content-Length")) |cl_str| { - const content_length = std.fmt.parseInt(usize, cl_str, 10) catch 0; - if (content_length > 0) { - const header_end_pos = std.mem.indexOf(u8, raw, "\r\n\r\n") orelse continue; - const body_start = header_end_pos + 4; - const body_received = n - body_start; - if (body_received < content_length) { - const total_size = body_start + content_length; - if (total_size > max_request_size) continue; - const full_buf = req_alloc.alloc(u8, total_size) catch continue; - @memcpy(full_buf[0..n], raw); - var total_read = n; - while (total_read < total_size) { - const extra = conn.stream.read(full_buf[total_read..total_size]) catch break; - if (extra == 0) break; - total_read += extra; - } - full_request = full_buf[0..total_read]; - } - } - } - const body = api.extractBody(full_request); var ctx = api.Context{ .store = &store, @@ -201,11 +191,55 @@ fn runServer(allocator: std.mem.Allocator, runtime: RuntimeConfig) !void { "HTTP/1.1 {s}\r\nContent-Type: application/json\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", .{ response.status, response.body.len }, ) catch continue; - _ = conn.stream.write(header) catch continue; - _ = conn.stream.write(response.body) catch continue; + var resp_write_buffer: [1024]u8 = undefined; + var writer = conn.writer(std_compat.io(), &resp_write_buffer); + writer.interface.writeAll(header) catch continue; + writer.interface.writeAll(response.body) catch continue; + writer.interface.flush() catch continue; } } +fn readHttpRequest(allocator: std.mem.Allocator, stream: *std.Io.net.Stream, max_bytes: usize) !?[]u8 { + var buffer: std.ArrayListUnmanaged(u8) = .empty; + defer buffer.deinit(allocator); + + var read_buffer: [request_read_chunk]u8 = undefined; + var reader = stream.reader(std_compat.io(), &read_buffer); + + while (true) { + const line = reader.interface.takeDelimiterInclusive('\n') catch |err| switch (err) { + error.EndOfStream => { + if (buffer.items.len == 0) return null; + return error.UnexpectedEof; + }, + else => |e| return e, + }; + + try buffer.appendSlice(allocator, line); + if (buffer.items.len > max_bytes) return error.RequestTooLarge; + + if (std.mem.eql(u8, line, "\r\n") or std.mem.eql(u8, line, "\n")) break; + } + + const header_end = std.mem.indexOf(u8, buffer.items, "\r\n\r\n") orelse return error.InvalidRequest; + const content_len = if (api.extractHeader(buffer.items[0 .. header_end + 4], "Content-Length")) |cl_str| + (std.fmt.parseInt(usize, cl_str, 10) catch return error.InvalidContentLength) + else + 0; + + const required = header_end + 4 + content_len; + if (required > max_bytes) return error.RequestTooLarge; + + if (content_len > 0) { + const body = try allocator.alloc(u8, content_len); + defer allocator.free(body); + try reader.interface.readSliceAll(body); + try buffer.appendSlice(allocator, body); + } + + return try allocator.dupe(u8, buffer.items[0..required]); +} + fn runSummaryCommand(allocator: std.mem.Allocator, runtime: RuntimeConfig) !void { var store = try Store.init(allocator, runtime.data_dir); defer store.deinit(); @@ -294,7 +328,7 @@ fn runEvalIngestCommand(allocator: std.mem.Allocator, runtime: RuntimeConfig, js try writeJsonToStdout(allocator, record); } -fn parseServeArgs(allocator: std.mem.Allocator, args: *std.process.ArgIterator) !struct { runtime: RuntimeConfig } { +fn parseServeArgs(allocator: std.mem.Allocator, args: *ArgCursor) !struct { runtime: RuntimeConfig } { var overrides = RuntimeOverrides{}; while (args.next()) |arg| { @@ -305,7 +339,7 @@ fn parseServeArgs(allocator: std.mem.Allocator, args: *std.process.ArgIterator) return .{ .runtime = try resolveRuntimeConfig(allocator, overrides) }; } -fn parseCommonArgs(allocator: std.mem.Allocator, args: *std.process.ArgIterator) !struct { +fn parseCommonArgs(allocator: std.mem.Allocator, args: *ArgCursor) !struct { runtime: RuntimeConfig, fn deinit(self: *@This(), alloc: std.mem.Allocator) void { @@ -321,7 +355,7 @@ fn parseCommonArgs(allocator: std.mem.Allocator, args: *std.process.ArgIterator) return .{ .runtime = try resolveRuntimeConfig(allocator, overrides) }; } -fn parseRunsArgs(allocator: std.mem.Allocator, args: *std.process.ArgIterator) !struct { +fn parseRunsArgs(allocator: std.mem.Allocator, args: *ArgCursor) !struct { common: struct { runtime: RuntimeConfig }, filter: domain.RunFilter, } { @@ -359,7 +393,7 @@ fn parseRunsArgs(allocator: std.mem.Allocator, args: *std.process.ArgIterator) ! }; } -fn parseRunDetailArgs(allocator: std.mem.Allocator, args: *std.process.ArgIterator) !struct { +fn parseRunDetailArgs(allocator: std.mem.Allocator, args: *ArgCursor) !struct { common: struct { runtime: RuntimeConfig }, run_id: []const u8, } { @@ -377,7 +411,7 @@ fn parseRunDetailArgs(allocator: std.mem.Allocator, args: *std.process.ArgIterat }; } -fn parseSpansArgs(allocator: std.mem.Allocator, args: *std.process.ArgIterator) !struct { +fn parseSpansArgs(allocator: std.mem.Allocator, args: *ArgCursor) !struct { common: struct { runtime: RuntimeConfig }, filter: domain.SpanFilter, } { @@ -419,7 +453,7 @@ fn parseSpansArgs(allocator: std.mem.Allocator, args: *std.process.ArgIterator) }; } -fn parseEvalsArgs(allocator: std.mem.Allocator, args: *std.process.ArgIterator) !struct { +fn parseEvalsArgs(allocator: std.mem.Allocator, args: *ArgCursor) !struct { common: struct { runtime: RuntimeConfig }, filter: domain.EvalFilter, } { @@ -451,7 +485,7 @@ fn parseEvalsArgs(allocator: std.mem.Allocator, args: *std.process.ArgIterator) }; } -fn parseJsonIngestArgs(allocator: std.mem.Allocator, args: *std.process.ArgIterator) !struct { +fn parseJsonIngestArgs(allocator: std.mem.Allocator, args: *ArgCursor) !struct { common: struct { runtime: RuntimeConfig }, json_payload: []const u8, } { @@ -474,7 +508,7 @@ fn parseJsonIngestArgs(allocator: std.mem.Allocator, args: *std.process.ArgItera } fn maybeParseRuntimeFlag( - args: *std.process.ArgIterator, + args: *ArgCursor, overrides: *RuntimeOverrides, arg: []const u8, allow_port_and_host: bool, @@ -525,17 +559,17 @@ fn resolveRuntimeConfig(allocator: std.mem.Allocator, overrides: RuntimeOverride }; } -fn parseRequiredU16(args: *std.process.ArgIterator, flag: []const u8) !u16 { +fn parseRequiredU16(args: *ArgCursor, flag: []const u8) !u16 { const value = try requireNext(args, flag); return std.fmt.parseInt(u16, value, 10); } -fn parseRequiredUsize(args: *std.process.ArgIterator, flag: []const u8) !usize { +fn parseRequiredUsize(args: *ArgCursor, flag: []const u8) !usize { const value = try requireNext(args, flag); return std.fmt.parseInt(usize, value, 10); } -fn requireNext(args: *std.process.ArgIterator, flag: []const u8) ![]const u8 { +fn requireNext(args: *ArgCursor, flag: []const u8) ![]const u8 { return args.next() orelse { std.debug.print("missing value for {s}\n", .{flag}); return error.MissingArgument; @@ -543,14 +577,12 @@ fn requireNext(args: *std.process.ArgIterator, flag: []const u8) ![]const u8 { } fn writeJsonToStdout(allocator: std.mem.Allocator, value: anytype) !void { - var out = std.io.Writer.Allocating.init(allocator); - defer out.deinit(); - try std.json.Stringify.value(value, .{ .whitespace = .indent_2 }, &out.writer); - const body = try out.toOwnedSlice(); + const body = try std.json.Stringify.valueAlloc(allocator, value, .{ .whitespace = .indent_2 }); defer allocator.free(body); - try std.fs.File.stdout().writeAll(body); - try std.fs.File.stdout().writeAll("\n"); + const stdout = std_compat.fs.File.stdout(); + try stdout.writeAll(body); + try stdout.writeAll("\n"); } fn printUsage() void { @@ -585,7 +617,8 @@ fn printUsage() void { \\ POST /v1/traces \\ POST /otlp/v1/traces \\ - , .{version.string}, + , + .{version.string}, ); } diff --git a/src/store.zig b/src/store.zig index 3f0846e..a9ada2d 100644 --- a/src/store.zig +++ b/src/store.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const std_compat = @import("compat.zig"); const domain = @import("domain.zig"); pub const Store = struct { @@ -40,7 +41,7 @@ pub const Store = struct { const id = try std.fmt.allocPrint(self.allocator, "spn-{d}", .{self.spans.items.len + 1}); errdefer self.allocator.free(id); - const stored_at_ms = std.time.milliTimestamp(); + const stored_at_ms = std_compat.time.milliTimestamp(); var record = try domain.materializeSpanRecord(self.allocator, payload, id, stored_at_ms); errdefer domain.freeSpanRecord(self.allocator, &record); @@ -53,7 +54,7 @@ pub const Store = struct { const id = try std.fmt.allocPrint(self.allocator, "eval-{d}", .{self.evals.items.len + 1}); errdefer self.allocator.free(id); - const stored_at_ms = std.time.milliTimestamp(); + const stored_at_ms = std_compat.time.milliTimestamp(); var record = try domain.materializeEvalRecord(self.allocator, payload, id, stored_at_ms); errdefer domain.freeEvalRecord(self.allocator, &record); @@ -419,27 +420,24 @@ fn finalizeVerdict(summary: *domain.RunSummary) void { fn ensureDirExists(path: []const u8) !void { if (std.fs.path.isAbsolute(path)) { - std.fs.makeDirAbsolute(path) catch |err| switch (err) { + std_compat.fs.makeDirAbsolute(path) catch |err| switch (err) { error.PathAlreadyExists => {}, else => return err, }; return; } - std.fs.cwd().makePath(path) catch |err| switch (err) { - error.PathAlreadyExists => {}, - else => return err, - }; + try std_compat.fs.cwd().makePath(path); } fn readFileIfExists(allocator: std.mem.Allocator, path: []const u8, max_bytes: usize) ![]u8 { const file = if (std.fs.path.isAbsolute(path)) - std.fs.openFileAbsolute(path, .{}) catch |err| { + std_compat.fs.openFileAbsolute(path, .{}) catch |err| { if (err == error.FileNotFound) return error.FileNotFound; return err; } else - std.fs.cwd().openFile(path, .{}) catch |err| { + std_compat.fs.cwd().openFile(path, .{}) catch |err| { if (err == error.FileNotFound) return error.FileNotFound; return err; }; @@ -447,25 +445,23 @@ fn readFileIfExists(allocator: std.mem.Allocator, path: []const u8, max_bytes: u return file.readToEndAlloc(allocator, max_bytes); } -fn createFileForAppend(path: []const u8) !std.fs.File { +fn createFileForAppend(path: []const u8) !std_compat.fs.File { if (std.fs.path.isAbsolute(path)) { - return std.fs.createFileAbsolute(path, .{ .truncate = false, .read = true }); + return std_compat.fs.createFileAbsolute(path, .{ .truncate = false, .read = true }); } - return std.fs.cwd().createFile(path, .{ .truncate = false, .read = true }); + return std_compat.fs.cwd().createFile(path, .{ .truncate = false, .read = true }); } fn encodeJson(allocator: std.mem.Allocator, value: anytype) ![]u8 { - var out = std.io.Writer.Allocating.init(allocator); - defer out.deinit(); - try std.json.Stringify.value(value, .{}, &out.writer); - return try out.toOwnedSlice(); + return try std.json.Stringify.valueAlloc(allocator, value, .{}); } test "store ingests and reloads jsonl data" { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); - const data_dir = try tmp.dir.realpathAlloc(std.testing.allocator, "."); + const tmp_dir = std_compat.fs.Dir.wrap(tmp.dir); + const data_dir = try tmp_dir.realpathAlloc(std.testing.allocator, "."); defer std.testing.allocator.free(data_dir); var store = try Store.init(std.testing.allocator, data_dir); @@ -499,7 +495,8 @@ test "list APIs filter by run and verdict" { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); - const data_dir = try tmp.dir.realpathAlloc(std.testing.allocator, "."); + const tmp_dir = std_compat.fs.Dir.wrap(tmp.dir); + const data_dir = try tmp_dir.realpathAlloc(std.testing.allocator, "."); defer std.testing.allocator.free(data_dir); var store = try Store.init(std.testing.allocator, data_dir);