diff --git a/CHANGELOG.md b/CHANGELOG.md index cfe9af9..a391162 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.14.4] - 2026-09-23 + ### Fixed +- A dial cancelled while connecting stops. The connect loop caught every error, `Canceled` included, and moved on to the next resolved address, and once a task has been cancelled its later I/O can no longer be interrupted. So a caller that gave up on a relay whose host never answers the SYN still waited for connects the cancel could no longer reach: 15 seconds on loopback, and longer on a real network. It now returns `Canceled` at once. +- Each resolved address is tried once. `getaddrinfo` was called without hints, and it lists every address once per socket type, so each address was tried twice, and a connect that hung, hung twice. - A `wss://` dial that got through the TLS handshake and then failed frees its TLS state. The state and its two 64 KiB buffers were freed only by errdefers inside the block that created them, which had already exited, so a relay that answered the websocket upgrade with anything but 101, or a dial cancelled while waiting for that answer, leaked all three. A client that retries such a relay leaked on every retry. ## [0.14.3] - 2026-09-23 diff --git a/README.md b/README.md index bf39ec2..6de6192 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ it like [Notary](https://github.com/zig-nostr/notary), a remote signer that keep your key off every client. Full docs, benchmarks, and the ecosystem overview live at [zignostr.com](https://zignostr.com). -> **Status: early (`v0.14.3`).** The library core, transport, local-first store +> **Status: early (`v0.14.4`).** The library core, transport, local-first store > and signer protocol have shipped and are covered by tests. Two native apps run > on it today. APIs may still change before 1.0. @@ -77,7 +77,7 @@ Methodology and the full write-up are on the Add the library to your `build.zig.zon`: ```sh -zig fetch --save https://github.com/zig-nostr/nostr/archive/refs/tags/v0.14.3.tar.gz +zig fetch --save https://github.com/zig-nostr/nostr/archive/refs/tags/v0.14.4.tar.gz ``` Wire the module in `build.zig`: diff --git a/build.zig.zon b/build.zig.zon index 91ecccb..3ed5f28 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,6 +1,6 @@ .{ .name = .nostr, - .version = "0.14.3", + .version = "0.14.4", // Generated at project creation; never regenerate for this repo. .fingerprint = 0x208aa38fcd8fcc08, .minimum_zig_version = "0.16.0", diff --git a/src/relay.zig b/src/relay.zig index a9d541d..30508c2 100644 --- a/src/relay.zig +++ b/src/relay.zig @@ -667,8 +667,13 @@ fn resolveAndConnect(gpa: std.mem.Allocator, io: std.Io, host: []const u8, port: const host_z = try gpa.dupeZ(u8, host); defer gpa.free(host_z); + // Stream sockets only. With no hints the resolver lists every address once + // per socket type, so each address was tried twice, and a connect that hung + // hung twice. + var hints = std.mem.zeroes(std.c.addrinfo); + hints.socktype = posix.SOCK.STREAM; var res: ?*std.c.addrinfo = null; - if (@intFromEnum(std.c.getaddrinfo(host_z.ptr, null, null, &res)) != 0) + if (@intFromEnum(std.c.getaddrinfo(host_z.ptr, null, &hints, &res)) != 0) return error.NameResolutionFailed; const list = res orelse return error.NameResolutionFailed; defer std.c.freeaddrinfo(list); @@ -688,9 +693,15 @@ fn resolveAndConnect(gpa: std.mem.Allocator, io: std.Io, host: []const u8, port: } }, else => continue, }; - return address.connect(io, .{ .mode = .stream }) catch |err| { - last_err = err; - continue; + return address.connect(io, .{ .mode = .stream }) catch |err| switch (err) { + // A caller that gave up on this dial meant all of it. Trying the + // next address would start a connect the cancellation can no + // longer reach, and it would run to its own timeout. + error.Canceled => return error.Canceled, + else => { + last_err = err; + continue; + }, }; } return last_err; @@ -701,6 +712,11 @@ fn resolveAndConnect(gpa: std.mem.Allocator, io: std.Io, host: []const u8, port: /// against the system CA bundle), and the websocket opening handshake. Returns /// a ready `Relay`. The caller owns it and must `deinit`. /// +/// Takes no deadline. To bound it, run it with `io.concurrent` and cancel it +/// when the deadline passes: a cancelled dial stops where it is and frees what +/// it allocated. The name lookup is the one step a cancel cannot interrupt, +/// because it is a plain libc call. +/// /// Not covered by CI (no relay is reachable there); the transport-agnostic /// `Connection` and the `IoStream` adapter it uses are what the tests exercise. pub fn dial(gpa: std.mem.Allocator, io: std.Io, url: []const u8) !*Relay { @@ -1471,6 +1487,31 @@ test "answering a ping does not count as hearing from the relay" { try std.testing.expectEqual(@as(?i64, null), conn.idleMs(std.testing.io)); } +test "a dial waiting on a silent peer can be cancelled, and frees what it made" { + // A peer that accepts the TCP connection and then never answers the + // websocket upgrade is the case a caller needs a way out of. `dial` takes + // no deadline; running it concurrently and cancelling it is the way out, + // and this pins that the cancel lands and leaves nothing behind. + const allocator = std.testing.allocator; + const io = std.testing.io; + + var listen_address: std.Io.net.IpAddress = .{ .ip4 = .loopback(0) }; + var server = try listen_address.listen(io, .{ .reuse_address = true }); + defer server.deinit(io); + const url = try std.fmt.allocPrint(allocator, "ws://127.0.0.1:{d}", .{server.socket.address.ip4.port}); + defer allocator.free(url); + + const started = std.Io.Timestamp.now(io, .awake).toMilliseconds(); + var pending = try io.concurrent(dial, .{ allocator, io, url }); + try io.sleep(.fromMilliseconds(100), .awake); + if (pending.cancel(io)) |relay| { + relay.deinit(); + return error.TestUnexpectedResult; // the peer never answered, so no dial can have succeeded + } else |_| {} + const waited = std.Io.Timestamp.now(io, .awake).toMilliseconds() - started; + try std.testing.expect(waited < 5000); +} + test "a deadline fires on a quiet socket and leaves the connection usable" { // The property the whole shape rests on: a deadline that expires must // consume NOTHING, so the very next call still sees the message that was diff --git a/src/root.zig b/src/root.zig index daf4905..b5d1628 100644 --- a/src/root.zig +++ b/src/root.zig @@ -4,7 +4,7 @@ const std = @import("std"); /// Kept in step with `build.zig.zon` by hand, and it had drifted three /// releases behind, so anything reading it was told the wrong number. -pub const version = "0.14.3"; +pub const version = "0.14.4"; pub const bech32 = @import("bech32.zig"); pub const nip19 = @import("nip19.zig");