Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 45 additions & 4 deletions src/app/runtime.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<unknown>";
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,
Expand Down Expand Up @@ -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;
Comment thread
forketyfork marked this conversation as resolved.
};
const prev_cwd_ptr = if (session.cwd_path) |p| p.ptr else null;
session.updateCwd(now);
Expand Down Expand Up @@ -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);

Expand Down
39 changes: 39 additions & 0 deletions src/session/state.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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").?);
Expand Down