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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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`:
Expand Down
2 changes: 1 addition & 1 deletion build.zig.zon
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
49 changes: 45 additions & 4 deletions src/relay.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/root.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading