diff --git a/src/cli/install.zig b/src/cli/install.zig index a64d06c..55f7a13 100644 --- a/src/cli/install.zig +++ b/src/cli/install.zig @@ -530,6 +530,9 @@ fn executeWithOpts( // Set up multi-progress: assign line indices and create coordinator const download_index = assignDownloadLineIndices(all_jobs.items); var multi = progress_mod.MultiProgress.init(download_index); + // Anchor the DECSET pair to scope exit so an early return between + // here and the worker join doesn't leave the terminal in DECRESET. + defer multi.finish(); // Main-thread bar allocation — draw an initial frame on every line // before workers spawn, and keep pointers stable for them. @@ -576,7 +579,6 @@ fn executeWithOpts( } for (threads.items) |t| t.join(); - multi.finish(); } // Emit from the main thread so ndjson order is deterministic diff --git a/src/main.zig b/src/main.zig index fa8a741..836cac1 100644 --- a/src/main.zig +++ b/src/main.zig @@ -3,13 +3,27 @@ const std = @import("std"); const color_mod = @import("ui/color.zig"); +const progress_mod = @import("ui/progress.zig"); const AppCtx = @import("app_ctx.zig").AppCtx; -// Release uses simple_panic so debug.Dwarf stays unreachable (~30 KB smaller). -pub const panic = if (@import("builtin").mode == .Debug) - std.debug.FullPanic(std.debug.defaultPanic) -else - std.debug.simple_panic; +// Wrap the panic path so the cursor + autowrap state owned by +// MultiProgress / Spinner is restored before abort. Defers don't run +// on panic, so this is the only way to leave the terminal usable when +// install/migrate trips a safety check. +pub const panic = std.debug.FullPanic(maltPanic); + +fn maltPanic(msg: []const u8, first_trace_addr: ?usize) noreturn { + progress_mod.restoreTerminal(); + if (@import("builtin").mode == .Debug) { + std.debug.defaultPanic(msg, first_trace_addr); + } else { + // Release mirror of std.debug.simple_panic.call: emit the + // message and trap, keeping debug.Dwarf out of the binary. + const stderr_writer = &std.debug.lockStderr(&.{}).file_writer.interface; + stderr_writer.writeAll(msg) catch {}; + @trap(); + } +} // Gate `.debug` on the runtime --debug flag so release builds still // surface std.log.debug diagnostics in bug reports. @@ -343,7 +357,6 @@ pub fn main(init: std.process.Init.Minimal) !void { // Seed the ui package state once so output/progress/color stop // pulling io/environ/stdio from module-level globals. const output_mod = @import("ui/output.zig"); - const progress_mod = @import("ui/progress.zig"); output_mod.setRuntime(ctx.io, ctx.environ, ctx.stdout, ctx.stderr); progress_mod.setRuntime(ctx.io, ctx.stderr); color_mod.setRuntime(ctx.io, ctx.environ); diff --git a/src/ui/progress.zig b/src/ui/progress.zig index 6b18321..b323ba8 100644 --- a/src/ui/progress.zig +++ b/src/ui/progress.zig @@ -46,6 +46,14 @@ fn sleepNs(ns: u64) void { /// Braille-based spinner frames, shared by ProgressBar and Spinner. const spinner_chars = [_][]const u8{ "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏" }; +/// Best-effort restore of terminal state mutated by `MultiProgress.init` +/// or `Spinner.start`: re-enable autowrap, show the cursor, return to +/// column 0. Safe to call from a panic / signal handler — the bytes are +/// idempotent, and writes silently drop when stderr is unconfigured. +pub fn restoreTerminal() void { + writeStderrAll("\x1b[?7h\x1b[?25h\r"); +} + /// Coordinates multiple progress bars on separate terminal lines. /// Reserves N lines upfront, then uses ANSI cursor movement so each /// bar updates its own line without interfering with others. @@ -84,8 +92,7 @@ pub const MultiProgress = struct { /// Must be called after all download threads have joined. pub fn finish(self: *MultiProgress) void { if (self.is_tty and !output.isQuiet()) { - // Re-enable autowrap, show cursor, return to col 0. - writeStderrAll("\x1b[?7h\x1b[?25h\r"); + restoreTerminal(); } } }; @@ -657,3 +664,11 @@ test "ProgressBar render survives label larger than the draw buffer" { indet.is_tty = true; indet.update(0); } + +test "restoreTerminal is callable without an active MultiProgress" { + // Panic / signal handlers may emit the restore sequence without ever + // having paired it to an init: the call must be allocation-free, + // re-entrant, and idempotent. + restoreTerminal(); + restoreTerminal(); +}