delint/get basic async checks working
This commit is contained in:
parent
b3645a7bd1
commit
8860efb371
14 changed files with 388 additions and 74 deletions
|
|
@ -2,4 +2,4 @@
|
|||
prek = "0.4.1"
|
||||
zig = "0.16.0"
|
||||
zls = "0.16.0"
|
||||
"ubi:DonIsaac/zlint" = "0.7.9"
|
||||
"ubi:DonIsaac/zlint" = "0.8.1"
|
||||
|
|
|
|||
|
|
@ -23,14 +23,6 @@ repos:
|
|||
entry: bash -c 'printf "%s\n" "$@" | zlint --deny-warnings --fix -S' --
|
||||
language: system
|
||||
types: [zig]
|
||||
# zlint 0.7.9 misparses Zig 0.16's `async`/`await` method
|
||||
# names (Group.async, Future.await) as the legacy keywords,
|
||||
# firing false-positive "syntax error" diagnostics. Until
|
||||
# zlint catches up, exclude files that legitimately use the
|
||||
# std.Io.Group async/await methods. `zig fmt` rewrites the
|
||||
# `@"async"`/`@"await"` workaround back to the bare form,
|
||||
# so we can't dodge it locally either.
|
||||
exclude: ^src/(tui|PortfolioData|service)\.zig$
|
||||
- repo: https://github.com/batmac/pre-commit-zig
|
||||
rev: v0.3.0
|
||||
hooks:
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@
|
|||
//! `deinit()` cancels first, then frees.
|
||||
|
||||
const std = @import("std");
|
||||
const log = std.log.scoped(.portfolio_data);
|
||||
|
||||
const PortfolioData = @This();
|
||||
|
||||
|
|
@ -517,21 +518,21 @@ pub fn load(
|
|||
defer watchlist_set.deinit();
|
||||
var portfolio_set = std.StringHashMap(void).init(gpa);
|
||||
defer portfolio_set.deinit();
|
||||
for (syms) |s| portfolio_set.put(s, {}) catch {};
|
||||
for (syms) |s| portfolio_set.put(s, {}) catch return error.OutOfMemory;
|
||||
for (opts.watchlist_syms) |sym| {
|
||||
if (!portfolio_set.contains(sym)) watchlist_set.put(sym, {}) catch {};
|
||||
if (!portfolio_set.contains(sym)) watchlist_set.put(sym, {}) catch return error.OutOfMemory;
|
||||
}
|
||||
for (pf.lots) |lot| {
|
||||
if (lot.security_type == .watch) {
|
||||
const sym = lot.priceSymbol();
|
||||
if (!portfolio_set.contains(sym)) watchlist_set.put(sym, {}) catch {};
|
||||
if (!portfolio_set.contains(sym)) watchlist_set.put(sym, {}) catch return error.OutOfMemory;
|
||||
}
|
||||
}
|
||||
var watch_syms_list: std.ArrayList([]const u8) = .empty;
|
||||
defer watch_syms_list.deinit(gpa);
|
||||
{
|
||||
var it = watchlist_set.keyIterator();
|
||||
while (it.next()) |k| watch_syms_list.append(gpa, k.*) catch {};
|
||||
while (it.next()) |k| watch_syms_list.append(gpa, k.*) catch return error.OutOfMemory;
|
||||
}
|
||||
|
||||
// ── Fetch prices ──────────────────────────────────────────
|
||||
|
|
@ -579,10 +580,16 @@ pub fn load(
|
|||
const k = entry.key_ptr.*;
|
||||
const v = entry.value_ptr.*;
|
||||
if (portfolio_set.contains(k)) {
|
||||
prices.put(k, v) catch {};
|
||||
prices.put(k, v) catch return error.OutOfMemory;
|
||||
} else if (watchlist_set.contains(k)) {
|
||||
const owned = arena_alloc.dupe(u8, k) catch continue;
|
||||
wp.put(owned, v) catch {};
|
||||
wp.put(owned, v) catch |err| {
|
||||
// Free the just-duped key... except it's
|
||||
// arena-allocated, so the arena reaps it.
|
||||
// Skip this watchlist price; a missing
|
||||
// watchlist entry degrades display only.
|
||||
log.warn("watchlist price put({s}): {t}", .{ k, err });
|
||||
};
|
||||
}
|
||||
}
|
||||
self.latest_quote_date = load_all.latest_date;
|
||||
|
|
|
|||
|
|
@ -104,26 +104,52 @@ pub const Check = struct {
|
|||
run: *const fn (ctx: CheckCtx) CheckResult,
|
||||
};
|
||||
|
||||
/// Per-check execution state, lives on `CheckPanel`. Today every
|
||||
/// check is sync so `state` is always `.complete` immediately after
|
||||
/// `runChecks` returns. The async path is in place for future use.
|
||||
/// Per-check execution state, lives on `CheckPanel`. Sync checks
|
||||
/// are `.complete` immediately after `runChecks` returns;
|
||||
/// `is_long_running` checks start `.pending` and transition to
|
||||
/// `.complete` via `poll` or `awaitResult`.
|
||||
pub const PendingCheck = struct {
|
||||
check: *const Check,
|
||||
/// Completion flag set by the async wrapper as its final
|
||||
/// action. `poll` reads this to detect completion without
|
||||
/// blocking — `std.Io.Future` itself only offers blocking
|
||||
/// `await`/`cancel`, so the flag is what makes a
|
||||
/// non-blocking probe possible. The tiny window between
|
||||
/// flag-set and the future's internal result write is
|
||||
/// covered by the (then nearly instant) `await` in `poll`.
|
||||
done: std.atomic.Value(bool) = .init(false),
|
||||
state: union(enum) {
|
||||
complete: CheckResult,
|
||||
// Future: pending: std.Io.Future(CheckResult), populated
|
||||
// by io.async() and resolved via poll/await. Adding a
|
||||
// variant here is a non-breaking API change for current
|
||||
// consumers.
|
||||
pending: std.Io.Future(CheckResult),
|
||||
},
|
||||
|
||||
/// Returns the resolved `CheckResult`, awaiting if necessary.
|
||||
/// Idempotent.
|
||||
/// Returns the resolved `CheckResult`, awaiting (blocking) if
|
||||
/// necessary. Idempotent.
|
||||
pub fn awaitResult(self: *PendingCheck, io: std.Io) CheckResult {
|
||||
_ = io;
|
||||
return switch (self.state) {
|
||||
.complete => |r| r,
|
||||
};
|
||||
switch (self.state) {
|
||||
.complete => |r| return r,
|
||||
.pending => |*f| {
|
||||
const r = f.await(io);
|
||||
self.state = .{ .complete = r };
|
||||
return r;
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Non-blocking completion probe. Returns true when the
|
||||
/// result is available in `.complete` (transitioning it
|
||||
/// there if the async task just finished). Returns false
|
||||
/// while the task is still running.
|
||||
pub fn poll(self: *PendingCheck, io: std.Io) bool {
|
||||
switch (self.state) {
|
||||
.complete => return true,
|
||||
.pending => |*f| {
|
||||
if (!self.done.load(.acquire)) return false;
|
||||
const r = f.await(io);
|
||||
self.state = .{ .complete = r };
|
||||
return true;
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -132,23 +158,35 @@ pub const PendingCheck = struct {
|
|||
/// per-finding strings inside each `CheckResult`).
|
||||
pub const CheckPanel = struct {
|
||||
allocator: std.mem.Allocator,
|
||||
io: std.Io,
|
||||
pending: []PendingCheck,
|
||||
|
||||
pub fn deinit(self: *CheckPanel) void {
|
||||
for (self.pending) |pc| freeResult(self.allocator, pc.state.complete);
|
||||
for (self.pending) |*pc| {
|
||||
// Resolve any still-pending future before freeing.
|
||||
// `cancel` requests cancelation and blocks until the
|
||||
// task returns — the task may still produce a full
|
||||
// result (checks don't hit cancelation points today),
|
||||
// which we then free like any complete result.
|
||||
const result = switch (pc.state) {
|
||||
.complete => |r| r,
|
||||
.pending => |*f| f.cancel(self.io),
|
||||
};
|
||||
freeResult(self.allocator, result);
|
||||
}
|
||||
self.allocator.free(self.pending);
|
||||
self.* = undefined;
|
||||
}
|
||||
|
||||
/// True iff every check has a resolved result. Today always
|
||||
/// returns `true` immediately after `runChecks` (sync only).
|
||||
pub fn isComplete(self: *const CheckPanel) bool {
|
||||
for (self.pending) |pc| {
|
||||
switch (pc.state) {
|
||||
.complete => {},
|
||||
}
|
||||
/// True iff every check has a resolved result. Non-blocking:
|
||||
/// polls each pending check, transitioning newly-finished
|
||||
/// ones to `.complete` as a side effect.
|
||||
pub fn isComplete(self: *CheckPanel) bool {
|
||||
var all = true;
|
||||
for (self.pending) |*pc| {
|
||||
if (!pc.poll(self.io)) all = false;
|
||||
}
|
||||
return true;
|
||||
return all;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -175,25 +213,66 @@ fn freeObservations(a: std.mem.Allocator, obs: []const Observation) void {
|
|||
|
||||
// ── Runner ────────────────────────────────────────────────────
|
||||
|
||||
/// Async wrapper for long-running checks. Runs the check, then
|
||||
/// sets the completion flag as the final action so `poll` can
|
||||
/// detect the result without blocking.
|
||||
///
|
||||
/// IMPORTANT: `done` points into the panel's `pending` array,
|
||||
/// and `ctx`'s borrowed slices (`rows`, `totals`) must outlive
|
||||
/// the task. Both invariants are owned by `runChecks`'s caller
|
||||
/// contract: the panel and the review view that owns ctx's rows
|
||||
/// live until `CheckPanel.deinit`, which resolves all futures
|
||||
/// before anything is freed.
|
||||
fn runCheckTask(check: *const Check, ctx: CheckCtx, done: *std.atomic.Value(bool)) CheckResult {
|
||||
const result = check.run(ctx);
|
||||
done.store(true, .release);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Run the registered checks against the given context. Returns a
|
||||
/// `CheckPanel` that renderers consume. Sync today; the contract
|
||||
/// allows a future async dispatch path for `is_long_running` checks.
|
||||
/// `CheckPanel` that renderers consume.
|
||||
///
|
||||
/// Sync checks (`is_long_running == false`) complete inline; the
|
||||
/// panel entry is `.complete` on return. Long-running checks are
|
||||
/// dispatched via `io.async` and start `.pending`; callers
|
||||
/// resolve them with `PendingCheck.poll` (non-blocking, for
|
||||
/// progressive TUI rendering) or `awaitResult` (blocking, for
|
||||
/// CLI output).
|
||||
///
|
||||
/// Lifetime contract: `ctx.rows` / `ctx.totals` are borrowed by
|
||||
/// in-flight async checks. The caller must keep them alive until
|
||||
/// every pending check resolves — in practice both the panel and
|
||||
/// the rows live on the same `ReviewView` and are torn down
|
||||
/// together by `ReviewView.deinit` (panel first, which resolves
|
||||
/// stragglers via cancel).
|
||||
pub fn runChecks(
|
||||
allocator: std.mem.Allocator,
|
||||
io: std.Io,
|
||||
ctx: CheckCtx,
|
||||
checks: []const Check,
|
||||
) !CheckPanel {
|
||||
_ = io;
|
||||
var pending = try allocator.alloc(PendingCheck, checks.len);
|
||||
errdefer allocator.free(pending);
|
||||
|
||||
// Two passes: initialize every slot first so the `done`
|
||||
// flags have stable addresses, THEN spawn the async tasks
|
||||
// that point at them. (Spawning during the init loop would
|
||||
// hand out pointers into an array we're still writing.)
|
||||
for (checks, 0..) |*check, i| {
|
||||
const result = check.run(ctx);
|
||||
pending[i] = .{ .check = check, .state = .{ .complete = result } };
|
||||
pending[i] = .{ .check = check, .state = .{ .complete = .skipped } };
|
||||
}
|
||||
|
||||
return .{ .allocator = allocator, .pending = pending };
|
||||
for (checks, 0..) |*check, i| {
|
||||
if (check.is_long_running) {
|
||||
pending[i].state = .{
|
||||
.pending = io.async(runCheckTask, .{ check, ctx, &pending[i].done }),
|
||||
};
|
||||
} else {
|
||||
pending[i].state = .{ .complete = check.run(ctx) };
|
||||
}
|
||||
}
|
||||
|
||||
return .{ .allocator = allocator, .io = io, .pending = pending };
|
||||
}
|
||||
|
||||
// ── Threshold constants ───────────────────────────────────────
|
||||
|
|
@ -1001,6 +1080,9 @@ test "runChecks: produces a panel with one entry per check" {
|
|||
var panel = try runChecks(testing.allocator, std.testing.io, ctx, &default_checks);
|
||||
defer panel.deinit();
|
||||
try testing.expectEqual(default_checks.len, panel.pending.len);
|
||||
// Await everything (drift is is_long_running and runs async);
|
||||
// after awaiting, the panel must report complete.
|
||||
for (panel.pending) |*pc| _ = pc.awaitResult(std.testing.io);
|
||||
try testing.expect(panel.isComplete());
|
||||
}
|
||||
|
||||
|
|
@ -1012,12 +1094,82 @@ test "runChecks: empty portfolio every check passes or skips" {
|
|||
};
|
||||
var panel = try runChecks(testing.allocator, std.testing.io, ctx, &default_checks);
|
||||
defer panel.deinit();
|
||||
for (panel.pending) |pc| {
|
||||
const tag = std.meta.activeTag(pc.state.complete);
|
||||
for (panel.pending) |*pc| {
|
||||
const tag = std.meta.activeTag(pc.awaitResult(std.testing.io));
|
||||
try testing.expect(tag == .pass or tag == .skipped);
|
||||
}
|
||||
}
|
||||
|
||||
test "runChecks: async check resolves via poll without blocking forever" {
|
||||
// Pin the async-dispatch contract: an is_long_running check
|
||||
// starts pending and becomes complete via poll once its task
|
||||
// finishes. We can't observe the intermediate pending state
|
||||
// deterministically (the task may finish before we look),
|
||||
// but we CAN assert the poll loop terminates and yields the
|
||||
// right result.
|
||||
const slow_check = [_]Check{.{
|
||||
.name = "test_async",
|
||||
.label = "Test async",
|
||||
.is_long_running = true,
|
||||
.run = struct {
|
||||
fn run(c: CheckCtx) CheckResult {
|
||||
_ = c;
|
||||
return .pass;
|
||||
}
|
||||
}.run,
|
||||
}};
|
||||
const ctx: CheckCtx = .{
|
||||
.allocator = testing.allocator,
|
||||
.rows = &.{},
|
||||
.totals = emptyTotals(),
|
||||
};
|
||||
var panel = try runChecks(testing.allocator, std.testing.io, ctx, &slow_check);
|
||||
defer panel.deinit();
|
||||
|
||||
// Poll until complete (bounded loop; the task is trivially
|
||||
// fast, so thousands of iterations would indicate a real
|
||||
// hang — fail rather than spin forever).
|
||||
var iterations: usize = 0;
|
||||
while (!panel.isComplete()) : (iterations += 1) {
|
||||
try testing.expect(iterations < 1_000_000);
|
||||
}
|
||||
try testing.expectEqual(CheckResult.pass, panel.pending[0].awaitResult(std.testing.io));
|
||||
}
|
||||
|
||||
test "runChecks: deinit with unresolved async check does not leak or crash" {
|
||||
// The panel may be torn down while a check is still pending
|
||||
// (user quits the TUI mid-poll). deinit must cancel/resolve
|
||||
// the future and free whatever result it produced.
|
||||
const slow_check = [_]Check{.{
|
||||
.name = "test_async_abandon",
|
||||
.label = "Test abandon",
|
||||
.is_long_running = true,
|
||||
.run = struct {
|
||||
fn run(c: CheckCtx) CheckResult {
|
||||
// Allocate a real finding so deinit has something
|
||||
// to free — exercises the result-ownership path.
|
||||
const obs = c.allocator.alloc(Observation, 1) catch return .pass;
|
||||
obs[0] = .{
|
||||
.severity = .warn,
|
||||
.kind = c.allocator.dupe(u8, "test_async_abandon") catch return .pass,
|
||||
.target = c.allocator.dupe(u8, "TEST") catch return .pass,
|
||||
.text = c.allocator.dupe(u8, "test finding") catch return .pass,
|
||||
};
|
||||
return .{ .warn = obs };
|
||||
}
|
||||
}.run,
|
||||
}};
|
||||
const ctx: CheckCtx = .{
|
||||
.allocator = testing.allocator,
|
||||
.rows = &.{},
|
||||
.totals = emptyTotals(),
|
||||
};
|
||||
var panel = try runChecks(testing.allocator, std.testing.io, ctx, &slow_check);
|
||||
// Deinit immediately — no poll, no await. testing.allocator
|
||||
// catches any leak of the result allocations.
|
||||
panel.deinit();
|
||||
}
|
||||
|
||||
test "default_checks: every check name is unique" {
|
||||
for (default_checks, 0..) |a, i| {
|
||||
for (default_checks[i + 1 ..]) |b| {
|
||||
|
|
|
|||
|
|
@ -46,7 +46,12 @@ pub fn writeFileAtomic(
|
|||
});
|
||||
errdefer {
|
||||
tmp_file.close(io);
|
||||
std.Io.Dir.cwd().deleteFile(io, tmp_path) catch {};
|
||||
// Best-effort cleanup of the temp file while unwinding
|
||||
// the primary error; a failed delete leaves a stray
|
||||
// .tmp that the next write overwrites.
|
||||
std.Io.Dir.cwd().deleteFile(io, tmp_path) catch |err| {
|
||||
std.log.debug("atomic write cleanup deleteFile({s}): {t}", .{ tmp_path, err });
|
||||
};
|
||||
}
|
||||
|
||||
try tmp_file.writeStreamingAll(io, bytes);
|
||||
|
|
@ -58,7 +63,11 @@ pub fn writeFileAtomic(
|
|||
}
|
||||
|
||||
std.Io.Dir.cwd().rename(tmp_path, std.Io.Dir.cwd(), path, io) catch |err| {
|
||||
std.Io.Dir.cwd().deleteFile(io, tmp_path) catch {};
|
||||
// Same best-effort cleanup as above; the rename failure is
|
||||
// the error the caller needs to see.
|
||||
std.Io.Dir.cwd().deleteFile(io, tmp_path) catch |del_err| {
|
||||
std.log.debug("atomic write cleanup deleteFile({s}): {t}", .{ tmp_path, del_err });
|
||||
};
|
||||
return err;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -222,6 +222,14 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
);
|
||||
defer view.deinit(allocator);
|
||||
|
||||
// CLI is one-shot output — block until every async check
|
||||
// resolves so the rendered grid + findings are complete.
|
||||
// (The TUI renders progressively instead; see review_tab's
|
||||
// tick hook.)
|
||||
if (view.observations) |*panel| {
|
||||
for (panel.pending) |*pc| _ = pc.awaitResult(io);
|
||||
}
|
||||
|
||||
// Sort: explicit --sort overrides the default grouping.
|
||||
if (parsed.sort) |field| {
|
||||
review_view.sortRows(view.rows, field, parsed.sort_dir);
|
||||
|
|
@ -359,7 +367,13 @@ fn renderStatusGrid(
|
|||
var worst: u8 = 0; // 0=pass/skipped, 1=warn, 2=flag/err
|
||||
var worst_color: [3]u8 = cli.CLR_MUTED;
|
||||
for (panel.pending[i..end]) |pc| {
|
||||
const result = pc.state.complete;
|
||||
// The CLI awaits every check before rendering (see
|
||||
// run()), so .pending here would be a logic bug —
|
||||
// skip defensively rather than crash.
|
||||
const result = switch (pc.state) {
|
||||
.complete => |r| r,
|
||||
.pending => continue,
|
||||
};
|
||||
const rank: u8 = switch (result) {
|
||||
.pass, .skipped => 0,
|
||||
.warn => 1,
|
||||
|
|
@ -390,12 +404,15 @@ fn renderStatusGrid(
|
|||
}
|
||||
try out.print("{s} ", .{label});
|
||||
|
||||
const glyph: []const u8 = switch (pc.state.complete) {
|
||||
.pass => "✅\u{FE0F}",
|
||||
.warn => "⚠️",
|
||||
.flag => "❌\u{FE0F}",
|
||||
.skipped => "➖\u{FE0F}",
|
||||
.err => "🛑\u{FE0F}",
|
||||
const glyph: []const u8 = switch (pc.state) {
|
||||
.complete => |r| switch (r) {
|
||||
.pass => "✅\u{FE0F}",
|
||||
.warn => "⚠️",
|
||||
.flag => "❌\u{FE0F}",
|
||||
.skipped => "➖\u{FE0F}",
|
||||
.err => "🛑\u{FE0F}",
|
||||
},
|
||||
.pending => "⏳\u{FE0F}", // see defensive note above
|
||||
};
|
||||
try out.print("{s}", .{glyph});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,9 +64,14 @@ pub fn tryAcquire(self: *RateLimiter) bool {
|
|||
/// Acquire a token, blocking (sleeping) until one is available.
|
||||
pub fn acquire(self: *RateLimiter) void {
|
||||
while (!self.tryAcquire()) {
|
||||
// Sleep for the time needed to generate 1 token
|
||||
// Sleep for the time needed to generate 1 token. An
|
||||
// interrupted sleep (cancelation propagating through the
|
||||
// Io) just loops back to tryAcquire — the next refill
|
||||
// covers whatever fraction of the wait elapsed.
|
||||
const wait_ns: u64 = @intFromFloat(1.0 / self.refill_rate_per_ns);
|
||||
std.Io.sleep(self.io, .{ .nanoseconds = @intCast(wait_ns) }, .awake) catch {};
|
||||
std.Io.sleep(self.io, .{ .nanoseconds = @intCast(wait_ns) }, .awake) catch |err| {
|
||||
std.log.scoped(.rate_limiter).debug("acquire sleep interrupted: {t}", .{err});
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -74,7 +79,11 @@ pub fn acquire(self: *RateLimiter) void {
|
|||
/// Use after receiving a server-side 429 to wait before retrying.
|
||||
pub fn backoff(self: *RateLimiter) void {
|
||||
const wait_ns: u64 = @max(self.estimateWaitNs(), 2 * std.time.ns_per_s);
|
||||
std.Io.sleep(self.io, .{ .nanoseconds = @intCast(wait_ns) }, .awake) catch {};
|
||||
// Interrupted backoff sleep degrades to a shorter wait; the
|
||||
// caller's retry may hit 429 again and re-backoff.
|
||||
std.Io.sleep(self.io, .{ .nanoseconds = @intCast(wait_ns) }, .awake) catch |err| {
|
||||
std.log.scoped(.rate_limiter).debug("backoff sleep interrupted: {t}", .{err});
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns estimated wait time in nanoseconds until a token is available.
|
||||
|
|
|
|||
|
|
@ -28,9 +28,11 @@ pub fn Padded(comptime T: type) type {
|
|||
// we can measure its length and pad. 64 bytes covers
|
||||
// every realistic format-method output in this codebase
|
||||
// (Money's worst case is ~14 chars; Date is exactly 10).
|
||||
// An inner value exceeding the buffer surfaces as
|
||||
// WriteFailed to the caller rather than UB.
|
||||
var tmp: [64]u8 = undefined;
|
||||
var fixed = std.Io.Writer.fixed(&tmp);
|
||||
self.inner.format(&fixed) catch unreachable;
|
||||
try self.inner.format(&fixed);
|
||||
const text = fixed.buffered();
|
||||
|
||||
const pad = if (text.len >= self.width) 0 else self.width - text.len;
|
||||
|
|
|
|||
|
|
@ -742,7 +742,7 @@ fn gitInTestRepo(allocator: std.mem.Allocator, cwd: []const u8, argv: []const []
|
|||
defer allocator.free(result.stderr);
|
||||
switch (result.term) {
|
||||
.exited => |code| if (code != 0) {
|
||||
std.debug.print("git command failed (code {d}): {s}\nstderr: {s}\n", .{ code, std.mem.join(allocator, " ", argv) catch "?", result.stderr });
|
||||
std.log.err("git command failed (code {d}): {s}\nstderr: {s}", .{ code, std.mem.join(allocator, " ", argv) catch "?", result.stderr });
|
||||
return error.GitFailed;
|
||||
},
|
||||
else => return error.GitFailed,
|
||||
|
|
|
|||
|
|
@ -96,8 +96,11 @@ pub const Tiingo = struct {
|
|||
) !CandleAndCorporateActions {
|
||||
var from_buf: [10]u8 = undefined;
|
||||
var to_buf: [10]u8 = undefined;
|
||||
const from_str = std.fmt.bufPrint(&from_buf, "{f}", .{from}) catch unreachable;
|
||||
const to_str = std.fmt.bufPrint(&to_buf, "{f}", .{to}) catch unreachable;
|
||||
// Date's `{f}` output is exactly 10 bytes (YYYY-MM-DD), so
|
||||
// these cannot fail in practice; `try` instead of
|
||||
// `catch unreachable` to keep the error path honest.
|
||||
const from_str = try std.fmt.bufPrint(&from_buf, "{f}", .{from});
|
||||
const to_str = try std.fmt.bufPrint(&to_buf, "{f}", .{to});
|
||||
|
||||
const symbol_url = try std.fmt.allocPrint(allocator, base_url ++ "/{s}/prices", .{symbol});
|
||||
defer allocator.free(symbol_url);
|
||||
|
|
|
|||
|
|
@ -52,8 +52,11 @@ pub const TwelveData = struct {
|
|||
|
||||
var from_buf: [10]u8 = undefined;
|
||||
var to_buf: [10]u8 = undefined;
|
||||
const from_str = std.fmt.bufPrint(&from_buf, "{f}", .{from}) catch unreachable;
|
||||
const to_str = std.fmt.bufPrint(&to_buf, "{f}", .{to}) catch unreachable;
|
||||
// Date's `{f}` output is exactly 10 bytes (YYYY-MM-DD), so
|
||||
// these cannot fail in practice; `try` instead of
|
||||
// `catch unreachable` to keep the error path honest.
|
||||
const from_str = try std.fmt.bufPrint(&from_buf, "{f}", .{from});
|
||||
const to_str = try std.fmt.bufPrint(&to_buf, "{f}", .{to});
|
||||
|
||||
// TwelveData's max outputsize is 5000 data points per request.
|
||||
// For daily candles this covers ~20 years of trading days (~252/year),
|
||||
|
|
|
|||
44
src/tui.zig
44
src/tui.zig
|
|
@ -468,6 +468,19 @@ pub const App = struct {
|
|||
/// observation engine async dispatch, etc.) has a wall-clock-
|
||||
/// independent tick to react to.
|
||||
frame: u64 = 0,
|
||||
/// True when the active tab has async work in flight that needs
|
||||
/// poll-driven redraws (e.g. review's observation panel with
|
||||
/// pending async checks). Set by the tab (loadData / activate);
|
||||
/// cleared by the tab when the work completes or the tab
|
||||
/// deactivates. While set, the event handler keeps a one-shot
|
||||
/// vxfw Tick timer armed so the draw loop wakes up to poll —
|
||||
/// without it, vxfw sleeps until the next user event and async
|
||||
/// results would sit invisible until a keypress.
|
||||
wants_poll_tick: bool = false,
|
||||
/// True while a vxfw Tick command is scheduled and not yet
|
||||
/// delivered. Prevents stacking duplicate timers (each event
|
||||
/// would otherwise arm another).
|
||||
poll_tick_armed: bool = false,
|
||||
|
||||
has_explicit_symbol: bool = false, // true if -s was used
|
||||
|
||||
|
|
@ -519,6 +532,27 @@ pub const App = struct {
|
|||
|
||||
fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vaxis.vxfw.EventContext, event: vaxis.vxfw.Event) anyerror!void {
|
||||
const self: *App = @ptrCast(@alignCast(ptr));
|
||||
// Poll-driven redraw support: while a tab has async work
|
||||
// in flight (`wants_poll_tick`), keep a one-shot vxfw
|
||||
// Tick timer armed. The timer fires a `.tick` event,
|
||||
// which requests a redraw; the draw pass runs the active
|
||||
// tab's `tick` hook, which polls the async work and
|
||||
// clears `wants_poll_tick` when done. Re-armed here on
|
||||
// every event (including the .tick itself) until the tab
|
||||
// clears the flag. 100ms cadence — fast enough to feel
|
||||
// responsive, slow enough to be invisible in CPU terms.
|
||||
defer if (self.wants_poll_tick and !self.poll_tick_armed) {
|
||||
if (ctx.tick(100, self.widget())) {
|
||||
self.poll_tick_armed = true;
|
||||
} else |err| {
|
||||
// OOM appending the Tick command. Leave
|
||||
// poll_tick_armed false so the next event retries
|
||||
// the arm; worst case the poll degrades to
|
||||
// event-driven (pre-timer behavior), not a
|
||||
// permanently stuck spinner.
|
||||
std.log.scoped(.tui).warn("poll tick arm failed: {t}", .{err});
|
||||
}
|
||||
};
|
||||
switch (event) {
|
||||
.key_press => |key| {
|
||||
// Tab-level pre-empt. The active tab gets first
|
||||
|
|
@ -553,6 +587,16 @@ pub const App = struct {
|
|||
.init => {
|
||||
self.loadTabData();
|
||||
},
|
||||
.tick => {
|
||||
// Our scheduled poll timer fired. Mark it
|
||||
// un-armed (the defer above re-arms if the tab
|
||||
// still wants polling) and request a redraw so
|
||||
// the draw pass runs the tab's tick hook.
|
||||
self.poll_tick_armed = false;
|
||||
if (self.wants_poll_tick) {
|
||||
ctx.redraw = true;
|
||||
}
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -142,6 +142,13 @@ pub const State = struct {
|
|||
/// Whether already-acknowledged findings are rendered. Toggled
|
||||
/// by `toggle_show_acked` (`v` key by default).
|
||||
show_acked: bool = false,
|
||||
/// True while the observation panel has unresolved async
|
||||
/// checks. Set when `loadData` builds a view whose panel
|
||||
/// isn't complete; cleared by `tick` once every check
|
||||
/// resolves (at which point findings_view is rebuilt to
|
||||
/// include the late results). Keeps the per-frame tick poll
|
||||
/// cost at zero for the common all-sync case.
|
||||
observations_pending: bool = false,
|
||||
|
||||
// ── Single-cursor (single-sheet) navigation ──────────────
|
||||
|
||||
|
|
@ -253,6 +260,10 @@ pub const tab = struct {
|
|||
|
||||
pub fn activate(state: *State, app: *App) !void {
|
||||
if (tab.isDisabled(app)) return;
|
||||
// Re-arm the poll timer if we still have async checks in
|
||||
// flight from a previous activation (user switched away
|
||||
// and back while a check was running).
|
||||
if (state.observations_pending) app.wants_poll_tick = true;
|
||||
if (state.loaded) return;
|
||||
// Load the journal before building findings; `loadData`
|
||||
// joins both into `findings_view`.
|
||||
|
|
@ -260,7 +271,16 @@ pub const tab = struct {
|
|||
loadData(state, app);
|
||||
}
|
||||
|
||||
pub const deactivate = framework.noopDeactivate(State);
|
||||
/// Pause poll-driven redraws while this tab is inactive. The
|
||||
/// tick hook only dispatches to the ACTIVE tab, so leaving
|
||||
/// `wants_poll_tick` set after switching away would spin the
|
||||
/// 100ms timer with nobody polling. `activate` re-arms when
|
||||
/// the user returns; the async check keeps running in the
|
||||
/// background either way.
|
||||
pub fn deactivate(state: *State, app: *App) void {
|
||||
_ = state;
|
||||
app.wants_poll_tick = false;
|
||||
}
|
||||
|
||||
/// Manual refresh: drops the cached view and re-builds. Also
|
||||
/// invalidates the shared `account_map` and `classification_map`
|
||||
|
|
@ -280,7 +300,29 @@ pub const tab = struct {
|
|||
loadData(state, app);
|
||||
}
|
||||
|
||||
pub const tick = framework.noopTick(State);
|
||||
/// Poll in-flight async observation checks. No-op (one bool
|
||||
/// test) unless `loadData` left the panel incomplete. When
|
||||
/// the last pending check resolves, rebuild the findings
|
||||
/// view so late findings appear, and clear both the local
|
||||
/// flag and the App-level poll-tick request so subsequent
|
||||
/// frames cost nothing.
|
||||
///
|
||||
/// Redraw cadence: while `app.wants_poll_tick` is set, the
|
||||
/// App keeps a 100ms vxfw Tick timer armed (see
|
||||
/// `typeErasedEventHandler`), so this hook gets called
|
||||
/// even when the user isn't generating events. Without
|
||||
/// that timer, async results would sit invisible until
|
||||
/// the next keypress.
|
||||
pub fn tick(state: *State, app: *App, frame: u64) void {
|
||||
_ = frame;
|
||||
if (!state.observations_pending) return;
|
||||
const view = if (state.view) |*v| v else return;
|
||||
const panel = if (view.observations) |*p| p else return;
|
||||
if (!panel.isComplete()) return;
|
||||
state.observations_pending = false;
|
||||
app.wants_poll_tick = false;
|
||||
rebuildFindingsView(state, app);
|
||||
}
|
||||
|
||||
pub fn handleAction(state: *State, app: *App, action: Action) void {
|
||||
switch (action) {
|
||||
|
|
@ -954,6 +996,20 @@ fn loadData(state: *State, app: *App) void {
|
|||
|
||||
applySort(state);
|
||||
rebuildFindingsView(state, app);
|
||||
|
||||
// If any check is still running (async dispatch), arm the
|
||||
// tick-poll so the findings view refreshes when results
|
||||
// land. `isComplete` also transitions newly-finished checks,
|
||||
// so a fast async check that finished during buildReview is
|
||||
// caught right here without waiting for the next frame.
|
||||
// `wants_poll_tick` tells the App to keep a vxfw Tick timer
|
||||
// armed so the poll runs even with no user input.
|
||||
if (state.view) |*v| {
|
||||
if (v.observations) |*panel| {
|
||||
state.observations_pending = !panel.isComplete();
|
||||
if (state.observations_pending) app.wants_poll_tick = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the absolute path to `acknowledgments.srf` next to the
|
||||
|
|
@ -1462,11 +1518,15 @@ fn appendStatusGrid(
|
|||
// Promote the row's worst-severity color so multi-cell rows
|
||||
// with mixed states still draw the user's eye to the bad
|
||||
// ones. Order from best to worst: pass < skipped < warn <
|
||||
// flag/err.
|
||||
// flag/err. Pending checks rank with pass (no news isn't
|
||||
// bad news yet).
|
||||
var row_style = th.mutedStyle();
|
||||
var worst: u8 = 0; // 0=pass/skipped, 1=warn, 2=flag/err
|
||||
var worst: u8 = 0; // 0=pass/skipped/pending, 1=warn, 2=flag/err
|
||||
for (panel.pending[i..end]) |pc| {
|
||||
const result = pc.state.complete;
|
||||
const result = switch (pc.state) {
|
||||
.complete => |r| r,
|
||||
.pending => continue,
|
||||
};
|
||||
const rank: u8 = switch (result) {
|
||||
.pass, .skipped => 0,
|
||||
.warn => 1,
|
||||
|
|
@ -1482,7 +1542,11 @@ fn appendStatusGrid(
|
|||
try text.appendSlice(arena, " ");
|
||||
for (panel.pending[i..end], 0..) |pc, col| {
|
||||
if (col > 0) try text.appendSlice(arena, " ");
|
||||
try appendStatusCell(arena, &text, pc.check.label, pc.state.complete);
|
||||
const result: ?observations.CheckResult = switch (pc.state) {
|
||||
.complete => |r| r,
|
||||
.pending => null,
|
||||
};
|
||||
try appendStatusCell(arena, &text, pc.check.label, result);
|
||||
}
|
||||
try lines.append(arena, .{ .text = try text.toOwnedSlice(arena), .style = row_style });
|
||||
|
||||
|
|
@ -1492,12 +1556,14 @@ fn appendStatusGrid(
|
|||
|
||||
/// Append one cell's bytes to `text`: right-padded label + space
|
||||
/// + glyph. With ASCII glyphs (always 1 col), the cell ends at
|
||||
/// exactly `status_cell_cols` display columns.
|
||||
/// exactly `status_cell_cols` display columns. A `null` result
|
||||
/// means the check is still running (async dispatch in flight);
|
||||
/// rendered as an hourglass.
|
||||
fn appendStatusCell(
|
||||
arena: std.mem.Allocator,
|
||||
text: *std.ArrayList(u8),
|
||||
label: []const u8,
|
||||
result: observations.CheckResult,
|
||||
result: ?observations.CheckResult,
|
||||
) !void {
|
||||
const lbl_cols = label.len; // ASCII labels: byte count == display cols
|
||||
if (lbl_cols < status_label_cols) {
|
||||
|
|
@ -1505,7 +1571,11 @@ fn appendStatusCell(
|
|||
}
|
||||
try text.appendSlice(arena, label);
|
||||
try text.append(arena, ' ');
|
||||
try text.appendSlice(arena, checkStatusGlyph(result));
|
||||
if (result) |r| {
|
||||
try text.appendSlice(arena, checkStatusGlyph(r));
|
||||
} else {
|
||||
try text.appendSlice(arena, "⏳\u{FE0F}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Append the findings section to `lines`. Layout:
|
||||
|
|
@ -3102,6 +3172,7 @@ test "appendStatusGrid: one row per status_cells_per_row checks" {
|
|||
};
|
||||
const panel: observations.CheckPanel = .{
|
||||
.allocator = testing.allocator,
|
||||
.io = std.testing.io,
|
||||
.pending = &pending,
|
||||
};
|
||||
|
||||
|
|
@ -3128,6 +3199,7 @@ test "appendStatusGrid: empty panel produces no lines" {
|
|||
|
||||
const panel: observations.CheckPanel = .{
|
||||
.allocator = testing.allocator,
|
||||
.io = std.testing.io,
|
||||
.pending = &.{},
|
||||
};
|
||||
var lines: std.ArrayList(StyledLine) = .empty;
|
||||
|
|
|
|||
|
|
@ -102,6 +102,10 @@ pub fn build(
|
|||
.flag => |o| o,
|
||||
.pass, .skipped, .err => continue,
|
||||
},
|
||||
// Still running — no findings to show yet. The caller
|
||||
// rebuilds the view when the panel completes (TUI
|
||||
// polls via tick; CLI awaits before building).
|
||||
.pending => continue,
|
||||
};
|
||||
|
||||
for (obs_slice) |obs| {
|
||||
|
|
@ -208,7 +212,7 @@ fn makePanel(allocator: std.mem.Allocator, obs_slice: []const Observation) !Chec
|
|||
.check = &check_singleton.c,
|
||||
.state = .{ .complete = .{ .warn = owned_obs } },
|
||||
};
|
||||
return .{ .allocator = allocator, .pending = pending };
|
||||
return .{ .allocator = allocator, .io = std.testing.io, .pending = pending };
|
||||
}
|
||||
|
||||
fn makeJournalWithAck(
|
||||
|
|
@ -374,7 +378,7 @@ test "build: pass/skipped/err checks contribute no rows" {
|
|||
.check = &check_singleton.c,
|
||||
.state = .{ .complete = .pass },
|
||||
};
|
||||
var panel = CheckPanel{ .allocator = testing.allocator, .pending = pending };
|
||||
var panel = CheckPanel{ .allocator = testing.allocator, .io = std.testing.io, .pending = pending };
|
||||
defer panel.deinit();
|
||||
|
||||
var journal = try makeEmptyJournal(testing.allocator);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue