delint/get basic async checks working

This commit is contained in:
Emil Lerch 2026-06-11 20:41:21 -07:00
parent b3645a7bd1
commit 8860efb371
Signed by: lobo
GPG key ID: A7B62D657EF764F8
14 changed files with 388 additions and 74 deletions

View file

@ -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"

View file

@ -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:

View file

@ -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;

View file

@ -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| {

View file

@ -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;
};
}

View file

@ -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});
}

View file

@ -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.

View file

@ -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;

View file

@ -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,

View file

@ -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);

View file

@ -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),

View file

@ -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 => {},
}
}

View file

@ -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;

View file

@ -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);