Merge pull request #2173 from ghostty-org/crashlog

Initial Crash Logging
This commit is contained in:
Mitchell Hashimoto
2024-09-02 10:35:27 -07:00
committed by GitHub
30 changed files with 6842 additions and 6 deletions

View File

@@ -22,6 +22,7 @@ const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const global_state = &@import("global.zig").state;
const oni = @import("oniguruma");
const crash = @import("crash/main.zig");
const unicode = @import("unicode/main.zig");
const renderer = @import("renderer.zig");
const termio = @import("termio.zig");
@@ -1218,6 +1219,10 @@ fn queueRender(self: *Surface) !void {
}
pub fn sizeCallback(self: *Surface, size: apprt.SurfaceSize) !void {
// Crash metadata in case we crash in here
crash.sentry.thread_state = self.crashThreadState();
defer crash.sentry.thread_state = null;
const new_screen_size: renderer.ScreenSize = .{
.width = size.width,
.height = size.height,
@@ -1274,6 +1279,10 @@ fn resize(self: *Surface, size: renderer.ScreenSize) !void {
pub fn preeditCallback(self: *Surface, preedit_: ?[]const u8) !void {
// log.debug("text preeditCallback value={any}", .{preedit_});
// Crash metadata in case we crash in here
crash.sentry.thread_state = self.crashThreadState();
defer crash.sentry.thread_state = null;
self.renderer_state.mutex.lock();
defer self.renderer_state.mutex.unlock();
@@ -1335,6 +1344,10 @@ pub fn keyCallback(
) !InputEffect {
// log.debug("text keyCallback event={}", .{event});
// Crash metadata in case we crash in here
crash.sentry.thread_state = self.crashThreadState();
defer crash.sentry.thread_state = null;
// Setup our inspector event if we have an inspector.
var insp_ev: ?inspector.key.Event = if (self.inspector != null) ev: {
var copy = event;
@@ -1729,6 +1742,10 @@ fn encodeKey(
/// if bracketed mode is on this will do a bracketed paste. Otherwise,
/// this will filter newlines to '\r'.
pub fn textCallback(self: *Surface, text: []const u8) !void {
// Crash metadata in case we crash in here
crash.sentry.thread_state = self.crashThreadState();
defer crash.sentry.thread_state = null;
try self.completeClipboardPaste(text, true);
}
@@ -1736,6 +1753,10 @@ pub fn textCallback(self: *Surface, text: []const u8) !void {
/// of focus state. This is used to pause rendering when the surface
/// is not visible, and also re-render when it becomes visible again.
pub fn occlusionCallback(self: *Surface, visible: bool) !void {
// Crash metadata in case we crash in here
crash.sentry.thread_state = self.crashThreadState();
defer crash.sentry.thread_state = null;
_ = self.renderer_thread.mailbox.push(.{
.visible = visible,
}, .{ .forever = {} });
@@ -1743,6 +1764,10 @@ pub fn occlusionCallback(self: *Surface, visible: bool) !void {
}
pub fn focusCallback(self: *Surface, focused: bool) !void {
// Crash metadata in case we crash in here
crash.sentry.thread_state = self.crashThreadState();
defer crash.sentry.thread_state = null;
// Notify our render thread of the new state
_ = self.renderer_thread.mailbox.push(.{
.focus = focused,
@@ -1813,6 +1838,10 @@ pub fn focusCallback(self: *Surface, focused: bool) !void {
}
pub fn refreshCallback(self: *Surface) !void {
// Crash metadata in case we crash in here
crash.sentry.thread_state = self.crashThreadState();
defer crash.sentry.thread_state = null;
// The point of this callback is to schedule a render, so do that.
try self.queueRender();
}
@@ -1825,6 +1854,10 @@ pub fn scrollCallback(
) !void {
// log.info("SCROLL: xoff={} yoff={} mods={}", .{ xoff, yoff, scroll_mods });
// Crash metadata in case we crash in here
crash.sentry.thread_state = self.crashThreadState();
defer crash.sentry.thread_state = null;
// Always show the mouse again if it is hidden
if (self.mouse.hidden) self.showMouse();
@@ -1980,6 +2013,10 @@ pub fn scrollCallback(
/// This is called when the content scale of the surface changes. The surface
/// can then update any DPI-sensitive state.
pub fn contentScaleCallback(self: *Surface, content_scale: apprt.ContentScale) !void {
// Crash metadata in case we crash in here
crash.sentry.thread_state = self.crashThreadState();
defer crash.sentry.thread_state = null;
// Calculate the new DPI
const x_dpi = content_scale.x * font.face.default_dpi;
const y_dpi = content_scale.y * font.face.default_dpi;
@@ -2293,6 +2330,10 @@ pub fn mouseButtonCallback(
button: input.MouseButton,
mods: input.Mods,
) !bool {
// Crash metadata in case we crash in here
crash.sentry.thread_state = self.crashThreadState();
defer crash.sentry.thread_state = null;
// log.debug("mouse action={} button={} mods={}", .{ action, button, mods });
// If we have an inspector, we always queue a render
@@ -2790,6 +2831,10 @@ pub fn mousePressureCallback(
stage: input.MousePressureStage,
pressure: f64,
) !void {
// Crash metadata in case we crash in here
crash.sentry.thread_state = self.crashThreadState();
defer crash.sentry.thread_state = null;
// We don't currently use the pressure value for anything. In the
// future, we could report this to applications using new mouse
// events or utilize it for some custom UI.
@@ -2824,6 +2869,10 @@ pub fn cursorPosCallback(
self: *Surface,
pos: apprt.CursorPos,
) !void {
// Crash metadata in case we crash in here
crash.sentry.thread_state = self.crashThreadState();
defer crash.sentry.thread_state = null;
// Always show the mouse again if it is hidden
if (self.mouse.hidden) self.showMouse();
@@ -3229,6 +3278,10 @@ fn dragLeftClickBefore(
/// Call to notify Ghostty that the color scheme for the terminal has
/// changed.
pub fn colorSchemeCallback(self: *Surface, scheme: apprt.ColorScheme) !void {
// Crash metadata in case we crash in here
crash.sentry.thread_state = self.crashThreadState();
defer crash.sentry.thread_state = null;
// If our scheme didn't change, then we don't do anything.
if (self.color_scheme == scheme) return;
@@ -3673,6 +3726,20 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
.quit => try self.app.setQuit(),
.crash => |location| switch (location) {
.main => @panic("crash binding action, crashing intentionally"),
.render => {
_ = self.renderer_thread.mailbox.push(.{ .crash = {} }, .{ .forever = {} });
self.queueRender() catch |err| {
// Not a big deal if this fails.
log.warn("failed to notify renderer of crash message err={}", .{err});
};
},
.io => self.io.queueMessage(.{ .crash = {} }, .unlocked),
},
.adjust_selection => |direction| {
self.renderer_state.mutex.lock();
defer self.renderer_state.mutex.unlock();
@@ -4083,6 +4150,13 @@ fn showDesktopNotification(self: *Surface, title: [:0]const u8, body: [:0]const
try self.rt_surface.showDesktopNotification(title, body);
}
fn crashThreadState(self: *Surface) crash.sentry.ThreadState {
return .{
.type = .main,
.surface = self,
};
}
pub const face_ttf = @embedFile("font/res/JetBrainsMono-Regular.ttf");
pub const face_bold_ttf = @embedFile("font/res/JetBrainsMono-Bold.ttf");
pub const face_emoji_ttf = @embedFile("font/res/NotoColorEmoji.ttf");

16
src/crash/main.zig Normal file
View File

@@ -0,0 +1,16 @@
//! The crash package contains all the logic around crash handling,
//! whether that's setting up the system to catch crashes (Sentry client),
//! introspecting crash reports, writing crash reports to disk, etc.
const sentry_envelope = @import("sentry_envelope.zig");
pub const sentry = @import("sentry.zig");
pub const Envelope = sentry_envelope.Envelope;
// The main init/deinit functions for global state.
pub const init = sentry.init;
pub const deinit = sentry.deinit;
test {
@import("std").testing.refAllDecls(@This());
}

279
src/crash/sentry.zig Normal file
View File

@@ -0,0 +1,279 @@
const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const builtin = @import("builtin");
const build_config = @import("../build_config.zig");
const sentry = @import("sentry");
const internal_os = @import("../os/main.zig");
const crash = @import("main.zig");
const state = &@import("../global.zig").state;
const Surface = @import("../Surface.zig");
const log = std.log.scoped(.sentry);
/// The global state for the Sentry SDK. This is unavoidable since crash
/// handling is a global process-wide thing.
var init_thread: ?std.Thread = null;
/// Thread-local state that can be set by thread main functions so that
/// crashes have more context.
///
/// This is a hack over Sentry native SDK limitations. The native SDK has
/// one global scope for all threads and no support for thread-local scopes.
/// This means that if we want to set thread-specific data we have to do it
/// on our own in the on crash callback.
pub const ThreadState = struct {
/// Thread type, used to tag the crash
type: Type,
/// The surface that this thread is attached to.
surface: *Surface,
pub const Type = enum { main, renderer, io };
};
/// See ThreadState. This should only ever be set by the owner of the
/// thread entry function.
pub threadlocal var thread_state: ?ThreadState = null;
/// Process-wide initialization of our Sentry client.
///
/// This should only be called from one thread, and deinit should be called
/// from the same thread that calls init to avoid data races.
///
/// PRIVACY NOTE: I want to make it very clear that Ghostty by default does
/// NOT send any data over the network. We use the Sentry native SDK to collect
/// crash reports and logs, but we only store them locally (see Transport).
/// It is up to the user to grab the logs and manually send them to us
/// (or they own Sentry instance) if they want to.
pub fn init(gpa: Allocator) !void {
// Not supported on Windows currently, doesn't build.
if (comptime builtin.os.tag == .windows) return;
// const start = try std.time.Instant.now();
// const start_micro = std.time.microTimestamp();
// defer {
// const end = std.time.Instant.now() catch unreachable;
// // "[updateFrame critical time] <START us>\t<TIME_TAKEN us>"
// std.log.err("[sentry init time] start={}us duration={}ns", .{ start_micro, end.since(start) / std.time.ns_per_us });
// }
// Must only start once
assert(init_thread == null);
// We use a thread for initializing Sentry because initialization takes
// ~2k ns on my M3 Max. That's not a LOT of time but it's enough to be
// 90% of our pre-App startup time. Everything Sentry is doing initially
// is safe to do on a separate thread and fast enough that its very
// likely to be done before a crash occurs.
const thr = try std.Thread.spawn(
.{},
initThread,
.{gpa},
);
thr.setName("sentry-init") catch {};
init_thread = thr;
}
fn initThread(gpa: Allocator) !void {
var arena = std.heap.ArenaAllocator.init(gpa);
defer arena.deinit();
const alloc = arena.allocator();
const transport = sentry.Transport.init(&Transport.send);
errdefer transport.deinit();
const opts = sentry.c.sentry_options_new();
errdefer sentry.c.sentry_options_free(opts);
sentry.c.sentry_options_set_release_n(
opts,
build_config.version_string.ptr,
build_config.version_string.len,
);
sentry.c.sentry_options_set_transport(opts, @ptrCast(transport));
// Set our crash callback. See beforeSend for more details on what we
// do here and why we use this.
sentry.c.sentry_options_set_before_send(opts, beforeSend, null);
// Determine the Sentry cache directory.
const cache_dir = try internal_os.xdg.cache(alloc, .{ .subdir = "ghostty/sentry" });
sentry.c.sentry_options_set_database_path_n(
opts,
cache_dir.ptr,
cache_dir.len,
);
if (comptime builtin.mode == .Debug) {
// Debug logging for Sentry
sentry.c.sentry_options_set_debug(opts, @intFromBool(true));
}
// Initialize
if (sentry.c.sentry_init(opts) != 0) return error.SentryInitFailed;
// Setup some basic tags that we always want present
sentry.setTag("build-mode", build_config.mode_string);
sentry.setTag("app-runtime", @tagName(build_config.app_runtime));
sentry.setTag("font-backend", @tagName(build_config.font_backend));
sentry.setTag("renderer", @tagName(build_config.renderer));
// Log some information about sentry
log.debug("sentry initialized database={s}", .{cache_dir});
}
/// Process-wide deinitialization of our Sentry client. This ensures all
/// our data is flushed.
pub fn deinit() void {
if (comptime builtin.os.tag == .windows) return;
// If we're still initializing then wait for init to finish. This
// is highly unlikely since init is a very fast operation but we want
// to avoid the possibility.
const thr = init_thread orelse return;
thr.join();
_ = sentry.c.sentry_close();
}
fn beforeSend(
event_val: sentry.c.sentry_value_t,
_: ?*anyopaque,
_: ?*anyopaque,
) callconv(.C) sentry.c.sentry_value_t {
// The native SDK at the time of writing doesn't support thread-local
// scopes. The full SDK has one global scope. So we use the beforeSend
// handler to set thread-specific data such as window size, grid size,
// etc. that we can use to debug crashes.
// If we don't have thread state we can't reliably determine
// metadata such as surface dimensions. In the future we can probably
// drop full app state (all surfaces, all windows, etc.).
const thr_state = thread_state orelse {
log.debug("no thread state, skipping crash metadata", .{});
return event_val;
};
// Get our event contexts. At this point Sentry has already merged
// all the contexts so we should have this key. If not, we create it.
const event: sentry.Value = .{ .value = event_val };
const contexts = event.get("contexts") orelse contexts: {
const obj = sentry.Value.initObject();
event.set("contexts", obj);
break :contexts obj;
};
const tags = event.get("tags") orelse tags: {
const obj = sentry.Value.initObject();
event.set("tags", obj);
break :tags obj;
};
// Store our thread type
tags.set("thread-type", sentry.Value.initString(@tagName(thr_state.type)));
// Read the surface data. This is likely unsafe because on a crash
// other threads can continue running. We don't have race-safe way to
// access this data so this might be corrupted but it's most likely fine.
{
const obj = sentry.Value.initObject();
errdefer obj.decref();
const surface = thr_state.surface;
obj.set(
"screen-width",
sentry.Value.initInt32(std.math.cast(i32, surface.screen_size.width) orelse -1),
);
obj.set(
"screen-height",
sentry.Value.initInt32(std.math.cast(i32, surface.screen_size.height) orelse -1),
);
obj.set(
"grid-columns",
sentry.Value.initInt32(std.math.cast(i32, surface.grid_size.columns) orelse -1),
);
obj.set(
"grid-rows",
sentry.Value.initInt32(std.math.cast(i32, surface.grid_size.rows) orelse -1),
);
obj.set(
"cell-width",
sentry.Value.initInt32(std.math.cast(i32, surface.cell_size.width) orelse -1),
);
obj.set(
"cell-height",
sentry.Value.initInt32(std.math.cast(i32, surface.cell_size.height) orelse -1),
);
contexts.set("Dimensions", obj);
}
return event_val;
}
pub const Transport = struct {
pub fn send(envelope: *sentry.Envelope, ud: ?*anyopaque) callconv(.C) void {
_ = ud;
defer envelope.deinit();
// Call our internal impl. If it fails there is nothing we can do
// but log to the user.
sendInternal(envelope) catch |err| {
log.warn("failed to persist crash report err={}", .{err});
};
}
/// Implementation of send but we can use Zig errors.
fn sendInternal(envelope: *sentry.Envelope) !void {
var arena = std.heap.ArenaAllocator.init(state.alloc);
defer arena.deinit();
const alloc = arena.allocator();
// Parse into an envelope structure
const json = envelope.serialize();
defer sentry.free(@ptrCast(json.ptr));
var parsed: crash.Envelope = parsed: {
var fbs = std.io.fixedBufferStream(json);
break :parsed try crash.Envelope.parse(alloc, fbs.reader());
};
defer parsed.deinit();
// If our envelope doesn't have an event then we don't do anything.
// To figure this out we first encode it into a string, parse it,
// and check if it has an event. Kind of wasteful but the best
// option we have at the time of writing this since the C API doesn't
// expose this information.
if (try shouldDiscard(&parsed)) {
log.info("sentry envelope does not contain crash, discarding", .{});
return;
}
// Generate a UUID for this envelope. The envelope DOES have an event_id
// header but I don't think there is any public API way to get it
// afaict so we generate a new UUID for the filename just so we don't
// conflict.
const uuid = sentry.UUID.init();
// Get our XDG state directory where we'll store the crash reports.
// This directory must exist for writing to work.
const crash_dir = try internal_os.xdg.state(alloc, .{ .subdir = "ghostty/crash" });
try std.fs.cwd().makePath(crash_dir);
// Build our final path and write to it.
const path = try std.fs.path.join(alloc, &.{
crash_dir,
try std.fmt.allocPrint(alloc, "{s}.ghosttycrash", .{uuid.string()}),
});
const file = try std.fs.cwd().createFile(path, .{});
defer file.close();
try file.writer().writeAll(json);
log.warn("crash report written to disk path={s}", .{path});
}
fn shouldDiscard(envelope: *const crash.Envelope) !bool {
// If we have an event item then we're good.
for (envelope.items) |item| {
if (item.type == .event) return false;
}
return true;
}
};

View File

@@ -0,0 +1,284 @@
const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
/// The Sentry Envelope format: https://develop.sentry.dev/sdk/envelopes/
///
/// The envelope is our primary crash report format since use the Sentry
/// client. It is designed and created by Sentry but is an open format
/// in that it is publicly documented and can be used by any system. This
/// lets us utilize the Sentry client for crash capture but also gives us
/// the opportunity to migrate to another system if we need to, and doesn't
/// force any user or developer to use Sentry the SaaS if they don't want
/// to.
///
/// This struct implements reading the envelope format (writing is not needed
/// currently but can be added later). It is incomplete; I only implemented
/// what I needed at the time.
pub const Envelope = struct {
// Developer note: this struct is really geared towards decoding an
// already-encoded envelope vs. building up an envelope from rich
// data types. I think it can be used for both I just didn't have
// the latter need.
//
// If I were to make that ability more enjoyable I'd probably change
// Item below a tagged union of either an "EncodedItem" (which is the
// current Item type) or a "DecodedItem" which is a union(ItemType)
// to its rich data type. This would allow the user to cheaply append
// items to the envelope without paying the encoding cost until
// serialization time.
//
// The way it is now, the user has to encode every entry as they build
// the envelope, which is probably fine but I wanted to write this down
// for my future self or some future contributor since it is fresh
// in my mind. Cheers.
/// The arena that the envelope is allocated in.
arena: std.heap.ArenaAllocator,
/// The headers of the envelope decoded into a json ObjectMap.
headers: std.json.ObjectMap,
/// The items in the envelope in the order they're encoded.
items: []const Item,
/// An encoded item. It is "encoded" in the sense that the payload
/// is a byte slice. The headers are "decoded" into a json ObjectMap
/// but that's still a pretty low-level representation.
pub const Item = struct {
headers: std.json.ObjectMap,
type: ItemType,
payload: []const u8,
};
/// Parse an envelope from a reader.
///
/// The full envelope must fit in memory for this to succeed. This
/// will always copy the data from the reader into memory, even if the
/// reader is already in-memory (i.e. a FixedBufferStream). This
/// simplifies memory lifetimes at the expense of a copy, but envelope
/// parsing in our use case is not a hot path.
pub fn parse(
alloc_gpa: Allocator,
reader: anytype,
) !Envelope {
// We use an arena allocator to read from reader. We pair this
// with `alloc_if_needed` when parsing json to allow the json
// to reference the arena-allocated memory if it can. That way both
// our temp and perm memory is part of the same arena. This slightly
// bloats our memory requirements but reduces allocations.
var arena = std.heap.ArenaAllocator.init(alloc_gpa);
errdefer arena.deinit();
const alloc = arena.allocator();
// Parse our elements. We do this outside of the struct assignment
// below to avoid the issue where order matters in struct assignment.
const headers = try parseHeader(alloc, reader);
const items = try parseItems(alloc, reader);
return .{
.headers = headers,
.items = items,
.arena = arena,
};
}
fn parseHeader(
alloc: Allocator,
reader: anytype,
) !std.json.ObjectMap {
var buf: std.ArrayListUnmanaged(u8) = .{};
reader.streamUntilDelimiter(
buf.writer(alloc),
'\n',
1024 * 1024, // 1MB, arbitrary choice
) catch |err| switch (err) {
// Envelope can be header-only.
error.EndOfStream => {},
else => |v| return v,
};
const value = try std.json.parseFromSliceLeaky(
std.json.Value,
alloc,
buf.items,
.{ .allocate = .alloc_if_needed },
);
return switch (value) {
.object => |map| map,
else => error.EnvelopeMalformedHeaders,
};
}
fn parseItems(
alloc: Allocator,
reader: anytype,
) ![]const Item {
var items = std.ArrayList(Item).init(alloc);
defer items.deinit();
while (try parseOneItem(alloc, reader)) |item| try items.append(item);
return try items.toOwnedSlice();
}
fn parseOneItem(
alloc: Allocator,
reader: anytype,
) !?Item {
// Get the next item which must start with a header.
var buf: std.ArrayListUnmanaged(u8) = .{};
reader.streamUntilDelimiter(
buf.writer(alloc),
'\n',
1024 * 1024, // 1MB, arbitrary choice
) catch |err| switch (err) {
error.EndOfStream => return null,
else => |v| return v,
};
// Parse the header JSON
const headers: std.json.ObjectMap = headers: {
const line = std.mem.trim(u8, buf.items, " \t");
if (line.len == 0) return null;
const value = try std.json.parseFromSliceLeaky(
std.json.Value,
alloc,
line,
.{ .allocate = .alloc_if_needed },
);
break :headers switch (value) {
.object => |map| map,
else => return error.EnvelopeItemMalformedHeaders,
};
};
// Get the event type
const typ: ItemType = if (headers.get("type")) |v| switch (v) {
.string => |str| std.meta.stringToEnum(
ItemType,
str,
) orelse .unknown,
else => return error.EnvelopeItemTypeMissing,
} else return error.EnvelopeItemTypeMissing;
// Get the payload length. The length is not required. If the length
// is not specified then it is the next line ending in `\n`.
const len_: ?u64 = if (headers.get("length")) |v| switch (v) {
.integer => |int| std.math.cast(
u64,
int,
) orelse return error.EnvelopeItemLengthMalformed,
else => return error.EnvelopeItemLengthMalformed,
} else null;
// Get the payload
const payload: []const u8 = if (len_) |len| payload: {
// The payload length is specified so read the exact length.
var payload = std.ArrayList(u8).init(alloc);
defer payload.deinit();
for (0..len) |_| {
const byte = reader.readByte() catch |err| switch (err) {
error.EndOfStream => return error.EnvelopeItemPayloadTooShort,
else => return err,
};
try payload.append(byte);
}
break :payload try payload.toOwnedSlice();
} else payload: {
// The payload is the next line ending in `\n`. It is required.
var payload = std.ArrayList(u8).init(alloc);
defer payload.deinit();
reader.streamUntilDelimiter(
payload.writer(),
'\n',
1024 * 1024 * 50, // 50MB, arbitrary choice
) catch |err| switch (err) {
error.EndOfStream => return error.EnvelopeItemPayloadTooShort,
else => |v| return v,
};
break :payload try payload.toOwnedSlice();
};
return .{
.headers = headers,
.type = typ,
.payload = payload,
};
}
pub fn deinit(self: *Envelope) void {
self.arena.deinit();
}
};
/// The various item types that can be in an envelope. This is a point
/// in time snapshot of the types that are known whenever this is edited.
/// Event types can be introduced at any time and unknown types will
/// take the "unknown" enum value.
///
/// https://develop.sentry.dev/sdk/envelopes/#data-model
pub const ItemType = enum {
/// Special event type for when the item type is unknown.
unknown,
/// Documented event types
event,
transaction,
attachment,
session,
sessions,
statsd,
metric_meta,
user_feedback,
client_report,
replay_event,
replay_recording,
profile,
check_in,
};
test "Envelope parse" {
const testing = std.testing;
const alloc = testing.allocator;
var fbs = std.io.fixedBufferStream(
\\{}
);
var v = try Envelope.parse(alloc, fbs.reader());
defer v.deinit();
}
test "Envelope parse session" {
const testing = std.testing;
const alloc = testing.allocator;
var fbs = std.io.fixedBufferStream(
\\{}
\\{"type":"session","length":218}
\\{"init":true,"sid":"c148cc2f-5f9f-4231-575c-2e85504d6434","status":"abnormal","errors":0,"started":"2024-08-29T02:38:57.607016Z","duration":0.000343,"attrs":{"release":"0.1.0-HEAD+d37b7d09","environment":"production"}}
);
var v = try Envelope.parse(alloc, fbs.reader());
defer v.deinit();
try testing.expectEqual(@as(usize, 1), v.items.len);
try testing.expectEqual(ItemType.session, v.items[0].type);
}
test "Envelope parse end in new line" {
const testing = std.testing;
const alloc = testing.allocator;
var fbs = std.io.fixedBufferStream(
\\{}
\\{"type":"session","length":218}
\\{"init":true,"sid":"c148cc2f-5f9f-4231-575c-2e85504d6434","status":"abnormal","errors":0,"started":"2024-08-29T02:38:57.607016Z","duration":0.000343,"attrs":{"release":"0.1.0-HEAD+d37b7d09","environment":"production"}}
\\
);
var v = try Envelope.parse(alloc, fbs.reader());
defer v.deinit();
try testing.expectEqual(@as(usize, 1), v.items.len);
try testing.expectEqual(ItemType.session, v.items[0].type);
}

View File

@@ -7,6 +7,7 @@ const fontconfig = @import("fontconfig");
const glslang = @import("glslang");
const harfbuzz = @import("harfbuzz");
const oni = @import("oniguruma");
const crash = @import("crash/main.zig");
const renderer = @import("renderer.zig");
const xev = @import("xev");
@@ -39,6 +40,14 @@ pub const GlobalState = struct {
/// Initialize the global state.
pub fn init(self: *GlobalState) !void {
// const start = try std.time.Instant.now();
// const start_micro = std.time.microTimestamp();
// defer {
// const end = std.time.Instant.now() catch unreachable;
// // "[updateFrame critical time] <START us>\t<TIME_TAKEN us>"
// std.log.err("[global init time] start={}us duration={}ns", .{ start_micro, end.since(start) / std.time.ns_per_us });
// }
// Initialize ourself to nothing so we don't have any extra state.
// IMPORTANT: this MUST be initialized before any log output because
// the log function uses the global state.
@@ -117,6 +126,18 @@ pub const GlobalState = struct {
// First things first, we fix our file descriptors
internal_os.fixMaxFiles();
// Initialize our crash reporting.
try crash.init(self.alloc);
// const sentrylib = @import("sentry");
// if (sentrylib.captureEvent(sentrylib.Value.initMessageEvent(
// .info,
// null,
// "hello, world",
// ))) |uuid| {
// std.log.warn("uuid={s}", .{uuid.string()});
// } else std.log.warn("failed to capture event", .{});
// We need to make sure the process locale is set properly. Locale
// affects a lot of behaviors in a shell.
try internal_os.ensureLocale(self.alloc);
@@ -138,6 +159,9 @@ pub const GlobalState = struct {
pub fn deinit(self: *GlobalState) void {
if (self.resources_dir) |dir| self.alloc.free(dir);
// Flush our crash logs
crash.deinit();
if (self.gpa) |*value| {
// We want to ensure that we deinit the GPA because this is
// the point at which it will output if there were safety violations.

View File

@@ -300,6 +300,28 @@ pub const Action = union(enum) {
/// Quit ghostty.
quit: void,
/// Crash ghostty in the desired thread for the focused surface.
///
/// WARNING: This is a hard crash (panic) and data can be lost.
///
/// The purpose of this action is to test crash handling. For some
/// users, it may be useful to test crash reporting functionality in
/// order to determine if it all works as expected.
///
/// The value determines the crash location:
///
/// - "main" - crash on the main (GUI) thread.
/// - "io" - crash on the IO thread for the focused surface.
/// - "render" - crash on the render thread for the focused surface.
///
crash: CrashThread,
pub const CrashThread = enum {
main,
io,
render,
};
pub const CursorKey = struct {
normal: []const u8,
application: []const u8,

View File

@@ -181,6 +181,7 @@ test {
// Libraries
_ = @import("segmented_pool.zig");
_ = @import("crash/main.zig");
_ = @import("inspector/main.zig");
_ = @import("terminal/main.zig");
_ = @import("terminfo/main.zig");

View File

@@ -21,17 +21,54 @@ pub const Options = struct {
/// Get the XDG user config directory. The returned value is allocated.
pub fn config(alloc: Allocator, opts: Options) ![]u8 {
return try dir(alloc, opts, .{
.env = "XDG_CONFIG_HOME",
.windows_env = "LOCALAPPDATA",
.default_subdir = ".config",
});
}
/// Get the XDG cache directory. The returned value is allocated.
pub fn cache(alloc: Allocator, opts: Options) ![]u8 {
return try dir(alloc, opts, .{
.env = "XDG_CACHE_HOME",
.windows_env = "LOCALAPPDATA",
.default_subdir = ".cache",
});
}
/// Get the XDG state directory. The returned value is allocated.
pub fn state(alloc: Allocator, opts: Options) ![]u8 {
return try dir(alloc, opts, .{
.env = "XDG_STATE_HOME",
.windows_env = "LOCALAPPDATA",
.default_subdir = ".local/state",
});
}
const InternalOptions = struct {
env: []const u8,
windows_env: []const u8,
default_subdir: []const u8,
};
/// Unified helper to get XDG directories that follow a common pattern.
fn dir(
alloc: Allocator,
opts: Options,
internal_opts: InternalOptions,
) ![]u8 {
// First check the env var. On Windows we have to allocate so this tracks
// both whether we have the env var and whether we own it.
// on Windows we treat `LOCALAPPDATA` as a fallback for `XDG_CONFIG_HOME`
const env_, const owned = switch (builtin.os.tag) {
else => .{ posix.getenv("XDG_CONFIG_HOME"), false },
else => .{ posix.getenv(internal_opts.env), false },
.windows => windows: {
if (std.process.getEnvVarOwned(alloc, "XDG_CONFIG_HOME")) |env| {
if (std.process.getEnvVarOwned(alloc, internal_opts.env)) |env| {
break :windows .{ env, true };
} else |err| switch (err) {
error.EnvironmentVariableNotFound => {
if (std.process.getEnvVarOwned(alloc, "LOCALAPPDATA")) |env| {
if (std.process.getEnvVarOwned(alloc, internal_opts.windows_env)) |env| {
break :windows .{ env, true };
} else |err2| switch (err2) {
error.EnvironmentVariableNotFound => break :windows .{ null, false },
@@ -60,7 +97,7 @@ pub fn config(alloc: Allocator, opts: Options) ![]u8 {
if (opts.home) |home| {
return try std.fs.path.join(alloc, &[_][]const u8{
home,
".config",
internal_opts.default_subdir,
opts.subdir orelse "",
});
}
@@ -70,7 +107,7 @@ pub fn config(alloc: Allocator, opts: Options) ![]u8 {
if (try homedir.home(&buf)) |home| {
return try std.fs.path.join(alloc, &[_][]const u8{
home,
".config",
internal_opts.default_subdir,
opts.subdir orelse "",
});
}

View File

@@ -137,6 +137,25 @@ const PosixPty = struct {
/// This should be called prior to exec in the forked child process
/// in order to setup the tty properly.
pub fn childPreExec(self: Pty) !void {
// Reset our signals
var sa: posix.Sigaction = .{
.handler = .{ .handler = posix.SIG.DFL },
.mask = posix.empty_sigset,
.flags = 0,
};
try posix.sigaction(posix.SIG.ABRT, &sa, null);
try posix.sigaction(posix.SIG.ALRM, &sa, null);
try posix.sigaction(posix.SIG.BUS, &sa, null);
try posix.sigaction(posix.SIG.CHLD, &sa, null);
try posix.sigaction(posix.SIG.FPE, &sa, null);
try posix.sigaction(posix.SIG.HUP, &sa, null);
try posix.sigaction(posix.SIG.ILL, &sa, null);
try posix.sigaction(posix.SIG.INT, &sa, null);
try posix.sigaction(posix.SIG.SEGV, &sa, null);
try posix.sigaction(posix.SIG.TRAP, &sa, null);
try posix.sigaction(posix.SIG.TERM, &sa, null);
try posix.sigaction(posix.SIG.QUIT, &sa, null);
// Create a new process group
if (setsid() < 0) return error.ProcessGroupFailed;

View File

@@ -5,6 +5,7 @@ pub const Thread = @This();
const std = @import("std");
const builtin = @import("builtin");
const xev = @import("xev");
const crash = @import("../crash/main.zig");
const renderer = @import("../renderer.zig");
const apprt = @import("../apprt.zig");
const configpkg = @import("../config.zig");
@@ -191,6 +192,13 @@ pub fn threadMain(self: *Thread) void {
fn threadMain_(self: *Thread) !void {
defer log.debug("renderer thread exited", .{});
// Setup our crash metadata
crash.sentry.thread_state = .{
.type = .renderer,
.surface = self.renderer.surface_mailbox.surface,
};
defer crash.sentry.thread_state = null;
// Run our loop start/end callbacks if the renderer cares.
const has_loop = @hasDecl(renderer.Renderer, "loopEnter");
if (has_loop) try self.renderer.loopEnter(self);
@@ -263,6 +271,8 @@ fn drainMailbox(self: *Thread) !void {
while (self.mailbox.pop()) |message| {
log.debug("mailbox message={}", .{message});
switch (message) {
.crash => @panic("crash request, crashing intentionally"),
.visible => |v| {
// Set our visible state
self.flags.visible = v;

View File

@@ -8,6 +8,10 @@ const terminal = @import("../terminal/main.zig");
/// The messages that can be sent to a renderer thread.
pub const Message = union(enum) {
/// Purposely crash the renderer. This is used for testing and debugging.
/// See the "crash" binding action.
crash: void,
/// A change in state in the window focus that this renderer is
/// rendering within. This is only sent when a change is detected so
/// the renderer is expected to handle all of these.

View File

@@ -15,6 +15,7 @@ const std = @import("std");
const ArenaAllocator = std.heap.ArenaAllocator;
const builtin = @import("builtin");
const xev = @import("xev");
const crash = @import("../crash/main.zig");
const termio = @import("../termio.zig");
const BlockingQueue = @import("../blocking_queue.zig").BlockingQueue;
@@ -200,6 +201,13 @@ pub fn threadMain(self: *Thread, io: *termio.Termio) void {
fn threadMain_(self: *Thread, io: *termio.Termio) !void {
defer log.debug("IO thread exited", .{});
// Setup our crash metadata
crash.sentry.thread_state = .{
.type = .io,
.surface = io.surface_mailbox.surface,
};
defer crash.sentry.thread_state = null;
// Get the mailbox. This must be an SPSC mailbox for threading.
const mailbox = switch (io.mailbox) {
.spsc => |*v| v,
@@ -261,6 +269,7 @@ fn drainMailbox(
log.debug("mailbox message={}", .{message});
switch (message) {
.crash => @panic("crash request, crashing intentionally"),
.change_config => |config| {
defer config.alloc.destroy(config.ptr);
try io.changeConfig(data, config.ptr);

View File

@@ -29,6 +29,10 @@ pub const Message = union(enum) {
padding: renderer.Padding,
};
/// Purposely crash the renderer. This is used for testing and debugging.
/// See the "crash" binding action.
crash: void,
/// The derived configuration to update the implementation with. This
/// is allocated via the allocator and is expected to be freed when done.
change_config: struct {