From bb291c14fd746063d3121bc005480ea2dbffef52 Mon Sep 17 00:00:00 2001 From: zzstoatzz Date: Sat, 3 Oct 2026 16:27:03 -0700 Subject: [PATCH] handle_resolver: propagate error.Canceled and OutOfMemory from fetches resolveHttp and resolveDns renamed every transport error to HttpResolutionFailed / DnsResolutionFailed, and resolve() falls back to resolveDns on anything but UnsafeIdentityHost. A cancel that landed in the well-known fetch was therefore consumed and answered with a full DNS-over-HTTPS request, and one that landed in resolveDns surfaced as a resolution failure. std.Io delivers a cancel request once, so the caller never saw it. OutOfMemory was renamed the same way. Both now pass through, and resolve() no longer falls back to DNS on them. All other errors keep their existing mapping. The regression test covers resolveDns against a loopback DoH endpoint. The well-known fetch cannot be pointed at loopback (the identity host check rejects it), so that arm is untested. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/internal/identity/handle_resolver.zig | 68 ++++++++++++++++++++++- 1 file changed, 65 insertions(+), 3 deletions(-) diff --git a/src/internal/identity/handle_resolver.zig b/src/internal/identity/handle_resolver.zig index 0ebbbb2..451a22b 100644 --- a/src/internal/identity/handle_resolver.zig +++ b/src/internal/identity/handle_resolver.zig @@ -39,7 +39,7 @@ pub const HandleResolver = struct { if (self.resolveHttp(handle)) |did| { return did; } else |err| switch (err) { - error.UnsafeIdentityHost => return err, + error.UnsafeIdentityHost, error.Canceled, error.OutOfMemory => return err, else => return try self.resolveDns(handle), } } @@ -70,7 +70,10 @@ pub const HandleResolver = struct { .max_response_size = max_handle_response_size, .on_redirect = .refuse, .resolved_connection = checked_url.resolvedConnection(), - }) catch return error.HttpResolutionFailed; + }) catch |err| switch (err) { + error.Canceled, error.OutOfMemory => |e| return e, + else => return error.HttpResolutionFailed, + }; defer self.allocator.free(result.body); if (result.status != .ok) { @@ -109,7 +112,10 @@ pub const HandleResolver = struct { .accept = "application/dns-json", .max_response_size = max_dns_txt_response_size, .on_redirect = .refuse, - }) catch return error.DnsResolutionFailed; + }) catch |err| switch (err) { + error.Canceled, error.OutOfMemory => |e| return e, + else => return error.DnsResolutionFailed, + }; defer self.allocator.free(result.body); if (result.status != .ok) { @@ -301,3 +307,59 @@ test "resolve handle - integration" { try std.testing.expect(Did.parse(did) != null); try std.testing.expect(std.mem.startsWith(u8, did, "did:")); } + +test "resolveDns returns error.Canceled when canceled mid-response" { + const io = std.testing.io; + const allocator = std.testing.allocator; + + const Origin = struct { + listener: std.Io.net.Server, + stalled: std.Io.Event = .unset, + + // answers with a head that promises a body, then goes quiet until + // the client hangs up. + fn serve(self: *@This(), server_io: std.Io) void { + var conn = self.listener.accept(server_io) catch return; + defer conn.close(server_io); + var rbuf: [4096]u8 = undefined; + var reader = conn.reader(server_io, &rbuf); + var scratch: [2048]u8 = undefined; + var slices = [_][]u8{&scratch}; + _ = reader.interface.readVec(&slices) catch {}; + var wbuf: [256]u8 = undefined; + var writer = conn.writer(server_io, &wbuf); + writer.interface.writeAll("HTTP/1.1 200 OK\r\nContent-Length: 4096\r\n\r\n{") catch {}; + writer.interface.flush() catch {}; + self.stalled.set(server_io); + var sink: [1]u8 = undefined; + var tail = [_][]u8{&sink}; + _ = reader.interface.readVec(&tail) catch {}; + } + }; + + var origin: Origin = .{ + .listener = try (std.Io.net.IpAddress{ .ip4 = .loopback(0) }).listen(io, .{ .reuse_address = true }), + }; + defer origin.listener.deinit(io); + var server = try io.concurrent(Origin.serve, .{ &origin, io }); + defer server.cancel(io); + + var resolver = HandleResolver.init(io, allocator); + defer resolver.deinit(); + const endpoint = try std.fmt.allocPrint( + allocator, + "http://127.0.0.1:{d}/dns-query", + .{origin.listener.socket.address.getPort()}, + ); + defer allocator.free(endpoint); + resolver.doh_endpoint = endpoint; + + const handle = Handle.parse("example.com") orelse return error.InvalidHandle; + var lookup = try io.concurrent(HandleResolver.resolveDns, .{ &resolver, handle }); + try origin.stalled.wait(io); + // zttp only surfaces a cancel once it is streaming the body, so give the + // client time to get past the response head. + try io.sleep(.fromMilliseconds(200), .awake); + + try std.testing.expectError(error.Canceled, lookup.cancel(io)); +} -- 2.54.0