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: 2 additions & 2 deletions build.zig.zon
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
.fingerprint = 0x67bf20367bf50f8b,
.dependencies = .{
.coapz = .{
.url = "git+https://github.com/cvik/coapz#v0.2.1",
.hash = "coapz-0.2.1-kDItyE38AABNjQWC5ANU31XsJdHR9PBmfA4zJ3NmWcrB",
.url = "git+https://github.com/cvik/coapz#v0.3.0",
.hash = "coapz-0.3.0-kDItyJkUAQDoQvLuHUX2cOWPIygn2rBDEa8MskzHnCKr",
},
},
.paths = .{
Expand Down
37 changes: 13 additions & 24 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,40 +91,29 @@ The client handles these; the server does not.
allocate. Consider ring buffer of pending notifications per resource.

### 2.2 Server-side Block2 — large responses (RFC 7959)
- **Status:** `[ ]` not implemented
- **Status:** `[x]` done
- **Issue:** Server responses capped at buffer_size (1280 bytes). No
fragmentation engine for larger payloads.
- **Impact:** Cannot serve resource descriptions, firmware manifests, or any
response exceeding one MTU.
- **Effort:** Medium. Requires:
- Detect Block2 option in request (or response exceeds MTU)
- Fragment response into blocks, serve on demand
- Track block transfer state per peer+token
- Handle client block-size negotiation (smaller SZX)
- **Perf note:** Pre-allocate block transfer state slots. Lazy fragmentation
(generate blocks on demand) avoids buffering full payload.
- **Resolution:** Handler returns full payload; server fragments transparently.
Shared `BlockTransfer` pool (`Config.max_block_transfers`, default 32) caches
full payload and serves blocks on demand. SZX negotiation supported.

### 2.3 Server-side Block1 — large uploads (RFC 7959)
- **Status:** `[ ]` not implemented
- **Status:** `[x]` done
- **Issue:** Server cannot receive payloads larger than one packet. No block
reassembly on inbound requests.
- **Impact:** Cannot accept firmware uploads, large config pushes, etc.
- **Effort:** Medium. Requires:
- Detect Block1 option in incoming request
- Reassembly buffer per peer+token
- 2.31 Continue responses for intermediate blocks
- Deliver assembled payload to handler on final block
- Timeout/cleanup for abandoned transfers
- **Perf note:** Reassembly buffers must be pre-allocated and bounded. Cap
concurrent transfers and max payload size. Evict stale transfers.
- **Resolution:** Server reassembles Block1 fragments transparently. Handler
sees the complete payload only after all blocks arrive. 2.31 Continue sent
for intermediate blocks. Max upload size configurable via
`Config.max_block_payload` (default 64KB). Shared pool with Block2.

### 2.4 Observe sequence reordering (RFC 7641 §3.4)
- **Status:** `[-]` field exists, never checked
- **Status:** `[x]` done
- **Issue:** Client has `last_seq` field but `routeObserve()` never compares
incoming sequence numbers. Stale/reordered notifications delivered as fresh.
- **Impact:** Application may act on stale data without knowing.
- **Effort:** Small. Add comparison in `routeObserve()` per §3.4 freshness rules.
- **Perf note:** None — single integer comparison.
- **Resolution:** Client extracts Observe option from wire data, compares with
`last_seq` using 24-bit wrap-around freshness check per §3.4. Stale/duplicate
notifications dropped silently.

---

Expand Down
31 changes: 25 additions & 6 deletions src/Client.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1389,6 +1389,13 @@ fn dispatchRecv(client: *Client, data: []const u8) void {
}

/// Try to route data to an observe subscription. Returns true if consumed.
/// Extract the Observe option sequence number from raw CoAP wire data.
/// Returns null if no Observe option is present.
fn parseObserveSeq(data: []const u8) ?u24 {
const opt = coapz.Packet.peekOption(data, .observe) orelse return null;
return @intCast(opt.as_uint() orelse return null);
}

fn routeObserve(client: *Client, token: []const u8, data: []const u8) bool {
for (&client.observes) |*obs| {
if (!obs.active) continue;
Expand All @@ -1397,15 +1404,28 @@ fn routeObserve(client: *Client, token: []const u8, data: []const u8) bool {

client.peer_confirmed = true;

// RFC 7641 §3.4: check observe sequence freshness.
// Extract observe option value from the wire data.
if (parseObserveSeq(data)) |seq| {
if (obs.last_seq != 0 or seq != 0) {
// Fresh if seq > last (with 24-bit wrap-around tolerance).
const diff = seq -% obs.last_seq;
if (diff == 0 or diff > 0x800000) {
// Stale or duplicate — drop silently.
return true;
}
}
obs.last_seq = seq;
}

if (obs.pending_count < max_pending_notifications) {
const copy = client.allocator.alloc(u8, data.len) catch return true;
@memcpy(copy, data);
const msg_kind: u2 = @intCast((data[0] >> 4) & 0x03);
obs.pending[obs.pending_count] = .{
.data = copy,
.len = @intCast(data.len),
.msg_id = @as(u16, data[2]) << 8 | data[3],
.is_con = msg_kind == 0,
.msg_id = coapz.Packet.peekMsgId(data) orelse 0,
.is_con = coapz.Packet.peekKind(data) == .confirmable,
};
obs.pending_count += 1;
}
Expand Down Expand Up @@ -1644,12 +1664,11 @@ fn waitForAck(client: *Client, slot_idx: u16) !void {
if (obs.pending_count < max_pending_notifications) {
const copy = try client.allocator.alloc(u8, data.len);
@memcpy(copy, data);
const msg_kind: u2 = @intCast((data[0] >> 4) & 0x03);
obs.pending[obs.pending_count] = .{
.data = copy,
.len = @intCast(data.len),
.msg_id = @as(u16, data[2]) << 8 | data[3],
.is_con = msg_kind == 0,
.msg_id = coapz.Packet.peekMsgId(data) orelse 0,
.is_con = coapz.Packet.peekKind(data) == .confirmable,
};
obs.pending_count += 1;
}
Expand Down
Loading
Loading