Recently, I’m learning the “Diagnostics” pattern through std.tar
. I want to use Diagnostics as my structured error handling paradigm.
To be honest, I am not fully satisfied with this implementation. I think it has the following problems:
1.Enabling or not enabling Diagnostics will cause changes in control flow other than error handling. When Diagnostics is enabled, some control flows that would have returned immediately due to an error will continue to execute.
2.After enabling Diagnostics, errors that would have been thrown, such as TarComponentsOutsideStrippedPrefix
, will no longer be thrown, but will be implicitly hidden in Diagnostics, and the caller cannot determine whether an error has occurred based on the error union. Now only “double error” (errors that occur during diagnostics) will be thrown.
3. Error codes are for control flow.I agree with this statement, and further, I also think that “error” should always be the center of error control flow, rather than other things, such as an enumTag that has nothing to do with error codes. The current Diagnostics pattern’s control flow center has changed from the error type to a tagged union tag. This is what I am particularly dissatisfied with.
These problems lead to the “Diagnostics” pattern being used for small-scale local error diagnosis, and not a substitute for global, holistic, structural errors. But in practice, the need for errors to carry context may be far greater than you think. Therefore, I tried to make a derived global “Diagnostics” pattern.
In this pattern, the last_diagnostic
pointer and the thrown error code together form the latest error context. If you want to override the underlying error with an upper-layer error that carries upper-layer information, you only need to push the captured underlying error together with its last_diagnostic
, then rewrite last_diagnostic
and throw a new error.
Tagged union is no longer used, because “error” is the only core of error control flow, so error code + ordinary union is used, supplemented by reflection between union field name and error name. Unfortunately, I have not found a way to convert errors through strings at compile time, nor have I found a solution to traverse errors in the error set at compile time (in this way, I can create a “diagnosable” error set to implement compile-time reflection), so currently, this reflection still relies on runtime string comparison, which deviates from the “error code” being the only core of error control flow. Now “error name” participates in controlling the control flow and has some impact on runtime performance.
Note: Use std.debug.print
only to make the test pass, and actually use std.log.err
const std = @import("std");
pub const Diagnostics = struct {
arena: std.heap.ArenaAllocator,
error_stack: std.ArrayListUnmanaged(Error) = .empty,
last_diagnostic: Diagnostic = undefined,
double_error: ?anyerror = null,
pub const Error = struct {
code: anyerror,
diagnostic: Diagnostic,
};
pub fn clear(self: *Diagnostics) void {
_ = self.arena.reset(.free_all);
self.error_stack = .empty;
self.last_diagnostic = undefined;
self.double_error = null;
}
pub fn log_all(self: *Diagnostics, last_error: ?anyerror) void {
if (last_error) |err| {
if (self.double_error) |double_error| {
std.debug.print("double error!{s}", .{@errorName(double_error)});
}
self.last_diagnostic.log(err);
var it = std.mem.reverseIterator(self.error_stack.items);
while (it.nextPtr()) |item| {
item.diagnostic.log(item.code);
}
if (@errorReturnTrace()) |trace| {
std.debug.dumpStackTrace(trace.*);
}
} else return;
}
};
pub const Diagnostic = union {
TarUnableToCreateSymLink: struct {
file_name: []const u8,
link_name: []const u8,
fn log(self: @This()) void {
std.debug.print("file_name: {s} link_name: {s}\n", .{ self.file_name, self.link_name });
}
},
TarComponentsOutsideStrippedPrefix: struct {
file_name: []const u8,
fn log(self: @This()) void {
std.debug.print("file_name: {s}\n", .{self.file_name});
}
},
pub fn enterStack(last_diagnostic: *@This(), last_error: anyerror) !void {
var diagnostics: *Diagnostics = @fieldParentPtr("last_diagnostic", last_diagnostic);
if (diagnostics.double_error) |_| {
return last_error;
}
diagnostics.error_stack.append(diagnostics.arena.allocator(), .{ .code = last_error, .diagnostic = last_diagnostic.* }) catch |double_error| {
diagnostics.double_error = double_error;
return last_error;
};
last_diagnostic.* = undefined;
}
pub fn getAllocator(last_diagnostic: *@This()) std.mem.Allocator {
const diagnostics: *Diagnostics = @fieldParentPtr("last_diagnostic", last_diagnostic);
return diagnostics.arena.allocator();
}
pub fn unableToConstructDiagnostic(last_diagnostic: *@This(), err: anyerror) !void {
const diagnostics: *Diagnostics = @fieldParentPtr("last_diagnostic", last_diagnostic);
diagnostics.double_error = err;
return error.UnableToConstructDiagnostic;
}
pub fn log(self: *Diagnostic, err: anyerror) void {
inline for (@typeInfo(Diagnostic).@"union".fields) |field| {
if (std.mem.eql(u8, @errorName(err), field.name)) {
if (@hasDecl(@FieldType(Diagnostic, field.name), "log")) {
@field(self, field.name).log();
} else {
std.debug.print("{s}:{}\n", .{ field.name, @field(self, field.name) });
}
return;
}
}
std.debug.print("{s}\n", .{@errorName(err)});
}
};
// Creates a symbolic link at path `file_name` which points to `link_name`.
fn createDirAndSymlink(dir: std.fs.Dir, link_name: []const u8, file_name: []const u8) !void {
dir.symLink(link_name, file_name, .{}) catch |err| {
if (err == error.FileNotFound) {
if (std.fs.path.dirname(file_name)) |dir_name| {
try dir.makePath(dir_name);
return try dir.symLink(link_name, file_name, .{});
}
}
return err;
};
}
fn stripComponents(path: []const u8, count: u32) []const u8 {
var i: usize = 0;
var c = count;
while (c > 0) : (c -= 1) {
if (std.mem.indexOfScalarPos(u8, path, i, '/')) |pos| {
i = pos + 1;
} else {
i = path.len;
break;
}
}
return path[i..];
}
fn foo(last_diagnostic: *Diagnostic) !void {
const path_names = [_][]const u8{ "hello", "world" };
for (path_names) |path_name| {
const file_name = stripComponents(path_name, 1);
if (file_name.len == 0) {
last_diagnostic.* = .{ .TarComponentsOutsideStrippedPrefix = .{
.file_name = last_diagnostic.getAllocator().dupe(u8, file_name) catch |err| {
return last_diagnostic.unableToConstructDiagnostic(err);
},
} };
return error.TarComponentsOutsideStrippedPrefix;
}
var root = std.testing.tmpDir(.{});
defer root.cleanup();
const link_name = "link";
createDirAndSymlink(root.dir, link_name, file_name) catch |err| {
try last_diagnostic.enterStack(err);
last_diagnostic.* = .{ .TarUnableToCreateSymLink = .{
.file_name = last_diagnostic.getAllocator().dupe(u8, file_name) catch |e| {
return last_diagnostic.unableToConstructDiagnostic(e);
},
.link_name = last_diagnostic.getAllocator().dupe(u8, link_name) catch |e| {
return last_diagnostic.unableToConstructDiagnostic(e);
},
} };
return error.TarUnableToCreateSymLink;
};
}
}
fn bar(last_diagnostic: *Diagnostic) !void {
const path_names = [_][]const u8{ "hello/world", "world/hello" };
for (path_names) |path_name| {
const file_name = stripComponents(path_name, 1);
if (file_name.len == 0) {
last_diagnostic.* = .{ .TarComponentsOutsideStrippedPrefix = .{
.file_name = last_diagnostic.getAllocator().dupe(u8, file_name) catch |err| {
return last_diagnostic.unableToConstructDiagnostic(err);
},
} };
return error.TarComponentsOutsideStrippedPrefix;
}
var root = std.testing.tmpDir(.{});
defer root.cleanup();
const link_name = "link";
createDirAndSymlink(root.dir, link_name, file_name) catch |err| {
try last_diagnostic.enterStack(err);
last_diagnostic.* = .{ .TarUnableToCreateSymLink = .{
.file_name = last_diagnostic.getAllocator().dupe(u8, file_name) catch |e| {
return last_diagnostic.unableToConstructDiagnostic(e);
},
.link_name = last_diagnostic.getAllocator().dupe(u8, link_name) catch |e| {
return last_diagnostic.unableToConstructDiagnostic(e);
},
} };
return error.TarUnableToCreateSymLink;
};
}
}
fn parse_csv(last_diagnostic: *Diagnostic) !void {
// now in the middle of this mess we have a int parsing error.
try last_diagnostic.enterStack(error.InvalidCharacter);
return error.ParseIntError;
}
fn baz(last_diagnostic: *Diagnostic) !void {
parse_csv(last_diagnostic) catch |err| {
try last_diagnostic.enterStack(err);
return error.ParseCsvFailed;
};
}
test "new diagnostics" {
const root_allocator = std.testing.allocator;
var arena = std.heap.ArenaAllocator.init(root_allocator);
defer arena.deinit();
var diagnostics: Diagnostics = .{ .arena = arena };
foo(&diagnostics.last_diagnostic) catch |err| {
diagnostics.log_all(err);
diagnostics.clear();
};
bar(&diagnostics.last_diagnostic) catch |err| {
diagnostics.log_all(err);
diagnostics.clear();
};
baz(&diagnostics.last_diagnostic) catch |err| {
diagnostics.log_all(err);
diagnostics.clear();
};
}