From 8860efb371d8b024bd7a2343b848a06654542682 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Thu, 11 Jun 2026 20:41:21 -0700 Subject: [PATCH] delint/get basic async checks working --- .mise.toml | 2 +- .pre-commit-config.yaml | 8 -- src/PortfolioData.zig | 19 ++- src/analytics/observations.zig | 212 +++++++++++++++++++++++++++----- src/atomic.zig | 13 +- src/commands/review.zig | 31 +++-- src/net/RateLimiter.zig | 15 ++- src/padded.zig | 4 +- src/portfolio_loader.zig | 2 +- src/providers/tiingo.zig | 7 +- src/providers/twelvedata.zig | 7 +- src/tui.zig | 44 +++++++ src/tui/review_tab.zig | 90 ++++++++++++-- src/views/observations_view.zig | 8 +- 14 files changed, 388 insertions(+), 74 deletions(-) diff --git a/.mise.toml b/.mise.toml index 01afb8d..e6de4e7 100644 --- a/.mise.toml +++ b/.mise.toml @@ -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" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3414367..08e39ca 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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: diff --git a/src/PortfolioData.zig b/src/PortfolioData.zig index bac6dac..ceaa06e 100644 --- a/src/PortfolioData.zig +++ b/src/PortfolioData.zig @@ -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; diff --git a/src/analytics/observations.zig b/src/analytics/observations.zig index 8a4b6d0..61a3120 100644 --- a/src/analytics/observations.zig +++ b/src/analytics/observations.zig @@ -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| { diff --git a/src/atomic.zig b/src/atomic.zig index f8654ae..985c1f1 100644 --- a/src/atomic.zig +++ b/src/atomic.zig @@ -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; }; } diff --git a/src/commands/review.zig b/src/commands/review.zig index 2d0763a..3978954 100644 --- a/src/commands/review.zig +++ b/src/commands/review.zig @@ -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}); } diff --git a/src/net/RateLimiter.zig b/src/net/RateLimiter.zig index 5cf5161..849a69d 100644 --- a/src/net/RateLimiter.zig +++ b/src/net/RateLimiter.zig @@ -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. diff --git a/src/padded.zig b/src/padded.zig index edfd181..acf3f0b 100644 --- a/src/padded.zig +++ b/src/padded.zig @@ -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; diff --git a/src/portfolio_loader.zig b/src/portfolio_loader.zig index 91d894a..2e66f18 100644 --- a/src/portfolio_loader.zig +++ b/src/portfolio_loader.zig @@ -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, diff --git a/src/providers/tiingo.zig b/src/providers/tiingo.zig index 1a0b18f..b6f51bb 100644 --- a/src/providers/tiingo.zig +++ b/src/providers/tiingo.zig @@ -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); diff --git a/src/providers/twelvedata.zig b/src/providers/twelvedata.zig index 576092b..57f5bc4 100644 --- a/src/providers/twelvedata.zig +++ b/src/providers/twelvedata.zig @@ -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), diff --git a/src/tui.zig b/src/tui.zig index cc65943..7d44891 100644 --- a/src/tui.zig +++ b/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 => {}, } } diff --git a/src/tui/review_tab.zig b/src/tui/review_tab.zig index bda989d..356198e 100644 --- a/src/tui/review_tab.zig +++ b/src/tui/review_tab.zig @@ -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; diff --git a/src/views/observations_view.zig b/src/views/observations_view.zig index 6d47d5b..e8f5220 100644 --- a/src/views/observations_view.zig +++ b/src/views/observations_view.zig @@ -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);