postalserver / postalserver/postal
CheckAllDNSScheduledTask - undefined method 'unpack' for nil (NoMethodError) len = readable_socks[0].read(2).unpack('n')[0]
- Dominant language
- Ruby
- Stars
- 16.8k
- Forks
- 1.3k
- Avg merge
- 13d 7h
- Merged PRs (30d)
- 2
Description
## Describe the bug
Sometimes, when `CheckAllDNSScheduledTask` is running, we see the following error:
```
undefined method 'unpack' for nil (NoMethodError) len = readable_socks[0].read(2).unpack('n')[0]
```
## To Reproduce
This is hard, I guess have a lot of domains that need to do a DNS check?
## Explanation and suggested solution from Claude:
The failing line lives in `Resolv::DNS::Requester::TCP#recv_reply` (resolv 0.6.2, line 931):
```ruby
def recv_reply(readable_socks)
len = readable_socks[0].read(2).unpack('n')[0] # <-- nil.unpack here
reply = @socks[0].read(len)
return reply, nil
end
```
DNS-over-TCP frames every message with a 2-byte big-endian length prefix, which is what `read(2).unpack('n')` is decoding. `recv_reply` is only called after the requester's `request` loop has done an `IO.select/wait_readable `and been told the socket is readable. The catch: **a socket whose peer has closed the connection also reports as readable,** and ` IO#read(2)` returns `nil` at EOF rather than blocking. So when the nameserver (or a firewall/middlebox) closes the TCP connection without sending a complete reply, ` read(2)` is `nil` and `nil.unpack('n') `raises your `NoMethodError`.
**Why it only happens sometimes, and via the DNS task.** Resolv only uses TCP as a _fallback_. Looking at `Resolv::DNS#fetch_resource`, it queries over UDP first and only retries over TCP when the UDP reply comes back with the truncation bit set (`reply.tc == 1`). This stdlib path doesn't negotiate a large EDNS0 buffer, so truncation — and thus the TCP fallback — kicks in for domains with big record sets: lots of TXT (heavy SPF/DKIM/verification records), many MX or NS, DNSSEC, etc. `CheckAllDNSScheduledTask` walks TXT/MX/CNAME/NS across every domain and track-domain, so it's the task most likely to hit a truncated response, fall back to TCP, and then meet a nameserver that drops the TCP connection. That's your "sometimes."
**Why 0.6.2 turns it into a crash**. The `request` loop _does_ guard the socket read — but only for `Errno::ECONNREFUSED` and `Errno::ECONNRESET,` which it converts to a timeout. A clean FIN (graceful close → `read` returns `nil`) isn't an exception, so it slips past that rescue and surfaces as the raw `NoMethodError`.
**The fix**. Upstream hardened exactly this. The first released version containing the fix is resolv 0.7.1, where `recv_reply` now does:
```
len_data = readable_socks[0].read(2)
raise EOFError if len_data.nil? || len_data.bytesize != 2
len = len_data.unpack('n')[0]
reply = @socks[0].read(len)
raise EOFError if reply.nil? || reply.bytesize != len
```
So the practical fix is to update the gem to 0.7.1.
Contributor guide
Research direction
Start at CheckAllDNSScheduledTask and inspect the project's resolv dependency, then review Resolv::DNS::Requester::TCP#recv_reply and the Resolv::DNS#fetch_resource fallback described in the issue. Update resolv from 0.6.2 to 0.7.1 and verify the scheduled DNS checks no longer surface the nil.unpack error.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- ruby
- Domain
- backend, networking
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 68/100