diff --git a/src/app/runtime.zig b/src/app/runtime.zig index d707167..e3adb12 100644 --- a/src/app/runtime.zig +++ b/src/app/runtime.zig @@ -144,6 +144,33 @@ fn writeRuntimeEvent(message: []const u8, event_name: []const u8, extra_data: [] }; } +fn agentLabel(session: *const SessionState) []const u8 { + if (session.agent_icon) |kind| return kind.name(); + return "none"; +} + +/// Log a structured `session_failed` event plus a human-readable error line +/// when a session is terminated due to an unrecoverable runtime error. The +/// `source` argument names the failing call site (e.g. `process_output`) and +/// becomes the `source=` field on the event so a future investigation can +/// grep `event=session_failed source=process_output` and find every instance. +fn reportSessionFailure(session: *const SessionState, err: anyerror, source: []const u8) void { + const agent = agentLabel(session); + const cwd = session.cwd_path orelse ""; + log.err( + "session {d}: {s} failed, marking session dead: {} (agent={s} cwd={s})", + .{ session.id, source, err, agent, cwd }, + ); + var extra_buf: [192]u8 = undefined; + const extra = std.fmt.bufPrint(&extra_buf, "session={d} agent={s} error={s} source={s}", .{ + session.id, agent, @errorName(err), source, + }) catch |fmt_err| { + log.warn("failed to format session_failed event payload: {}", .{fmt_err}); + return; + }; + writeRuntimeEvent("session terminated after unrecoverable error", "session_failed", extra); +} + fn emitViewModeTransitionEvents( previous_mode: app_state.ViewMode, next_mode: app_state.ViewMode, @@ -2294,12 +2321,14 @@ pub fn run() !void { } session.checkAlive(); session.processOutput() catch |err| { - log.err("session {d}: process output failed: {}", .{ session.id, err }); - return err; + reportSessionFailure(session, err, "process_output"); + session.failAndTerminate(); + continue; }; session.flushPendingWrites() catch |err| { - log.err("session {d}: flush pending writes failed: {}", .{ session.id, err }); - return err; + reportSessionFailure(session, err, "flush_pending_writes"); + session.failAndTerminate(); + continue; }; const prev_cwd_ptr = if (session.cwd_path) |p| p.ptr else null; session.updateCwd(now); @@ -3139,6 +3168,18 @@ test "planExternalSpawnSlot reports full grid" { try std.testing.expect(planExternalSpawnSlot(&sessions, grid_layout.max_grid_size, grid_layout.max_grid_size, 0) == null); } +test "agentLabel reports the detected agent name or 'none'" { + var session: SessionState = undefined; + session.agent_icon = null; + try std.testing.expectEqualStrings("none", agentLabel(&session)); + + session.agent_icon = .codex; + try std.testing.expectEqualStrings("codex", agentLabel(&session)); + + session.agent_icon = .claude; + try std.testing.expectEqualStrings("claude", agentLabel(&session)); +} + test "validateExternalSpawnCwd accepts directories and rejects relative paths" { try std.testing.expect(validateExternalSpawnCwd("/tmp") == null); diff --git a/src/session/state.zig b/src/session/state.zig index 47b6d77..a1be2d9 100644 --- a/src/session/state.zig +++ b/src/session/state.zig @@ -610,6 +610,24 @@ pub const SessionState = struct { return quit_capture_active; } + /// Mark the session as failed after an unrecoverable output-processing error + /// (e.g. ghostty-vt resource exhaustion like `HyperlinkSetOutOfMemory`). Sends + /// SIGTERM to the still-running child so it stops consuming resources behind a + /// "[Process completed]" UI, drops queued stdin so `flushPendingWrites` does + /// not retry every frame, then flips `dead` and bumps the render epoch. The + /// shell/terminal/stream wrappers stay alive so scrollback remains visible and + /// the session can be restarted through the existing dead-state UI path. + pub fn failAndTerminate(self: *SessionState) void { + if (self.shell) |shell| { + if (self.spawned and !self.dead) { + _ = std.c.kill(shell.child_pid, std.c.SIG.TERM); + } + } + self.pending_write.clearAndFree(self.allocator); + self.dead = true; + self.markDirty(); + } + /// Try to flush any queued stdin data; preserves ordering relative to new input. pub fn flushPendingWrites(self: *SessionState) !void { if (self.pending_write.items.len == 0) return; @@ -1164,6 +1182,27 @@ test "shouldProcessOutput drains dead sessions only during quit capture" { try std.testing.expect(SessionState.shouldProcessOutput(true, true, true)); } +test "failAndTerminate marks dead, bumps render epoch, and drops pending writes" { + const allocator = std.testing.allocator; + + var session: SessionState = undefined; + session.shell = null; + session.spawned = false; + session.dead = false; + session.render_epoch = 4; + session.allocator = allocator; + session.pending_write = .empty; + try session.pending_write.appendSlice(allocator, "queued"); + defer session.pending_write.deinit(allocator); + + session.failAndTerminate(); + + try std.testing.expect(session.dead); + try std.testing.expectEqual(@as(u64, 5), session.render_epoch); + try std.testing.expectEqual(@as(usize, 0), session.pending_write.items.len); + try std.testing.expectEqual(@as(usize, 0), session.pending_write.capacity); +} + test "AgentKind.fromComm recognises known agent names" { try std.testing.expectEqual(AgentKind.claude, AgentKind.fromComm("claude").?); try std.testing.expectEqual(AgentKind.codex, AgentKind.fromComm("codex").?);