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
1 change: 1 addition & 0 deletions changelog.d/6862-dns-parity-expansion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Expanded deterministic `node:dns` parity coverage from 6 to 43 fixtures and strengthened the node-suite regression floor to protect both pass counts and fixture counts.
4 changes: 1 addition & 3 deletions crates/perry-codegen/src/loop_purity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,7 @@ fn expr_alloc_free(e: &Expr) -> bool {
// Element READS never allocate — they return an existing element / a
// number. Recurse so the object and index are themselves alloc-free.
Expr::IndexGet { object, index } => expr_alloc_free(object) && expr_alloc_free(index),
Expr::BufferIndexGet { buffer, index } => {
expr_alloc_free(buffer) && expr_alloc_free(index)
}
Expr::BufferIndexGet { buffer, index } => expr_alloc_free(buffer) && expr_alloc_free(index),
Expr::Uint8ArrayGet { array, index } => expr_alloc_free(array) && expr_alloc_free(index),
// `arr[i]++` / `--`: read-modify-write of an existing numeric slot, no
// growth, no allocation.
Expand Down
30 changes: 25 additions & 5 deletions scripts/node_suite_regression_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,15 +52,23 @@ def main():
print(f"ERROR: runner exited {proc.returncode}", file=sys.stderr)
return 2

# Parse "module pass total %" rows from the runner table.
# Parse "module pass total % outcome=count ..." rows from the runner table.
# The header row ("module pass total %") can't match because pass/total
# are not digits, so no name-based exclusion is needed — and excluding the
# name "module" would wrongly drop the real node:module module.
current = {}
for line in proc.stdout.splitlines():
m = re.match(r"^(\S+)\s+(\d+)\s+(\d+)\s+[\d.]+", line)
m = re.match(r"^(\S+)\s+(\d+)\s+(\d+)\s+[\d.]+(?:\s+(.*))?$", line)
if m:
current[m.group(1)] = {"pass": int(m.group(2)), "total": int(m.group(3))}
outcomes = {
name: int(count)
for name, count in re.findall(r"(\w+)=(\d+)", m.group(4) or "")
}
current[m.group(1)] = {
"pass": int(m.group(2)),
"total": int(m.group(3)),
"outcomes": outcomes,
}

regressions, improvements = [], []
for mod, floor in baseline.items():
Expand All @@ -71,8 +79,20 @@ def main():
if cur["pass"] < floor["pass"]:
regressions.append(
f"{mod}: {cur['pass']}/{cur['total']} < floor {floor['pass']}/{floor['total']} (-{floor['pass'] - cur['pass']})")
elif cur["pass"] > floor["pass"]:
improvements.append(f"{mod}: {cur['pass']}/{cur['total']} (+{cur['pass'] - floor['pass']})")
if cur["total"] < floor["total"]:
regressions.append(
f"{mod}: {cur['total']} fixtures < floor {floor['total']} (-{floor['total'] - cur['total']})")
for outcome, ceiling in floor.get("outcomes", {}).items():
count = cur["outcomes"].get(outcome, 0)
if count > ceiling:
regressions.append(
f"{mod}: {outcome}={count} > ceiling {ceiling} (+{count - ceiling})")
pass_delta = cur["pass"] - floor["pass"]
fixture_delta = cur["total"] - floor["total"]
if pass_delta > 0 or fixture_delta > 0:
improvements.append(
f"{mod}: {cur['pass']}/{cur['total']} "
f"({pass_delta:+d} passes, {fixture_delta:+d} fixtures)")

# Overall is derived, not stored (avoids cross-PR merge conflicts on a
# shared aggregate). Compute it from the per-module floors at report time.
Expand Down
16 changes: 10 additions & 6 deletions scripts/node_suite_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ def run_one(args):
try:
n = subprocess.run([NODE, path], capture_output=True, text=True, timeout=30)
except Exception:
return (mod, "node_err")
return (mod, "node_err", path)
# A non-zero node exit can be intentional (the test exercises an error path),
# so we don't bucket it as node_err; we require Perry to match BOTH stdout and
# the exit code below, which keeps genuine error-path parity counted as pass.
Expand All @@ -112,15 +112,15 @@ def run_one(args):
try:
c = subprocess.run([PERRY, path, "-o", out], capture_output=True, text=True, timeout=120)
if c.returncode != 0:
return (mod, "compile_fail")
return (mod, "compile_fail", path)
p = subprocess.run([out], capture_output=True, text=True, timeout=30)
except Exception:
return (mod, "perry_err")
return (mod, "perry_err", path)
# Match stdout byte-for-byte (ignore only trailing-newline noise, not leading
# whitespace) AND exit code — so a Perry crash that happened to print matching
# output before dying is a diff, not a false pass.
ok = (normalize(n.stdout.rstrip("\n")) == normalize(p.stdout.rstrip("\n"))) and (n.returncode == p.returncode)
return (mod, "pass" if ok else "diff")
return (mod, "pass" if ok else "diff", path)


# --- pre-warm one test per module serially ---
Expand All @@ -144,11 +144,15 @@ def run_one(args):
sys.stderr.write(f"fast lane: {len(fast)} tests @6, slow lane: {len(slow)} tests @1\n")
sys.stderr.flush()
with ThreadPoolExecutor(max_workers=6) as ex:
for mod, outcome in ex.map(run_one, fast):
for mod, outcome, path in ex.map(run_one, fast):
res[mod][outcome] += 1
if outcome not in {"pass", "diff"}:
sys.stderr.write(f"{outcome}: {os.path.relpath(path, ROOT)}\n")
for t in slow:
mod, outcome = run_one(t)
mod, outcome, path = run_one(t)
res[mod][outcome] += 1
if outcome not in {"pass", "diff"}:
sys.stderr.write(f"{outcome}: {os.path.relpath(path, ROOT)}\n")

# --- report ---
tot_p = tot = 0
Expand Down
218 changes: 218 additions & 0 deletions test-parity/node-suite/dns/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
# `node:dns` granular parity suite

This directory compares deterministic public `node:dns` and `node:dns/promises`
behavior with Node 26.5.0. Each TypeScript file has one contract or one small
record family. The differential runner executes this module sequentially.

## Audited starting point

The six starting fixtures were reviewed before expansion:

- `constants/error-aliases.ts` already covered the full public error-code table.
- `imports/default-export.ts` mixed import identity with a live `resolve4()`
request. The request was removed because it queried the host nameserver and
changed between `ECONNREFUSED`, `ENOTFOUND`, and `EBADRESP`.
- `lookup/loopback.ts` uses only the system hosts path and loopback addresses.
It remains as the broad callback/promise smoke case.
- `resolve/localhost.ts` queried the configured nameserver rather than the hosts
file. It was removed and replaced with local authoritative-server fixtures.
- `settings/default-result-order.ts` used host-dependent localhost ordering. It
now tests only shared state, valid values, and invalid-value preservation.
- `settings/servers.ts` only parses and stores server addresses. It remains and
now reports missing alternate-runtime methods without aborting.

The audit also traced Perry's DNS manifest, native dispatch table,
`crates/perry-runtime/src/dns.rs`, and
`crates/perry-runtime/src/dns_resolver.rs`. Perry implements real wire queries,
but several `Resolver` object, validation, callback-request, TTL, cancellation,
and descriptor contracts still differ from Node.

## Fixed upstream sources

The selection was reviewed on 2026-07-26 against these primary snapshots:

- Node 26.5.0 commit
[`bebd1b8d92bf4cc917844d6335ed1ecf9c2a75fb`](https://github.com/nodejs/node/tree/bebd1b8d92bf4cc917844d6335ed1ecf9c2a75fb),
especially
[`lib/dns.js`](https://github.com/nodejs/node/blob/bebd1b8d92bf4cc917844d6335ed1ecf9c2a75fb/lib/dns.js),
[`internal/dns/utils.js`](https://github.com/nodejs/node/blob/bebd1b8d92bf4cc917844d6335ed1ecf9c2a75fb/lib/internal/dns/utils.js),
[`callback_resolver.js`](https://github.com/nodejs/node/blob/bebd1b8d92bf4cc917844d6335ed1ecf9c2a75fb/lib/internal/dns/callback_resolver.js),
[`promises.js`](https://github.com/nodejs/node/blob/bebd1b8d92bf4cc917844d6335ed1ecf9c2a75fb/lib/internal/dns/promises.js),
and the
[`test-dns*` parallel tests](https://github.com/nodejs/node/tree/bebd1b8d92bf4cc917844d6335ed1ecf9c2a75fb/test/parallel).
- Deno main commit
[`34c46613cbe20450b74c0e8d4f0fd8f6f781d807`](https://github.com/denoland/deno/tree/34c46613cbe20450b74c0e8d4f0fd8f6f781d807),
especially
[`dns_test.ts`](https://github.com/denoland/deno/blob/34c46613cbe20450b74c0e8d4f0fd8f6f781d807/tests/unit_node/dns_test.ts)
and its
[`node:dns` polyfill](https://github.com/denoland/deno/blob/34c46613cbe20450b74c0e8d4f0fd8f6f781d807/ext/node/polyfills/dns.ts).
- Bun main commit
[`44f6469e0d4ae93467aa65c7e3bc9001000c7b31`](https://github.com/oven-sh/bun/tree/44f6469e0d4ae93467aa65c7e3bc9001000c7b31),
especially
[`node-dns.test.js`](https://github.com/oven-sh/bun/blob/44f6469e0d4ae93467aa65c7e3bc9001000c7b31/test/js/node/dns/node-dns.test.js),
its selected
[Node DNS tests](https://github.com/oven-sh/bun/tree/44f6469e0d4ae93467aa65c7e3bc9001000c7b31/test/js/node/test/parallel),
and
[`dns.ts`](https://github.com/oven-sh/bun/blob/44f6469e0d4ae93467aa65c7e3bc9001000c7b31/src/js/node/dns.ts).

Node 26.5.0 is the oracle. Deno and Bun results show whether another runtime
made the same choice; they do not weaken the Node contract.

## Covered contracts

- export inventory, default/namespace identity, callback/promise aliases,
descriptors, function names, and arity;
- literal IPv4/IPv6 lookup, localhost loopback, callback request objects, family
forms, option accessor order, option validation, falsy hostnames, and
`util.promisify()` behavior;
- IPv4/IPv6 loopback `lookupService`, port coercion, and argument validation;
- shared default result order, module resolver rebinding, server parsing,
sparse/accessor arrays, invalid-update preservation, and resolver-local server
state;
- `Resolver` prototype layout, constructor option access and validation,
receiver checks, `setLocalAddress`, `setServers`, method validation, active
cancellation, and idempotent cancellation;
- callback and promise A/AAAA with TTL, ANY, CAA, CNAME, MX, NAPTR, NS, PTR,
SOA, SRV, TXT, IDNA, rrtype aliases, reverse validation, and DNS error shape.

Record fixtures use `fixtures/local-dns-server.mjs`. The `.mjs` extension keeps
the helper out of the runner's recursive `*.ts` fixture discovery and makes its
ES module mode explicit. It starts the same child Node server for every runtime,
binds an ephemeral UDP loopback port, returns fixed TEST-NET/documentation
records, and closes in `finally`. No fixture sends a query to an internet
nameserver.

## Environment and result

- Oracle: Node 26.5.0, commit `bebd1b8d92bf4cc917844d6335ed1ecf9c2a75fb`.
- Perry runtime: commit `563c35951b347aabac3e093efd9c8b2af8ecd5d9`, built with
`rustc 1.95.0 (59807616e 2026-04-14)` and `cargo build --release --bin perry`.
- Alternate execution: Deno 2.9.3 and Bun 1.2.18.
- Alternate source review: Deno `34c46613cbe20450b74c0e8d4f0fd8f6f781d807` and
Bun `44f6469e0d4ae93467aa65c7e3bc9001000c7b31`.

Three complete Node rounds ran all 43 fixtures with zero errors, crashes, or
timeouts and byte-identical aggregate SHA-256
`e18b3a7e82b9309c1f8db862d25a5ee1ec2210a61a4f0ad09582ebff948a2f2d`. Three
complete focused Perry runs produced the same **17 pass / 26 diff / 0 compile
failure / 0 crash / 0 timeout** result after active cancellation idempotence was
added. The baseline records `17/43`.

One complete alternate-runtime pass produced:

- Deno: 21 exact matches and 22 diffs; no error, crash, or timeout.
- Bun: 13 exact matches and 30 diffs; no error, crash, or timeout.

## Stable Perry differences

- Module surface: the `promises` property is a data property rather than Node's
lazy getter; public functions use different names and arities.
- Lookup: callback requests return `undefined`; callback checks, falsy-host
errors, string families, option getter order, validation, and promisification
differ.
- Lookup service: numeric-string promise coercion and argument checks differ.
- Resolution: TTL objects, typed record fields, callback request objects, and
enumerable DNS error fields differ. A/AAAA values, ANY values after key
canonicalization, IDNA, name records, and TXT records match.
- Resolver: active `cancel()` reports `ETIMEOUT` instead of `ECANCELLED`;
constructor checks, prototype layout, method metadata, resolve validation, and
local-address validation differ.
- Settings: default-resolver method rebinding, sparse/accessor server arrays,
and one bracketed IPv6 normalization case differ.

## Per-fixture classification

`pass` and `match` mean exact stdout and exit-code parity with Node 26.5.0.
`diff` means the fixture completed but exposed a stable contract difference.

| Fixture | Perry | Deno | Bun |
| ---------------------------------------- | ----- | ----- | ----- |
| `constants/error-aliases.ts` | pass | diff | match |
| `imports/aliases.ts` | pass | diff | diff |
| `imports/default-export.ts` | pass | match | match |
| `imports/descriptors.ts` | diff | diff | diff |
| `imports/export-inventory.ts` | pass | diff | diff |
| `imports/function-metadata.ts` | diff | diff | diff |
| `lookup-service/ipv6-loopback.ts` | pass | match | diff |
| `lookup-service/port-coercion.ts` | diff | diff | diff |
| `lookup-service/validation.ts` | diff | match | match |
| `lookup/callback-validation.ts` | diff | match | match |
| `lookup/falsy-hostname.ts` | diff | diff | diff |
| `lookup/family-forms.ts` | diff | diff | diff |
| `lookup/ip-literals-callback.ts` | diff | match | diff |
| `lookup/ip-literals-promises.ts` | pass | match | match |
| `lookup/loopback.ts` | pass | match | match |
| `lookup/options-accessors.ts` | diff | diff | diff |
| `lookup/options-validation.ts` | diff | match | match |
| `lookup/promisify.ts` | diff | diff | diff |
| `resolve/address-records.ts` | diff | match | diff |
| `resolve/any-records.ts` | pass | match | match |
| `resolve/errors.ts` | diff | diff | diff |
| `resolve/idna.ts` | pass | match | diff |
| `resolve/name-records.ts` | pass | diff | match |
| `resolve/reverse-validation.ts` | diff | diff | diff |
| `resolve/rrtype-aliases.ts` | diff | match | diff |
| `resolve/structured-records.ts` | diff | diff | diff |
| `resolve/txt-record.ts` | pass | match | diff |
| `resolver/cancel-active.ts` | diff | match | diff |
| `resolver/cancel-idempotent.ts` | pass | match | match |
| `resolver/constructor-validation.ts` | diff | match | diff |
| `resolver/method-metadata.ts` | diff | diff | diff |
| `resolver/options-accessors.ts` | pass | diff | diff |
| `resolver/prototype.ts` | diff | diff | diff |
| `resolver/receiver-validation.ts` | pass | match | diff |
| `resolver/resolve-receiver.ts` | pass | match | diff |
| `resolver/resolve-validation.ts` | diff | match | diff |
| `resolver/set-local-address.ts` | diff | diff | diff |
| `resolver/set-servers-validation.ts` | pass | match | match |
| `settings/default-resolver-rebinding.ts` | diff | diff | diff |
| `settings/default-result-order.ts` | pass | diff | diff |
| `settings/servers-array-semantics.ts` | diff | match | match |
| `settings/servers-normalization.ts` | diff | diff | match |
| `settings/servers.ts` | pass | diff | diff |

## Commands

```sh
cargo build --release --bin perry
NODE_BIN="$HOME/.nvm/versions/node/v26.5.0/bin/node" \
python3 scripts/node_suite_run.py target/release/perry "$PWD" dns
python3 -m json.tool test-parity/node_suite_baseline.json >/dev/null
```

Local authoritative-server cases need permission to bind ephemeral loopback UDP
ports and spawn the helper Node process.

## Stopping boundary

The suite stops at 43 fixtures. A fresh review of the fixed Node, Deno, and Bun
trees found no other public contract that was both deterministic, portable,
non-redundant, and reachable through this print-and-diff harness.

Excluded on purpose:

- Node's `test/internet/test-dns-*` files and Bun's public-domain fixtures:
answers, TTLs, delegation, and availability can change.
- Successful `reverse()` was prototyped against the local server. Perry did not
settle within 70 seconds, so retaining it would leave cleanup to the harness
timeout and would not isolate a useful result. Input validation remains.
- The valid callback `Resolver.resolve(hostname, callback)` default-rrtype
overload was prototyped against the local server. Node and Deno settled, but
Perry did not invoke the callback. Retaining it would require an arbitrary
timeout or fire-and-forget cleanup; argument validation remains covered.
- Node's two-channel query test was prototyped with two local sockets. Perry did
not pass the auxiliary-server ready barrier within 30 seconds. Server-state
independence remains covered without keeping a timeout that mixes child
process behavior into the DNS result.
- Resolver timeout/retry timing, set-servers-during-query, worker termination,
perf hooks, snapshots, memory faults, malformed packet counts, TCP fallback,
and stress cases depend on timers, scheduler order, internals, workers, or
crash-only harnesses.
- `resolveTlsa()` remains covered by export and method metadata only. It was not
in the requested record-method set or the selected Deno/Bun suites; adding a
value-shape case would not supply cross-runtime evidence.
- Exact host `getServers()` defaults, localhost address order, reverse
hostnames, service names, and non-loopback `lookup()` results depend on OS
configuration.
- DNS-over-TLS/HTTPS, DNSSEC, cache policy, and transport internals are not
public `node:dns` contracts in the selected Node suite.
Loading
Loading