Skip to content

fix: pre-v0.6.0 release hardening#126

Merged
buffcode merged 15 commits into
masterfrom
fix/pre-v6-release
Jul 1, 2026
Merged

fix: pre-v0.6.0 release hardening#126
buffcode merged 15 commits into
masterfrom
fix/pre-v6-release

Conversation

@buffcode

@buffcode buffcode commented Apr 22, 2026

Copy link
Copy Markdown
Owner

Summary

Pre-release hardening and test scaffolding for v0.6.0.

Core library fixes

  • Move cache / singleton state onto class instances (6cbb103) — fixes cross-contamination between independent new NtpTimeSync(...) constructions
  • Rewrite createPacket with Buffer.writeBigUInt64BE (6b2b18f) — removes deprecated substr usage, ends per-packet string allocations, bit-accurate timestamps
  • Narrow NTP reply field validation in acceptResponse (4602083) — replaces ! assertions on transmitTimestamp / receiveTimestamp / precision
  • Register message listener before send (f8377c3) — closes a race where fast replies were missed
  • Increment retry on no-progress rounds (fdc1924) — previously only zero-result rounds triggered retry
  • Harden socket and listener cleanup (893ed71) — wraps sync throws in createPacket, uses removeAllListeners before close

Review-driven hardening (024b211)

  • Reject unknown constructor options — recursiveResolveOptions iterated over the defaults, so the "Invalid option" guard could never fire and typos were silently dropped. Now iterates the caller's keys and throws; recursion only descends when both sides are plain objects.
  • IPv6-aware server parsing — server.split(":", 2) mangled IPv6 literals (host became the first hextet, port NaN). New parseServer() handles host, host:port, IPv4[:port], bracketed IPv6 [::1][:port], and bare IPv6 literals (host-only), with port-range validation.
  • Signed clock offset — replaced ((|T2-T1| + |T3-T4|)/2) * sign(T3>T4) with the RFC 5905 formula ((T2-T1) + (T3-T4))/2, which was returning the wrong sign when the inbound and outbound legs disagreed.

Public API

  • Switch src/index.ts from export * to explicit named exports; deep-freeze NtpTimeSyncDefaultOptions (5c1032e). Surface area unchanged (5 names), runtime mutation now blocked.

Dependencies

Tests

  • 32 unit tests covering packet construction, response validation, sample math, instance isolation, retry bounds, socket-leak regression (4091071), plus option validation, server parsing, and an offset-sign regression (024b211). Runs in ~1s, fully offline via in-process UDP fixture.

CI

  • New verify-package job runs npm pack --dry-run, publint, and @arethetypeswrong/cli on Node 22 (1d20851). Catches exports/files/types regressions before publish.
  • Bump repository.url with git+ prefix for publint compliance (8f40049).
  • Key the node_modules cache on package.json instead of the gitignored yarn.lock (00c02b0).
  • Upgrade attw to 0.18.4 to fix a verify-package crash (dd2cd47): attw-core 0.18.2 decompressed the packed tarball with a streaming Gunzip callback that kept only the last chunk. CI resolves dependencies fresh (yarn.lock is gitignored), so it pulled fflate@0.8.3, which emits the tarball in multiple chunks → empty untar → Cannot read properties of undefined (reading 'filename'). core 0.18.3 collects all chunks. Reproduced on Node 22.23.1 with a node_modules install and verified 0.18.4 passes under the same conditions.

⚠️ Breaking changes

  • Unknown constructor options now throw Invalid option: <key> instead of being ignored.
  • Computed offsets change for asymmetric network paths (signed RFC 5905 formula).
  • src/index.ts no longer re-exports via export * (explicit named exports only).

Test plan

  • yarn build exits 0
  • yarn test:unit — 32/32 passing
  • yarn verify:package — pack + publint + attw all green
  • yarn test:integration:cjs and yarn test:integration:esm — passing
  • CI matrix green across all advertised Node versions
  • Manual: bump version in package.json to 0.6.0 before tagging

buffcode and others added 10 commits April 22, 2026 10:05
Add pre-publish artifact verification to catch broken files/exports,
wrong entry paths, or incorrect types resolution before 0.6.0 ships.
Runs as a dedicated verify-package job on Node 22 LTS (artifact check,
not runtime check) that needs the build matrix to succeed first.

- npm pack --dry-run: confirm tarball manifest is producible
- publint: lint package.json exports/main/module/types/files
- @arethetypeswrong/cli: verify TS consumers (node16/bundler) resolve
  working types for both CJS and ESM entrypoints

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace Array(48) + pad()/parseInt()/substr() chain with Buffer.alloc(48)
plus direct byte assignment and buf.writeBigUInt64BE for the origin and
transmit timestamps. Drops the now-unused pad() helper.

The new implementation splits the NTP 64-bit timestamp into whole seconds
and fractional seconds before combining via BigInt so the fractional
portion no longer loses low-order bits to the Number mantissa during the
legacy (seconds * 2**32) float round-trip.
acceptResponse previously validated only originTimestamp, leaving
transmitTimestamp, receiveTimestamp, and precision as non-null-asserted
in collectSamples. Add explicit undefined checks for those fields in
acceptResponse (throwing the same Format error pattern as existing
checks) so malformed replies are rejected before they become samples,
and drop the ! assertions in collectSamples. A guard in the forEach
provides type narrowing that is now backed by the validator.
The previous ordering registered client.once("message") inside the
send() callback, so an NTP reply that arrived after the packet was on
the wire but before Node fired the send callback would hit no listener
and be silently dropped; the caller would then wait the full
replyTimeout before rejecting. Attach the message listener up front and
keep hasFinished/timeoutHandler guards so the timeout, send errors, and
socket errors still short-circuit the promise correctly.
The retry counter only advanced when ntpResults was still empty, so any
partial success that produced fewer than numSamples usable packets
(e.g. one server responding, the rest persistently timing out) could
spin the do/while loop indefinitely because retry stayed at 0. Snapshot
the result count before each round and bump retry when the post-round
count is unchanged, keeping the existing retry < 3 cap.
Two related leaks: (1) dgram.send can throw synchronously when the
socket is in a bad state, and the throw was not caught, so the timeout
handler, UDP socket, and registered listeners all leaked and the
surrounding promise was never rejected; wrap the send call so we clear
the timeout, run NtpTimeSync.cleanup, and reject exactly once. (2)
NtpTimeSync.cleanup only called client.close(), leaving "message" and
"error" listeners attached on an abandoned socket where a late event
could still fire resolve/reject or prevent the process from exiting;
call client.removeAllListeners() before close().
@buffcode buffcode self-assigned this Apr 22, 2026
buffcode and others added 5 commits April 22, 2026 14:58
….lock

The cache key used hashFiles('**/yarn.lock'), but yarn.lock is
gitignored and thus absent from CI checkouts. hashFiles returned an
empty hash, collapsing the key to a constant (e.g. Linux-modules--22)
that never invalidated across commits.

This caused verify-package to restore a stale node_modules from
before commit 1d20851 and skip install (cache-hit guard), so newly
added devDependencies publint and @arethetypeswrong/cli were missing:
/bin/sh: 1: publint: not found (exit 127).

Switch all three jobs (build, verify-package, deno) to key on
package.json, which is tracked and changes when deps change.
Applies the open Dependabot update (#128, prettier 3.8.3 -> 3.9.3) and
bumps the ntp-packet-parser runtime dependency to 0.6.1. Verified with
unit + integration tests, publint, and attw.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
attw 0.18.2's `--pack` mode parses `npm pack --json` internally and
crashes ("Cannot read properties of undefined (reading 'filename')") on
the npm bundled with node 22.23.1 in CI, while publint's own pack of the
same tree succeeds. Pack the tarball ourselves and hand the .tgz to attw,
which bypasses the broken parser. Tarball is cleaned up and *.tgz is
already gitignored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@arethetypeswrong/cli 0.18.2 (core 0.18.2) decompresses the packed
tarball with a streaming Gunzip callback that keeps only the *last*
chunk. With fflate 0.8.3 (which CI resolves fresh since yarn.lock is
gitignored) the tarball is emitted in multiple chunks, so the untar
sees no data and attw crashes with "Cannot read properties of undefined
(reading 'filename')". core 0.18.3 fixed this to collect all chunks.

Reproduced on node 22.23.1 with a node_modules install; verified 0.18.4
passes under the same conditions. Reverts the earlier explicit-pack
workaround, which did not help since the bug is in extraction, not pack.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three correctness fixes flagged during the pre-v0.6.0 review:

- recursiveResolveOptions rejected unknown options by iterating over the
  defaults, so the guard could never fire and typos were silently
  ignored. Iterate the caller's keys instead (and only recurse when both
  sides are plain objects). Unknown options now throw "Invalid option".

- Server parsing used server.split(":", 2), which mangles IPv6 literals
  (host became the first hextet, port NaN). Add parseServer() supporting
  bracketed IPv6 with optional port, bare IPv6 literals (host-only), and
  the existing host / host:port / IPv4[:port] forms, with port-range
  validation.

- Clock offset used ((|T2-T1| + |T3-T4|) / 2) * sign(T3>T4), which gives
  the wrong result when the inbound and outbound legs disagree in sign.
  Use the signed RFC 5905 formula ((T2-T1) + (T3-T4)) / 2.

Adds 10 tests (option validation, server parsing) plus an offset-sign
regression that fails against the old heuristic. BREAKING: unknown
options now throw, and computed offsets change for asymmetric paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@buffcode buffcode merged commit 3ccdc1c into master Jul 1, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant