Skip to content

Add periodic re-read safety net for TLS cert watcher (#586) - #1394

Merged
kriszyp merged 3 commits into
mainfrom
kris/586-cert-watcher-polling-fallback
Jul 8, 2026
Merged

kriszyp merged 3 commits into
mainfrom
kris/586-cert-watcher-polling-fallback

Conversation

@kriszyp

@kriszyp kriszyp commented Jun 19, 2026 •

Copy link
Copy Markdown
Member

Summary

The TLS cert-file watcher in security/keys.ts (loadAndWatch) relies solely on chokidar's inotify events. On filesystems where inotify is unreliable — overlayfs, many container setups, network mounts — a real certificate renewal can be silently missed, leaving Harper serving the old (expired) cert with no error. This is a live risk for the July 8 2026 fleet cert rotation (P0). A CI manifestation of exactly this — overlayfs dropping inotify events — was confirmed in #1392.

This PR hardens detection with a periodic re-read safety net. It does not touch worker-side propagation (the table subscription + TLS rebuild), which already works.

What changed

  • Periodic re-read poll added alongside the existing chokidar watcher. A per-file setInterval (.unref()'d so it never holds the process open) re-stats the watched file and routes through the same reload path on change. Chokidar stays the fast path; the poll is the belt-and-suspenders net for missed events.
  • De-dupe: both detection paths share the existing lastModified mtime fingerprint, so an inotify event and a poll for the same change never double-reload. An unchanged file does only a cheap statSync (no PEM read, no reload).
  • Configurable interval: top-level tls.certificateWatchInterval (ms), default 300000 (5 min); 0 disables polling. Registered in hdbTerms.ts, static/defaultConfig.yaml, the Joi validator (validation/configValidator.ts), and config-root.schema.json (which has additionalProperties: false on tlsConfig).

Where to look

  • security/keys.ts loadAndWatch — poll registration and thread gating.
  • Thread gating (please sanity-check): the certificate poll runs on the main thread only (workers get cert updates via the hdb_certificate subscription). The private-key poll runs on all threads, because each worker loads its own key directly from disk with no table propagation — a key renewal missed by inotify on a worker would otherwise strand the stale key. This asymmetry was raised by cross-model review and is deliberate.

Open item for reviewer (deliberately out of scope)

Cross-model review flagged a latent race: when a renewal rotates both cert and key, a worker's private-key poll only refreshes the in-thread privateKeys map — it does not itself trigger a createTLSSelector/updateTLS rebuild. The TLS rebuild is driven by the hdb_certificate subscription. This is pre-existing behavior of the private-key chokidar watcher too (its loadCert callback already just sets the map), not introduced here — this PR only adds a second trigger to the same path. Fixing worker key-rotation → immediate TLS rebuild is a separate, riskier change I deliberately kept out of this P0 patch. Flagging so it's not rediscovered.

Testing

  • New unit coverage in unitTests/security/keys.test.js: swap missed by inotify is picked up by the poll (chokidar stubbed so only the poll path runs); mtime-fingerprint dedup; configured-interval resolution + .unref(); default/invalid-interval fallback; 0 disables; private-key path stays wired.
  • Existing integrationTests/security/cert-reload.test.ts (TLS certificates not auto-reloaded on worker threads (flipping/stale cert after renewal) #586 multi-worker propagation regression) still passes.
  • test:unit:main + test:unit:config green (10 pre-existing globalIsolation.test.js failures are unrelated — identical on clean main). Lint/format clean; TypeStrip --check clean.

Notes

  • P0 / July 8 cert-rotation relevant; likely a 5.1.6 patch candidate.
  • Generated by an LLM (Claude Opus 4.8).

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a periodic polling safety net (certificateWatchInterval) to complement the inotify-based file watcher for certificates and private keys, ensuring reliable reloads in environments like containers or network mounts where inotify can fail. It includes configuration validation, default values, and comprehensive unit tests. The feedback suggests two key improvements: first, replacing the synchronous statSync call inside the polling loop with asynchronous fs.stat to prevent blocking the Node.js event loop; second, enforcing a minimum interval limit (e.g., 1000ms) to safeguard against rapid disk polling caused by configuration typos.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread security/keys.ts
Comment on lines +315 to +326
const poll = () => {
let stats;
try {
stats = statSync(path);
} catch (error) {
// File may be transiently absent (e.g. atomic-rename renewal in flight); the chokidar
// watcher and the next poll will pick up the replacement.
logger.trace?.(`Watch poll could not stat ${type}:`, path, error);
return;
}
loadFile(path, stats);
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using the synchronous statSync inside a periodic timer tick blocks the Node.js event loop. Since this poll runs on all threads for private keys and can be configured to run frequently, doing synchronous file I/O (especially on network mounts or slow container filesystems, which this safety net is specifically designed for) can cause latency spikes and degrade application performance.

We should use the asynchronous fs.stat instead to avoid blocking the event loop.

const poll = () => {
	fs.stat(path, (error, stats) => {
		if (error) {
			// File may be transiently absent (e.g. atomic-rename renewal in flight); the chokidar
			// watcher and the next poll will pick up the replacement.
			logger.trace?.("Watch poll could not stat " + type + ":", path, error);
			return;
		}
		loadFile(path, stats);
	});
};

Comment thread security/keys.ts
Comment on lines +260 to +266
function getCertificateWatchInterval(): number {
const configured = envManager.get(CONFIG_PARAMS.TLS_CERTIFICATEWATCHINTERVAL);
if (configured == null) return DEFAULT_CERTIFICATE_WATCH_INTERVAL_MS;
const interval = Number(configured);
if (!Number.isFinite(interval) || interval < 0) return DEFAULT_CERTIFICATE_WATCH_INTERVAL_MS;
return interval;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Enforce a reasonable minimum limit (e.g., 1000ms) for the watch interval to prevent accidental performance degradation or denial of service due to rapid disk polling. This also safeguards against configuration typos where a user might specify the interval in seconds instead of milliseconds (e.g., setting 300 instead of 300000).

Suggested change
function getCertificateWatchInterval(): number {
const configured = envManager.get(CONFIG_PARAMS.TLS_CERTIFICATEWATCHINTERVAL);
if (configured == null) return DEFAULT_CERTIFICATE_WATCH_INTERVAL_MS;
const interval = Number(configured);
if (!Number.isFinite(interval) || interval < 0) return DEFAULT_CERTIFICATE_WATCH_INTERVAL_MS;
return interval;
}
function getCertificateWatchInterval(): number {
const configured = envManager.get(CONFIG_PARAMS.TLS_CERTIFICATEWATCHINTERVAL);
if (configured == null) return DEFAULT_CERTIFICATE_WATCH_INTERVAL_MS;
const interval = Number(configured);
if (!Number.isFinite(interval) || interval < 0) return DEFAULT_CERTIFICATE_WATCH_INTERVAL_MS;
// Prevent excessively frequent polling (e.g. due to config typos like seconds vs ms) by enforcing a minimum of 1000ms (1s) unless disabled (0).
if (interval > 0 && interval < 1000) return DEFAULT_CERTIFICATE_WATCH_INTERVAL_MS;
return interval;
}

@claude

claude Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

@kriszyp

kriszyp commented Jun 19, 2026

Copy link
Copy Markdown
Member Author

Holding this — blocked on the security/keys.ts cert-reload fixes (do not merge yet).

This adds a production periodic re-read safety net so a missed inotify event can't strand a renewed cert (overlayfs / network-mount / container filesystems) — relevant to the July 8 cert rotation. The code is complete, but it should land on top of the two keys.ts cert-reload fixes currently in progress:

  1. the cert staleness-guard field-name mismatch in loadCertificates() — which currently rejects a detected reload, and
  2. the worker-thread private-key hot-reload race.

Until those land, the cert hot-reload integration test stays red regardless of detection, so verifying this PR end-to-end isn't meaningful yet. Once they merge I'll rebase this on top and re-verify the cert test end-to-end. This PR also supersedes the test-only polling override in #1392 (production polling makes that redundant).

— Claude (Opus 4.8), via the PR-triage session

kriszyp added a commit that referenced this pull request Jun 19, 2026
When a TLS renewal rotates both the certificate and the private key, each
worker receives the new cert through the system.hdb_certificate table
subscription (which rebuilds its TLS secure contexts) but reloads the new
private key independently from disk into its in-thread privateKeys map. The
key reload did not trigger a context rebuild, so a worker could build a
context pairing the new cert with the old key and never rebuild it until the
next cert-table change — leaving HTTPS stale/broken on that worker.

A private-key reload (the single handlePrivateKeyReload sink for both the
chokidar watcher and PR #1394's periodic poll) now triggers a debounced
rebuild of every live TLS selector on that thread via the module-level
liveTLSRebuilders set. The hdb_certificate subscription shares the same
debounced scheduleRebuild (identical 1500ms cadence, now coalesced).
Transient one-shot selectors (getReplicationCert) opt out via liveReload=false
so they don't accumulate. A rotation guard skips the initial load and
identical-content reloads to avoid watcher thrash.

Pre-existing race (not introduced by #1394, which left it out of scope).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
kriszyp and others added 2 commits June 19, 2026 15:20
The cert-file watcher in security/keys.ts relies solely on chokidar's
inotify events. On filesystems where inotify is unreliable (overlayfs,
many container setups, network mounts) a real cert renewal can be
silently missed, stranding the old/expired cert. This is a live risk
for fleet cert rotation.

Add a periodic re-read safety net alongside the existing chokidar
watcher: a per-file interval timer (.unref'd) re-stats the watched file
and routes through the same reload path on change. Both detection paths
share the existing lastModified fingerprint, so an inotify event and a
poll for the same change never double-reload, and an unchanged file does
no PEM read.

The certificate poll runs on the main thread only (workers get cert
updates via the hdb_certificate table subscription). The private-key
poll runs on all threads, because each worker loads its own key directly
from disk with no table propagation, so a key renewal missed by inotify
on a worker would otherwise strand the stale key.

Interval is configurable via the top-level tls.certificateWatchInterval
(ms, default 300000 = 5 minutes); 0 disables polling, leaving only
inotify. Registered in hdbTerms, defaultConfig.yaml, the Joi validator,
and config-root.schema.json (additionalProperties: false there).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
chokidar v4 defaults alwaysStat:false, so the 'change' handler is invoked
with undefined stats. loadFile read stats.mtimeMs directly, which threw and
was swallowed by the catch — silently skipping the inotify fast-path reload
and leaving only the periodic poll to recover. Fall back to statSync(path)
when stats is absent so the immediate watcher path works on all platforms.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@kriszyp
kriszyp force-pushed the kris/586-cert-watcher-polling-fallback branch from e987961 to 024cee0 Compare June 19, 2026 21:31
A misconfigured tls.certificateWatchInterval (e.g. 1, intending seconds)
would otherwise spin the safety-net poll into a tight stat() loop. Floor any
positive value at 1s; 0 still explicitly disables polling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@kriszyp
kriszyp requested a review from DavidCockerill June 19, 2026 21:32
@kriszyp

kriszyp commented Jun 19, 2026

Copy link
Copy Markdown
Member Author

Rebased onto current main (v5.1.6), reconciling this poll safety net with the cert-reload work that just shipped.

Rebase / conflict resolution

  • security/keys.ts auto-merged cleanly: kept the shipped loadAndWatch structure (the lastModified mtime-fingerprint dedup, the isMainThread reload-warn line) and re-applied the periodic poll on top. The shipped file_timestamp staleness guard (fix(tls): persist and read certificate file_timestamp for staleness guard #1408), worker secure-context rebuild on key hot-reload, and rebuild-on-first-key behavior are untouched.
  • Only conflict was unitTests/security/keys.test.js — purely co-located describe blocks (the shipped staleness-guard / hot-reload suites vs. this PR's poll suite). Kept both.

Poll is now the production companion to the inotify watcher

  • The poll statSyncs the cert file each tick and delegates into the shipped loadFile(path, stats), so it shares the one lastModified dedup — inotify and the poll never double-reload or thrash. No parallel reload path.
  • Main-thread-only for certs (workers get certs via the hdb_certificate subscription); runs on all threads for private keys (each worker loads its own key from disk). Configurable via tls.certificateWatchInterval (default 5m, 0 disables), timer .unref()'d. This is the safety net for the July-8 overlayfs/container/network-mount case where a real renewal's inotify event is silently missed.

Two small hardening commits from cross-model review (Codex clean; Gemini flagged):

  • Reload on chokidar change events that omit stats. chokidar v4 defaults alwaysStat:false, so loadFile was reading stats.mtimeMs off undefined, throwing into the catch and silently skipping the inotify fast-path reload (leaving only the poll). Now falls back to statSync(path) when stats is absent.
  • Clamp a sub-second configured interval to a 1s floor so a typo (e.g. 1 meaning seconds) can't spin a tight stat() loop; 0 still disables.

Tests: unitTests/security/keys.test.js (30 passing, incl. a test proving the poll picks up a cert swap when the inotify event is bypassed, plus the new stats-less-change and clamp tests) and both cert hot-reload integration tests (cert-reload, cert-key-reload) green locally.

I deliberately kept the per-tick stat synchronous (Gemini suggested async fs.stat): it's a 5-minute, cold-path safety net consistent with the existing sync stats in loadAndWatch, so async would add complexity for no real benefit. Flagging for the reviewer to weigh in if you'd prefer otherwise.

— generated by Claude Opus 4.8

@kriszyp
kriszyp marked this pull request as ready for review June 19, 2026 22:02
@kriszyp
kriszyp merged commit 8b722db into main Jul 8, 2026
82 of 83 checks passed
@kriszyp
kriszyp deleted the kris/586-cert-watcher-polling-fallback branch July 8, 2026 16:57
@kriszyp kriszyp added the patch label Jul 8, 2026
@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Patch cherry-pick: merged

Cherry-picked onto v5.1.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant