The Cuckoo Escapement: field report, kernel patch, dashboard
What the Raspberry Pi time-server guides get wrong on a Pi 4, with the measurements. The headline artifact is a four-line pps-gpio patch: PREEMPT_RT force-threads IRQ handlers, and pps-gpio takes its timestamp inside its handler, so the realtime kernel puts a scheduler between the electrical edge and the clock. IRQF_NO_THREAD takes RMS offset from 2468 ns to 199 ns. - kernel/ the patch - dashboard/ live status page (position hidden by default) - docs-site/ the write-up (Astro/Starlight, brass, no tutorial section)
This commit is contained in:
commit
6881489bf6
56 changed files with 10297 additions and 0 deletions
19
docs-site/.dockerignore
Normal file
19
docs-site/.dockerignore
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
node_modules/
|
||||
dist/
|
||||
.astro/
|
||||
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
|
||||
.git/
|
||||
.gitignore
|
||||
|
||||
*.log
|
||||
.DS_Store
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# don't pull in repo-root artifacts
|
||||
../artifacts/
|
||||
README.md
|
||||
9
docs-site/.env.example
Normal file
9
docs-site/.env.example
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# The Cuckoo Escapement — docs-site environment.
|
||||
# Note: no MODE var. The compose *profile* is the mode switch
|
||||
# (the warehacking reference doc is stale on this point).
|
||||
|
||||
COMPOSE_PROJECT_NAME=cuckoo-escapement-docs
|
||||
|
||||
# Production: cuckoo.warehack.ing
|
||||
# Local dev: cuckoo.l.warehack.ing (internal only — never reference publicly)
|
||||
DOMAIN=cuckoo.warehack.ing
|
||||
22
docs-site/.gitignore
vendored
Normal file
22
docs-site/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# build output
|
||||
dist/
|
||||
.astro/
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# environment
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
|
||||
# editor
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
47
docs-site/Dockerfile
Normal file
47
docs-site/Dockerfile
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# Multi-stage build for the cuckoo-escapement docs site.
|
||||
#
|
||||
# Stages:
|
||||
# - base : Node, pnpm/npm tooling, deps installed
|
||||
# - dev : runs `astro dev` with HMR for local development
|
||||
# - builder : produces the static `dist/`
|
||||
# - prod : caddy:alpine that serves `dist/` (no Node at runtime)
|
||||
#
|
||||
# `docker compose --profile dev up` → dev target
|
||||
# `docker compose up` (no profile) → prod target
|
||||
|
||||
# Pinned through the mirror.gcr.io pass-through to dodge intermittent
|
||||
# Docker Hub TLS hiccups during builds. Same content, more reliable
|
||||
# fetch path. The `docker pull` resolves identically against either.
|
||||
FROM mirror.gcr.io/library/node:22-alpine AS base
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm install --no-audit --no-fund
|
||||
|
||||
|
||||
# ----- dev: astro dev server with HMR -----
|
||||
FROM base AS dev
|
||||
# Astro's binary is in node_modules/.bin — package.json's `dev` script
|
||||
# already binds to 0.0.0.0 for HMR-behind-Caddy.
|
||||
COPY . .
|
||||
ENV ASTRO_TELEMETRY_DISABLED=1
|
||||
EXPOSE 4321
|
||||
CMD ["npm", "run", "dev"]
|
||||
|
||||
|
||||
# ----- builder: produce dist/ -----
|
||||
FROM base AS builder
|
||||
COPY . .
|
||||
ENV ASTRO_TELEMETRY_DISABLED=1
|
||||
RUN npm run build
|
||||
|
||||
|
||||
# ----- prod: caddy serves the static build -----
|
||||
FROM mirror.gcr.io/library/caddy:2-alpine AS prod
|
||||
# Caddyfile is intentionally minimal — caddy-docker-proxy on the host
|
||||
# handles TLS, routing, and the public-facing reverse proxy. This
|
||||
# container just serves files locally; the proxy points at it.
|
||||
RUN mkdir -p /srv/docs
|
||||
COPY --from=builder /app/dist /srv/docs
|
||||
RUN printf ':80 {\n\troot * /srv/docs\n\tfile_server\n\ttry_files {path} {path}/ /404.html\n\tencode zstd gzip\n}\n' > /etc/caddy/Caddyfile
|
||||
EXPOSE 80
|
||||
59
docs-site/Makefile
Normal file
59
docs-site/Makefile
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
# cuckoo-escapement docs — make targets follow the warehacking cookie-cutter.
|
||||
#
|
||||
# `make prod` builds the static site + brings up Caddy serving it.
|
||||
# `make dev` starts the Astro dev server with HMR behind Caddy.
|
||||
# `make down` stops both.
|
||||
|
||||
SHELL := /usr/bin/env bash
|
||||
.SHELLFLAGS := -eu -o pipefail -c
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
.PHONY: help
|
||||
help: ## Show this help
|
||||
@awk 'BEGIN {FS = ":.*##"} /^[a-zA-Z0-9_-]+:.*##/ {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
|
||||
|
||||
.PHONY: prod
|
||||
prod: ## Build + run the production docs container (Caddy serves dist/)
|
||||
docker compose up -d --build docs
|
||||
|
||||
.PHONY: dev
|
||||
dev: ## Run the Astro dev server with HMR (--profile dev)
|
||||
docker compose --profile dev up --build docs-dev
|
||||
|
||||
.PHONY: down
|
||||
down: ## Stop and remove the docs containers
|
||||
docker compose --profile dev down
|
||||
docker compose down
|
||||
|
||||
.PHONY: logs
|
||||
logs: ## Tail logs (works for whichever profile is up)
|
||||
docker compose logs -f --tail=100
|
||||
|
||||
.PHONY: build
|
||||
build: ## Build the static site WITHOUT bringing up Caddy (CI gate)
|
||||
docker compose build docs
|
||||
|
||||
.PHONY: shell
|
||||
shell: ## Open a shell in the running dev container (debugging)
|
||||
docker compose exec docs-dev sh
|
||||
|
||||
# ---- Production deploy --------------------------------------------------
|
||||
#
|
||||
# `make deploy` pulls origin/main on the warehack.ing prod host and rebuilds
|
||||
# the docs container. Agent-forwarding (`-A`) lets the remote `git pull` use
|
||||
# the operator's local SSH key for Gitea — nothing persistent is provisioned
|
||||
# on the deploy host.
|
||||
#
|
||||
# Override DEPLOY_HOST / DEPLOY_PATH for a different deployment without
|
||||
# editing this file. The defaults are the warehack.ing cookie-cutter shape
|
||||
# (see ~/.claude/references/warehacking.md).
|
||||
|
||||
DEPLOY_HOST ?= warehack-ing@warehack.ing
|
||||
DEPLOY_PATH ?= ~/cuckoo-escapement
|
||||
|
||||
.PHONY: deploy
|
||||
deploy: ## Pull main + rebuild the docs container on the prod host
|
||||
@echo "==> deploying $(DEPLOY_HOST):$(DEPLOY_PATH)"
|
||||
ssh -A $(DEPLOY_HOST) "cd $(DEPLOY_PATH) && git fetch origin main && git reset --hard origin/main && cd docs-site && make prod"
|
||||
@echo "==> sanity check"
|
||||
@curl -s -o /dev/null -w " HTTP %{http_code} %{url_effective}\n" "https://cuckoo.warehack.ing/explanation/bug-detection/"
|
||||
105
docs-site/astro.config.mjs
Normal file
105
docs-site/astro.config.mjs
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
// The Cuckoo Escapement — Starlight, diátaxis-shaped.
|
||||
//
|
||||
// This site is a FIELD REPORT, not a tutorial. The build guides already exist
|
||||
// (geerlingguy/time-pi, josh-blake/pixie) and they're good. What doesn't exist
|
||||
// is a record of everything they get wrong on a Pi 4, with numbers. So the IA is
|
||||
// deliberately inverted from the usual docs site: heavy on Explanation and
|
||||
// Reference, and there is no Tutorial section at all.
|
||||
//
|
||||
// Telemetry + devToolbar off per project convention. The HMR block is required
|
||||
// when the dev server runs behind Caddy (TLS-terminating proxy) — without an
|
||||
// explicit host/protocol/clientPort, Vite's WebSocket drops every ~10s.
|
||||
//
|
||||
// Site URL comes from DOMAIN so one image serves both cuckoo.warehack.ing (prod)
|
||||
// and cuckoo.l.warehack.ing (local dev).
|
||||
|
||||
import mdx from "@astrojs/mdx";
|
||||
import sitemap from "@astrojs/sitemap";
|
||||
import starlight from "@astrojs/starlight";
|
||||
import { defineConfig } from "astro/config";
|
||||
import starlightLinksValidator from "starlight-links-validator";
|
||||
|
||||
const domain = process.env.DOMAIN ?? "cuckoo.warehack.ing";
|
||||
|
||||
export default defineConfig({
|
||||
site: `https://${domain}`,
|
||||
telemetry: false,
|
||||
devToolbar: { enabled: false },
|
||||
|
||||
vite: {
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
hmr: { host: domain, protocol: "wss", clientPort: 443 },
|
||||
},
|
||||
},
|
||||
|
||||
integrations: [
|
||||
starlight({
|
||||
title: "The Cuckoo Escapement",
|
||||
description:
|
||||
"What the Raspberry Pi time-server guides get wrong, and the numbers to prove it. " +
|
||||
"GPS Stratum 1 on a Pi 4: PREEMPT_RT makes PPS jitter worse, the PPS interrupt " +
|
||||
"cannot be pinned, PTP is impossible, and your dashboard is taxing your clock.",
|
||||
|
||||
// The mark IS the word "cuckoo" — a rebus. replacesTitle stops Starlight
|
||||
// rendering the title text beside it (which would read "…escapement The
|
||||
// Cuckoo Escapement"). The SVG's aria-label carries the full name.
|
||||
logo: { src: "./src/assets/logo.svg", replacesTitle: true },
|
||||
favicon: "/favicon.svg",
|
||||
customCss: ["./src/styles/brass.css"],
|
||||
|
||||
social: [
|
||||
{
|
||||
icon: "seti:git",
|
||||
label: "Source",
|
||||
href: "https://git.supported.systems/warehack.ing/cuckoo-escapement",
|
||||
},
|
||||
],
|
||||
|
||||
// Diátaxis, but weighted for a field report. Explanation leads, because the
|
||||
// whole point is WHY the received wisdom is wrong. There is no Tutorial —
|
||||
// that would be the one thing the world does not need another of.
|
||||
// NB: Starlight >=0.39 removed the inline {label, autogenerate} shorthand;
|
||||
// it must be nested as {label, items: [{autogenerate}]}.
|
||||
sidebar: [
|
||||
{
|
||||
label: "Start here",
|
||||
items: [
|
||||
{ label: "What this is (and isn't)", slug: "index" },
|
||||
{ label: "The findings, in brief", slug: "findings" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Explanation",
|
||||
items: [{ autogenerate: { directory: "explanation" } }],
|
||||
},
|
||||
{
|
||||
label: "Reference",
|
||||
items: [{ autogenerate: { directory: "reference" } }],
|
||||
},
|
||||
{
|
||||
label: "How-to",
|
||||
items: [{ autogenerate: { directory: "how-to" } }],
|
||||
},
|
||||
],
|
||||
|
||||
components: {
|
||||
// Appends the "A Supported Systems Joint" badge under Starlight's default
|
||||
// footer, without rewriting the component.
|
||||
Footer: "./src/components/Footer.astro",
|
||||
},
|
||||
|
||||
plugins: [
|
||||
starlightLinksValidator({
|
||||
// Broken internal links fail the build rather than shipping silently.
|
||||
errorOnRelativeLinks: false,
|
||||
}),
|
||||
],
|
||||
|
||||
pagination: true,
|
||||
lastUpdated: true,
|
||||
}),
|
||||
mdx(),
|
||||
sitemap(),
|
||||
],
|
||||
});
|
||||
73
docs-site/docker-compose.yml
Normal file
73
docs-site/docker-compose.yml
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
# cuckoo-escapement docs site — two profiles.
|
||||
#
|
||||
# Default (no --profile flag):
|
||||
# prod-style — Caddy serves the built dist/. Use for production-like
|
||||
# deploys (the public site at cuckoo.warehack.ing runs this).
|
||||
#
|
||||
# --profile dev:
|
||||
# Astro dev server with HMR. Volume mounts on src/ so edits hot-reload.
|
||||
# The Vite HMR WebSocket is configured in astro.config.mjs to work
|
||||
# behind the caddy-docker-proxy TLS-terminating front-end — see the
|
||||
# `caddy.reverse_proxy.*` labels below for the WebSocket-friendly
|
||||
# timeout configuration.
|
||||
#
|
||||
# Both services attach to the external `caddy` network and expose
|
||||
# themselves to caddy-docker-proxy via labels. Edit DOMAIN in .env to
|
||||
# switch between cuckoo.warehack.ing (prod) and cuckoo.l.warehack.ing
|
||||
# (local-dev tier).
|
||||
|
||||
services:
|
||||
docs:
|
||||
profiles: ["prod", ""]
|
||||
build:
|
||||
context: .
|
||||
target: prod
|
||||
image: cuckoo-escapement-docs:prod
|
||||
container_name: cuckoo-escapement-docs
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- caddy
|
||||
labels:
|
||||
caddy: ${DOMAIN:-cuckoo.warehack.ing}
|
||||
caddy.reverse_proxy: "{{upstreams 80}}"
|
||||
# encode + gzip already in the container; let caddy pass through.
|
||||
|
||||
docs-dev:
|
||||
profiles: ["dev"]
|
||||
build:
|
||||
context: .
|
||||
target: dev
|
||||
image: cuckoo-escapement-docs:dev
|
||||
container_name: cuckoo-escapement-docs-dev
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- DOMAIN=${DOMAIN:-cuckoo.l.warehack.ing}
|
||||
- ASTRO_TELEMETRY_DISABLED=1
|
||||
volumes:
|
||||
# Hot-reload bind mounts. node_modules stays inside the container
|
||||
# so host platform mismatches don't break native deps.
|
||||
- ./astro.config.mjs:/app/astro.config.mjs:ro
|
||||
- ./tsconfig.json:/app/tsconfig.json:ro
|
||||
- ./src:/app/src
|
||||
- ./public:/app/public
|
||||
networks:
|
||||
- caddy
|
||||
labels:
|
||||
caddy: ${DOMAIN:-cuckoo.l.warehack.ing}
|
||||
caddy.reverse_proxy: "{{upstreams 4321}}"
|
||||
# Vite HMR over WebSocket. Caddy's defaults close "idle" WS
|
||||
# connections after ~10-15s; HMR doesn't send app-level pings, so
|
||||
# we need explicit long-lived timeouts. Required for Caddy 2.10+
|
||||
# (HTTP/2 WS fix). See ~/.claude/references/web-frontend.md.
|
||||
caddy.reverse_proxy.flush_interval: "-1"
|
||||
caddy.reverse_proxy.transport: "http"
|
||||
caddy.reverse_proxy.transport.read_timeout: "0"
|
||||
caddy.reverse_proxy.transport.write_timeout: "0"
|
||||
caddy.reverse_proxy.transport.keepalive: "5m"
|
||||
caddy.reverse_proxy.transport.keepalive_idle_conns: "10"
|
||||
caddy.reverse_proxy.stream_timeout: "24h"
|
||||
caddy.reverse_proxy.stream_close_delay: "5s"
|
||||
|
||||
networks:
|
||||
caddy:
|
||||
external: true
|
||||
6461
docs-site/package-lock.json
generated
Normal file
6461
docs-site/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
20
docs-site/package.json
Normal file
20
docs-site/package.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"name": "cuckoo-escapement-docs",
|
||||
"type": "module",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "astro dev --host 0.0.0.0",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview --host 0.0.0.0",
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/mdx": "^5.0.4",
|
||||
"@astrojs/sitemap": "^3.7.2",
|
||||
"@astrojs/starlight": "^0.39.2",
|
||||
"astro": "^6.3.1",
|
||||
"sharp": "^0.34.0",
|
||||
"starlight-links-validator": "^0.24.0"
|
||||
}
|
||||
}
|
||||
13
docs-site/public/favicon.svg
Normal file
13
docs-site/public/favicon.svg
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="The Cuckoo Escapement">
|
||||
<rect width="32" height="32" rx="6" fill="#0b0d10"/>
|
||||
<path d="M 12.67 4.99 L 14.64 1.56 L 17.36 1.56 L 19.33 4.99 L 19.77 5.14 L 23.38 3.52 L 25.59 5.12 L 25.17 9.06 L 25.44 9.43 L 29.31 10.24 L 30.15 12.84 L 27.50 15.77 L 27.50 16.23 L 30.15 19.16 L 29.31 21.76 L 25.44 22.57 L 25.17 22.94 L 25.59 26.88 L 23.38 28.48 L 19.77 26.86 L 19.33 27.01 L 17.36 30.44 L 14.64 30.44 L 12.67 27.01 L 12.23 26.86 L 8.62 28.48 L 6.41 26.88 L 6.83 22.94 L 6.56 22.57 L 2.69 21.76 L 1.85 19.16 L 4.50 16.23 L 4.50 15.77 L 1.85 12.84 L 2.69 10.24 L 6.56 9.43 L 6.83 9.06 L 6.41 5.12 L 8.62 3.52 L 12.23 5.14 Z" fill="none" stroke="#d9a441" stroke-width="1.3" stroke-linejoin="round"/>
|
||||
<circle cx="16" cy="16" r="10" fill="none" stroke="#d9a441" stroke-width="1" opacity=".7"/>
|
||||
<g fill="#e8e4dc">
|
||||
<path d="M 9 21 L 3.5 17.5 L 8.5 16 Z"/>
|
||||
<ellipse cx="14.5" cy="18" rx="6" ry="4.4"/>
|
||||
<circle cx="20.5" cy="14" r="3.5"/>
|
||||
<path d="M 23 12.2 L 29 12.4 L 23.5 14.8 Z"/>
|
||||
<path d="M 23 15.3 L 28.6 16.8 L 22.6 16.4 Z"/>
|
||||
</g>
|
||||
<circle cx="21.3" cy="13.2" r=".7" fill="#0b0d10"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
66
docs-site/public/supported-systems-logo.svg
Normal file
66
docs-site/public/supported-systems-logo.svg
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 75" height="100%" width="100%">
|
||||
<!-- Gradient Definitions -->
|
||||
<defs>
|
||||
<linearGradient id="gradient1" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="#60a5fa"></stop>
|
||||
<stop offset="50%" stop-color="#3b82f6"></stop>
|
||||
<stop offset="100%" stop-color="#2563eb"></stop>
|
||||
</linearGradient>
|
||||
<linearGradient id="gradient2" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="#93c5fd"></stop>
|
||||
<stop offset="50%" stop-color="#60a5fa"></stop>
|
||||
<stop offset="100%" stop-color="#3b82f6"></stop>
|
||||
</linearGradient>
|
||||
<linearGradient id="flowGradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#2563eb"></stop>
|
||||
<stop offset="50%" stop-color="#60a5fa"></stop>
|
||||
<stop offset="100%" stop-color="#2563eb"></stop>
|
||||
</linearGradient>
|
||||
<pattern id="circuitPattern" patternUnits="userSpaceOnUse" width="12" height="45" patternTransform="scale(1)">
|
||||
<rect width="12" height="45" fill="url(#gradient1)"></rect>
|
||||
<path d="M2,5 h8 M2,5 v5 M10,5 v10 M5,15 h5 M5,15 v10 M3,25 h7 M7,25 v10 M3,35 h4" stroke="#dbeafe" stroke-width="0.5" fill="none" opacity="0.7"></path>
|
||||
<circle cx="2" cy="5" r="1" fill="#dbeafe" opacity="0.7"></circle>
|
||||
<circle cx="10" cy="5" r="1" fill="#dbeafe" opacity="0.7"></circle>
|
||||
<circle cx="5" cy="15" r="1" fill="#dbeafe" opacity="0.7"></circle>
|
||||
<circle cx="3" cy="25" r="1" fill="#dbeafe" opacity="0.7"></circle>
|
||||
<circle cx="7" cy="35" r="1" fill="#dbeafe" opacity="0.7"></circle>
|
||||
</pattern>
|
||||
<pattern id="binaryPattern" patternUnits="userSpaceOnUse" width="12" height="35" patternTransform="scale(1)">
|
||||
<rect width="12" height="35" fill="#2563eb"></rect>
|
||||
<text x="3" y="8" font-family="monospace" font-size="3" fill="#FFFFFF" opacity="0.5">10</text>
|
||||
<text x="3" y="14" font-family="monospace" font-size="3" fill="#FFFFFF" opacity="0.5">01</text>
|
||||
<text x="3" y="20" font-family="monospace" font-size="3" fill="#FFFFFF" opacity="0.5">11</text>
|
||||
<text x="3" y="26" font-family="monospace" font-size="3" fill="#FFFFFF" opacity="0.5">00</text>
|
||||
<text x="3" y="32" font-family="monospace" font-size="3" fill="#FFFFFF" opacity="0.5">10</text>
|
||||
</pattern>
|
||||
<pattern id="punchCardPattern" patternUnits="userSpaceOnUse" width="12" height="45" patternTransform="scale(1)">
|
||||
<rect width="12" height="45" fill="#3b82f6"></rect>
|
||||
<path d="M0,5 h12 M0,10 h12 M0,15 h12 M0,20 h12 M0,25 h12 M0,30 h12 M0,35 h12 M0,40 h12" stroke="#93c5fd" stroke-width="0.2" fill="none"></path>
|
||||
<circle cx="3" cy="7" r="1" fill="#1e3a8a" opacity="0.9"></circle>
|
||||
<circle cx="9" cy="7" r="1" fill="#1e3a8a" opacity="0.9"></circle>
|
||||
<circle cx="6" cy="12" r="1" fill="#1e3a8a" opacity="0.9"></circle>
|
||||
<circle cx="3" cy="17" r="1" fill="#1e3a8a" opacity="0.9"></circle>
|
||||
<circle cx="9" cy="22" r="1" fill="#1e3a8a" opacity="0.9"></circle>
|
||||
<circle cx="6" cy="27" r="1" fill="#1e3a8a" opacity="0.9"></circle>
|
||||
<circle cx="3" cy="32" r="1" fill="#1e3a8a" opacity="0.9"></circle>
|
||||
<circle cx="9" cy="37" r="1" fill="#1e3a8a" opacity="0.9"></circle>
|
||||
</pattern>
|
||||
</defs>
|
||||
<!-- Flow lines behind bars -->
|
||||
<g opacity="0.3">
|
||||
<path d="M6,50 C20,40 40,55 48,35 C56,50 75,30 90,55" stroke="url(#flowGradient)" stroke-width="1" fill="none"></path>
|
||||
<path d="M6,60 C30,50 50,40 70,55 C80,45 90,60 90,60" stroke="url(#flowGradient)" stroke-width="1" fill="none"></path>
|
||||
</g>
|
||||
<!-- Bar chart graphic - the "towers" -->
|
||||
<g>
|
||||
<rect x="0" y="45" width="12" height="25" rx="1" ry="1" fill="url(#binaryPattern)"></rect>
|
||||
<rect x="14" y="35" width="12" height="35" rx="1" ry="1" fill="#2563eb"></rect>
|
||||
<rect x="28" y="25" width="12" height="45" rx="1" ry="1" fill="url(#circuitPattern)"></rect>
|
||||
<rect x="42" y="20" width="12" height="50" rx="1" ry="1" fill="url(#gradient2)"></rect>
|
||||
<rect x="56" y="25" width="12" height="45" rx="1" ry="1" fill="url(#punchCardPattern)"></rect>
|
||||
<rect x="70" y="35" width="12" height="35" rx="1" ry="1" fill="url(#circuitPattern)"></rect>
|
||||
<rect x="84" y="45" width="12" height="25" rx="1" ry="1" fill="#2563eb"></rect>
|
||||
<!-- Connecting glow -->
|
||||
<path d="M12,55 L14,55 M26,45 L28,45 M40,40 L42,40 M54,40 L56,40 M82,55 L84,55" stroke="#bfdbfe" stroke-width="0.8" stroke-opacity="0.6"></path>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.3 KiB |
43
docs-site/src/assets/logo.svg
Normal file
43
docs-site/src/assets/logo.svg
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 360 78" role="img" aria-label="The Cuckoo Escapement">
|
||||
<style>
|
||||
.mono { font-family: "JetBrains Mono","SF Mono",Menlo,monospace; font-weight: 700; }
|
||||
.cream { fill: #e8e4dc; }
|
||||
.gear { fill: none; stroke: #d9a441; stroke-width: 1.9; stroke-linejoin: round; }
|
||||
.rim { fill: none; stroke: #d9a441; stroke-width: 1.3; opacity: .8; }
|
||||
.tick { stroke: #d9a441; stroke-width: 1.4; opacity: .5; stroke-linecap: round; }
|
||||
.head { fill: #e8e4dc; }
|
||||
.eye { fill: #0b0d10; }
|
||||
</style>
|
||||
|
||||
<!-- one ring, two readings: gear teeth outside (escapement), hour ticks inside (dial) -->
|
||||
<path d="M 30.61 13.60 L 34.99 6.14 L 41.01 6.14 L 45.39 13.60 L 46.36 13.91 L 54.29 10.46 L 59.16 14.00 L 58.33 22.60 L 58.93 23.43 L 67.37 25.29 L 69.23 31.02 L 63.49 37.49 L 63.49 38.51 L 69.23 44.98 L 67.37 50.71 L 58.93 52.57 L 58.33 53.40 L 59.16 62.00 L 54.29 65.54 L 46.36 62.09 L 45.39 62.40 L 41.01 69.86 L 34.99 69.86 L 30.61 62.40 L 29.64 62.09 L 21.71 65.54 L 16.84 62.00 L 17.67 53.40 L 17.07 52.57 L 8.63 50.71 L 6.77 44.98 L 12.51 38.51 L 12.51 37.49 L 6.77 31.02 L 8.63 25.29 L 17.07 23.43 L 17.67 22.60 L 16.84 14.00 L 21.71 10.46 L 29.64 13.91 Z" class="gear"/>
|
||||
<circle cx="38.0" cy="38.0" r="23.5" class="rim"/>
|
||||
<g class="tick">
|
||||
<line x1="38.00" y1="15.50" x2="38.00" y2="18.50"/>
|
||||
<line x1="49.25" y1="18.51" x2="47.75" y2="21.11"/>
|
||||
<line x1="57.49" y1="26.75" x2="54.89" y2="28.25"/>
|
||||
<line x1="60.50" y1="38.00" x2="57.50" y2="38.00"/>
|
||||
<line x1="57.49" y1="49.25" x2="54.89" y2="47.75"/>
|
||||
<line x1="49.25" y1="57.49" x2="47.75" y2="54.89"/>
|
||||
<line x1="38.00" y1="60.50" x2="38.00" y2="57.50"/>
|
||||
<line x1="26.75" y1="57.49" x2="28.25" y2="54.89"/>
|
||||
<line x1="18.51" y1="49.25" x2="21.11" y2="47.75"/>
|
||||
<line x1="15.50" y1="38.00" x2="18.50" y2="38.00"/>
|
||||
<line x1="18.51" y1="26.75" x2="21.11" y2="28.25"/>
|
||||
<line x1="26.75" y1="18.51" x2="28.25" y2="21.11"/>
|
||||
</g>
|
||||
|
||||
<!-- the cuckoo, head only, beak open mid-call -->
|
||||
<g class="head">
|
||||
<!-- crest: three feathers, swept back. this is what says "bird" -->
|
||||
<path d="M 30 26 L 27 16 L 35 23 Z"/>
|
||||
<path d="M 36 23 L 36 13 L 42 22 Z"/>
|
||||
<path d="M 42 23 L 46 15 L 47 25 Z"/>
|
||||
<circle cx="37" cy="39" r="13.5"/> <!-- big round head -->
|
||||
<path d="M 47 33.5 L 62 34.5 L 48.5 40 Z"/> <!-- upper beak: SHORT + chunky -->
|
||||
<path d="M 47.5 42 L 60 46.5 L 46 44 Z"/> <!-- lower beak, gape open -->
|
||||
</g>
|
||||
<circle cx="40.5" cy="35.5" r="2.2" class="eye"/>
|
||||
|
||||
<text x="92" y="52" class="mono cream" font-size="33">escapement</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
13
docs-site/src/components/Footer.astro
Normal file
13
docs-site/src/components/Footer.astro
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
---
|
||||
// Starlight Footer override. We wrap <Default /> so all upstream behavior —
|
||||
// last-updated stamp, prev/next pagination — is preserved, then append the
|
||||
// maker's plate below it.
|
||||
//
|
||||
// Same pattern (Default + extension) used across the other warehack.ing sites,
|
||||
// which keeps Starlight upgrades cheap.
|
||||
import Default from "@astrojs/starlight/components/Footer.astro";
|
||||
import SupportedSystemsBadge from "./SupportedSystemsBadge.astro";
|
||||
---
|
||||
|
||||
<Default><slot /></Default>
|
||||
<SupportedSystemsBadge />
|
||||
47
docs-site/src/components/SupportedSystemsBadge.astro
Normal file
47
docs-site/src/components/SupportedSystemsBadge.astro
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
---
|
||||
// "A Supported Systems Joint" — the maker's plate.
|
||||
//
|
||||
// Clockmakers signed the BACKPLATE: the flat brass face of the movement that
|
||||
// only shows when you open the case. It's the part a repairer sees a century
|
||||
// later, not the part the owner looks at. A site footer is the same thing —
|
||||
// the back of the movement — so this is an engraved plate rather than a
|
||||
// marketing strip, and nothing on it moves. A signature should be quiet.
|
||||
//
|
||||
// Shared verbatim with the dashboard's footer (static HTML/CSS port, same
|
||||
// markup and class names). Styles live in src/styles/brass.css.
|
||||
---
|
||||
|
||||
<aside class="ss-plate" aria-label="Supported Systems">
|
||||
<a class="ss-plate__link" href="https://supported.systems" rel="noopener">
|
||||
<img
|
||||
class="ss-plate__logo"
|
||||
src="/supported-systems-logo.svg"
|
||||
alt=""
|
||||
width="52"
|
||||
height="39"
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
<span class="ss-plate__copy">
|
||||
<span class="ss-plate__heading">A Supported Systems Joint</span>
|
||||
<span class="ss-plate__body">
|
||||
The Cuckoo Escapement is built and maintained by
|
||||
<span class="ss-plate__name">Supported Systems</span> — a boutique
|
||||
software studio focused on thoughtful, user-first technology. We measure
|
||||
things before we believe them.
|
||||
</span>
|
||||
<span class="ss-plate__cta">
|
||||
Visit supported.systems
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
|
||||
<path
|
||||
d="M4 8h7M8 5l3 3-3 3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
</aside>
|
||||
8
docs-site/src/content.config.ts
Normal file
8
docs-site/src/content.config.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// Starlight content collection — required for content.config.ts in Astro 6.x.
|
||||
import { defineCollection } from "astro:content";
|
||||
import { docsLoader } from "@astrojs/starlight/loaders";
|
||||
import { docsSchema } from "@astrojs/starlight/schema";
|
||||
|
||||
export const collections = {
|
||||
docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }),
|
||||
};
|
||||
51
docs-site/src/content/docs/explanation/cpu0-is-sacred.md
Normal file
51
docs-site/src/content/docs/explanation/cpu0-is-sacred.md
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
---
|
||||
title: cpu0 is sacred
|
||||
description: The CPU map this box lives by — discovered by measurement, not designed.
|
||||
sidebar:
|
||||
order: 6
|
||||
---
|
||||
|
||||
This is the layout the machine ended up with:
|
||||
|
||||
```
|
||||
cpu0 ──── PPS interrupt. Nothing else. Ever.
|
||||
cpu1 ──── web tier (dashboard, Caddy)
|
||||
cpu2 ──── chronyd (isolated)
|
||||
cpu3 ──── gpsd + UART IRQ thread (isolated)
|
||||
```
|
||||
|
||||
Not one of those four lines came from a guide. Each came from a measurement that
|
||||
contradicted an assumption.
|
||||
|
||||
- **cpu0 holds the PPS interrupt** because the Pi 4's GPIO mux
|
||||
[physically refuses to move it](/explanation/the-interrupt-you-cannot-move/).
|
||||
That isn't a preference; it's a constraint we cannot configure away.
|
||||
- **cpu2 and cpu3 are isolated** (`isolcpus=2,3`) for the timing daemons, which
|
||||
want determinism more than throughput.
|
||||
- **cpu1 got the web tier** only after a benchmark caught it
|
||||
[taxing the clock 36% from cpu0](/explanation/the-observer-effect/).
|
||||
|
||||
## The rule this implies
|
||||
|
||||
Because the PPS interrupt cannot be relocated, **cpu0 is load-bearing for
|
||||
precision in a way no other core is**. Anything you schedule there is competing
|
||||
directly with the timestamp.
|
||||
|
||||
So: every service on this box — an exporter, a log shipper, a backup job, a cron
|
||||
entry, anything you add six months from now — belongs on cpu1–3.
|
||||
|
||||
```ini
|
||||
[Service]
|
||||
CPUAffinity=1
|
||||
Nice=10
|
||||
```
|
||||
|
||||
It's a one-line tax, and the alternative is a slow, invisible erosion of the one
|
||||
number the machine exists to produce.
|
||||
|
||||
:::note[Isolation alone did nothing]
|
||||
Worth saying plainly: `isolcpus` on its own did **not** improve PPS jitter, and
|
||||
plausibly made it worse — by evacuating cpu2/3, we concentrated *everything else*
|
||||
onto cpu0/1, which is where the PPS interrupt lives. Isolation only pays once you
|
||||
also keep the evacuated work away from the PPS core.
|
||||
:::
|
||||
52
docs-site/src/content/docs/explanation/no-ptp-on-a-pi-4.md
Normal file
52
docs-site/src/content/docs/explanation/no-ptp-on-a-pi-4.md
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
---
|
||||
title: Why PTP is off the table on a Pi 4
|
||||
description: PTP's entire value is hardware timestamping. The Pi 4's NIC has no PTP hardware clock. Software PTP is a worse NTP.
|
||||
sidebar:
|
||||
order: 3
|
||||
---
|
||||
|
||||
The reference builds all reach for **PTP** (IEEE 1588), and they're right to: on
|
||||
the right hardware it's dramatically better than NTP.
|
||||
|
||||
The Pi 4 is not the right hardware. One command settles it:
|
||||
|
||||
```console
|
||||
$ ethtool -T eth0
|
||||
Capabilities:
|
||||
software-transmit
|
||||
software-receive
|
||||
software-system-clock
|
||||
PTP Hardware Clock: none
|
||||
Hardware Transmit Timestamp Modes: none
|
||||
Hardware Receive Filter Modes: none
|
||||
```
|
||||
|
||||
**`PTP Hardware Clock: none`.** There isn't one. There's no `/dev/ptp0` to open.
|
||||
|
||||
## Why that's fatal rather than inconvenient
|
||||
|
||||
PTP's whole advantage is **hardware timestamping**: the network card itself
|
||||
stamps the packet as it crosses the wire, in silicon, outside the operating
|
||||
system. That's what removes kernel scheduling, driver latency, and queueing from
|
||||
the measurement, and it's why PTP reaches nanoseconds where NTP reaches
|
||||
microseconds.
|
||||
|
||||
Take the hardware clock away and PTP is just... a protocol. Software-timestamped
|
||||
PTP has the packets stamped by the *kernel*, on the *CPU*, subject to exactly the
|
||||
scheduling jitter you were trying to escape. It is a more complicated NTP with
|
||||
worse tooling.
|
||||
|
||||
## But the guides say the Pi 4's PHY supports PTP
|
||||
|
||||
They do, and the *chip* does — the BCM54213PE PHY has PTP capability on paper.
|
||||
It doesn't matter. The Pi 4's `bcmgenet` MAC driver doesn't expose a PHC, so
|
||||
Linux has nothing to give you. And the reference builds that make PTP work feed
|
||||
the PPS into the NIC through a **SYNC pin that only the CM4/CM5 break out** — a
|
||||
regular Pi 4 board doesn't route it anywhere you can reach.
|
||||
|
||||
## So don't chase it
|
||||
|
||||
We spent real time on this before running `ethtool -T`, which we should have run
|
||||
first. If your board reports `PTP Hardware Clock: none`, close the tab. Put the
|
||||
effort into the PPS path instead — that's where the nanoseconds actually are, and
|
||||
[it needs the help](/explanation/preempt-rt-made-it-worse/).
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
---
|
||||
title: Why PREEMPT_RT made it worse
|
||||
description: The realtime kernel force-threads interrupt handlers. The PPS driver takes its timestamp inside its handler. Those two facts multiply badly.
|
||||
sidebar:
|
||||
order: 1
|
||||
---
|
||||
|
||||
Installing a realtime kernel is the prestige move in every Pi time-server guide.
|
||||
It is also, on its own, the single most damaging thing we did.
|
||||
|
||||
| | raw PPS jitter (σ) | peak-to-peak |
|
||||
|---|---|---|
|
||||
| Stock kernel | 2134 ns | 11 µs |
|
||||
| **PREEMPT_RT** | **6947 ns** | **38 µs** |
|
||||
|
||||
Three times worse. Not marginally, not within noise — **three times.**
|
||||
|
||||
## Why
|
||||
|
||||
PREEMPT_RT achieves its determinism by **force-threading interrupt handlers**.
|
||||
Instead of running in hard-IRQ context (immediately, uninterruptibly, nanoseconds
|
||||
after the electrical edge), a handler becomes a schedulable kernel thread that
|
||||
the scheduler runs *when it gets around to it*.
|
||||
|
||||
For most drivers this is a good trade: you lose a little latency, you gain the
|
||||
ability to preempt long-running handlers, and the *worst case* improves. That's
|
||||
the entire pitch of realtime Linux, and it's a good pitch.
|
||||
|
||||
But look at what `pps-gpio` actually does in its handler:
|
||||
|
||||
```c
|
||||
static irqreturn_t pps_gpio_irq_handler(int irq, void *data)
|
||||
{
|
||||
...
|
||||
pps_get_ts(&ts); /* ← THE TIMESTAMP IS TAKEN HERE */
|
||||
pps_event(info->pps, &ts, ...);
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**The handler is the measurement.** `pps_get_ts()` is the whole point of the
|
||||
driver — it captures *when the pulse arrived*. Everything downstream, every
|
||||
nanosecond of accuracy chrony reports, descends from that one call.
|
||||
|
||||
So when PREEMPT_RT threads this handler, it doesn't defer some *work*. It defers
|
||||
**the act of looking at the clock**. The timestamp is no longer taken at the
|
||||
electrical edge; it's taken after thread-wakeup latency — microseconds later, and
|
||||
*variably* later, which is worse.
|
||||
|
||||
We didn't make the system more deterministic. We inserted a scheduler between the
|
||||
pulse and the clock.
|
||||
|
||||
## You can see it happen
|
||||
|
||||
On a stock kernel, the PPS interrupt has no thread at all:
|
||||
|
||||
```console
|
||||
$ ps -eo pid,class,rtprio,psr,comm | grep irq/41
|
||||
(nothing — it runs in hard-irq context)
|
||||
```
|
||||
|
||||
Boot PREEMPT_RT and it materialises:
|
||||
|
||||
```console
|
||||
$ ps -eo pid,class,rtprio,psr,comm | grep irq/41
|
||||
239 RR 50 3 irq/41-pps@12.-1
|
||||
```
|
||||
|
||||
That thread is the problem. It is also — and this is the cruel part — *exactly
|
||||
what the guides tell you to go and pin to an isolated core.* You can only
|
||||
`taskset` a thread. The advice to isolate the PPS IRQ **requires** the very
|
||||
threading that destroys the timestamp.
|
||||
|
||||
<div />
|
||||
|
||||
:::danger[The trap, stated plainly]
|
||||
**You can pin the PPS interrupt, or you can timestamp it fast. You cannot do
|
||||
both.** Threading is the price of pinning, and on our board that price was 12×
|
||||
the accuracy. A hard-IRQ handler on a *busy* CPU 0 beat a threaded-and-pinned one
|
||||
on a *quiet, isolated* CPU 3 — by a mile.
|
||||
:::
|
||||
|
||||
## The fix
|
||||
|
||||
Tell the kernel this particular handler must not be threaded:
|
||||
|
||||
```c
|
||||
flags |= IRQF_NO_THREAD;
|
||||
```
|
||||
|
||||
That's it. The timestamp goes back to hard-IRQ context, at the electrical edge,
|
||||
while the rest of the system keeps every benefit of PREEMPT_RT.
|
||||
|
||||
| | RMS offset | raw PPS jitter |
|
||||
|---|---|---|
|
||||
| Stock kernel | 440 ns | 2134 ns |
|
||||
| PREEMPT_RT (unpatched) | 2468 ns | 6947 ns |
|
||||
| **PREEMPT_RT + `IRQF_NO_THREAD`** | **199 ns** | 2568 ns |
|
||||
|
||||
The patch is four lines and it's [here](/reference/the-patch/). It is, as far as
|
||||
we can tell, not applied anywhere — which means **anyone running GPIO-based PPS
|
||||
on a realtime kernel today is silently eating microseconds of jitter** and has no
|
||||
reason to suspect it, because everything *looks* fine. chrony still says Stratum 1.
|
||||
The dashboard still says locked. The number is just quietly worse.
|
||||
|
||||
## The lesson underneath
|
||||
|
||||
The realtime kernel is not "the fast kernel." It is the *predictable* kernel, and
|
||||
it buys predictability by making things schedulable. If the thing you care about
|
||||
is **a measurement taken inside an interrupt handler**, making it schedulable is
|
||||
precisely the wrong move.
|
||||
|
||||
Nothing about that is obvious from the outside. It is only obvious from a number.
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
---
|
||||
title: The interrupt you cannot move
|
||||
description: On a Pi 4, GPIO interrupts are demuxed through pinctrl-bcm2835 and refuse an smp_affinity. The IRQ-isolation advice is unachievable here.
|
||||
sidebar:
|
||||
order: 2
|
||||
---
|
||||
|
||||
Every guide says the same thing: park the PPS interrupt on its own isolated CPU,
|
||||
give it realtime priority, and keep the noisy world away from it.
|
||||
|
||||
On a Raspberry Pi 4, **you cannot.**
|
||||
|
||||
```console
|
||||
$ echo 3 > /proc/irq/41/smp_affinity_list
|
||||
tee: /proc/irq/41/smp_affinity_list: Operation not permitted
|
||||
```
|
||||
|
||||
## Why
|
||||
|
||||
Your PPS arrives on a **GPIO pin**, and GPIO interrupts on the BCM2711 are not
|
||||
first-class interrupts. They are **demultiplexed** through the GPIO controller:
|
||||
|
||||
```console
|
||||
$ grep -E 'pps|uart' /proc/interrupts
|
||||
40: 3532866 0 0 0 GICv2 153 Level uart-pl011
|
||||
41: 104835 0 0 0 pinctrl-bcm2835 18 Edge pps@12.-1
|
||||
```
|
||||
|
||||
Look at the difference. The UART is a **GICv2** interrupt — a real line into the
|
||||
interrupt controller, and it takes an affinity happily. The PPS is a
|
||||
**`pinctrl-bcm2835`** interrupt — one of dozens of GPIO lines multiplexed behind
|
||||
a single parent IRQ. There is no per-line steering to give. Every GPIO interrupt
|
||||
lands wherever the GPIO controller's parent lands, together.
|
||||
|
||||
So the PPS interrupt goes where it goes, and no amount of configuration moves it.
|
||||
|
||||
## The cruel bit
|
||||
|
||||
There *is* one way to gain control of it: **PREEMPT_RT force-threads interrupt
|
||||
handlers**, and a thread can be `taskset` anywhere. Boot a realtime kernel and the
|
||||
thing you couldn't pin becomes pinnable:
|
||||
|
||||
```console
|
||||
$ ps -eo pid,class,rtprio,psr,comm | grep irq/41
|
||||
239 RR 50 3 irq/41-pps@12.-1 ← RT priority, isolated CPU 3. It worked!
|
||||
```
|
||||
|
||||
The guides are vindicated. Except it's a trap, because
|
||||
[threading the handler is what destroys the
|
||||
timestamp](/explanation/preempt-rt-made-it-worse/) — `pps-gpio` takes its
|
||||
measurement *inside* that handler, so putting it behind the scheduler costs more
|
||||
than the isolation ever gives back.
|
||||
|
||||
:::danger[Pin it, or timestamp it fast. Not both.]
|
||||
- **Threaded + pinned to a quiet isolated core:** RMS offset **2468 ns**
|
||||
- **Hard-IRQ + unpinned on a busy CPU 0:** RMS offset **199 ns**
|
||||
|
||||
The fast handler on the *noisy* core beat the scheduled handler on the *quiet*
|
||||
core by more than 12×. Interrupt latency dominates CPU contention, and it isn't
|
||||
close.
|
||||
:::
|
||||
|
||||
## What to do instead
|
||||
|
||||
Accept that the PPS interrupt lives on CPU 0, and then **treat CPU 0 as sacred**.
|
||||
You can't move the interrupt, but you can move *everything else*:
|
||||
|
||||
```ini
|
||||
# every other service gets an affinity that isn't 0
|
||||
[Service]
|
||||
CPUAffinity=1
|
||||
```
|
||||
|
||||
That's the whole strategy. It's not the one in the guides, but it's the one the
|
||||
hardware permits. [→ cpu0 is sacred](/explanation/cpu0-is-sacred/)
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
---
|
||||
title: The observer effect
|
||||
description: Our monitoring dashboard cost 36% more PPS jitter. The instrument was bending the measurement.
|
||||
sidebar:
|
||||
order: 5
|
||||
---
|
||||
|
||||
We built a status dashboard for the time server. Then somebody asked the obvious
|
||||
question nobody asks: **is the dashboard hurting the clock?**
|
||||
|
||||
It was. By 36%.
|
||||
|
||||
## The measurement
|
||||
|
||||
A/B/A, sixty-two seconds of raw `ppstest` per round, with the middle round as the
|
||||
control and the third to prove it wasn't drift:
|
||||
|
||||
| Round | Dashboard | PPS jitter (σ) | peak-to-peak |
|
||||
|---|---|---|---|
|
||||
| 1 | **on** | 1912 ns | 10252 ns |
|
||||
| 2 | **off** | **1304 ns** | **6914 ns** |
|
||||
| 3 | **on** | 2179 ns | 11683 ns |
|
||||
|
||||
Round 3 reproduces round 1. It's real.
|
||||
|
||||
## Why
|
||||
|
||||
Two facts, individually harmless, catastrophic together:
|
||||
|
||||
1. The collector was **forking `chronyc` four times a second** — once each for
|
||||
`tracking`, `sources`, `sourcestats`, `clients`. Process creation is one of the
|
||||
most expensive things you can ask a scheduler to do.
|
||||
|
||||
2. That churn landed on **CPU 0** — the one core the PPS interrupt is welded to
|
||||
and [cannot be moved off](/explanation/the-interrupt-you-cannot-move/).
|
||||
|
||||
The monitoring tool was standing on the neck of the thing it monitors. And it was
|
||||
invisible: every metric looked fine, chrony still said Stratum 1, the dashboard
|
||||
still said "locked". The number was just quietly worse.
|
||||
|
||||
## The fix (no timing code was touched)
|
||||
|
||||
**1. Batch chronyc into one process.** It reads commands from stdin, so one fork
|
||||
serves all three:
|
||||
|
||||
```bash
|
||||
printf 'tracking\nsources\nsourcestats\n' | chronyc -c
|
||||
```
|
||||
|
||||
Tell the outputs apart by field count: 14 = tracking, 10 = sources, 8 = sourcestats.
|
||||
|
||||
:::caution
|
||||
Passing multiple commands as **arguments** silently runs only the first.
|
||||
`chronyc -c tracking sources sourcestats` returns tracking and nothing else, with
|
||||
no error. Use stdin.
|
||||
:::
|
||||
|
||||
**2. Get off CPU 0.**
|
||||
|
||||
```ini
|
||||
[Service]
|
||||
CPUAffinity=1
|
||||
```
|
||||
|
||||
## The result
|
||||
|
||||
| | Dashboard off | Dashboard on | Penalty |
|
||||
|---|---|---|---|
|
||||
| Before | 1304 ns | 1912 / 2179 ns | **+36%** |
|
||||
| After | 1437 ns | **1169 / 1450 ns** | **none — within noise** |
|
||||
|
||||
The box running its *entire* production stack is now quieter than it was sitting
|
||||
**idle** before the fix.
|
||||
|
||||
## The general rule
|
||||
|
||||
On a machine where one core is load-bearing for precision, **every other service
|
||||
you run is a tenant on the other cores**, whether it knows it or not. And process
|
||||
creation is the loudest neighbour there is.
|
||||
|
||||
A monitoring tool must not perturb what it measures. If you have never checked
|
||||
whether yours does, you do not know that it doesn't.
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
---
|
||||
title: Where the precision actually lives
|
||||
description: NMEA labels the second. PPS carries all the accuracy. Once you internalise that, half the tuning advice evaporates.
|
||||
sidebar:
|
||||
order: 4
|
||||
---
|
||||
|
||||
A GPS receiver hands you time twice, in two completely different currencies, and
|
||||
almost every tuning mistake comes from confusing them.
|
||||
|
||||
## NMEA tells you *which* second it is
|
||||
|
||||
The receiver computes the time precisely, and then it has to **shift a text
|
||||
sentence out of a serial port**. At 9600 baud that takes hundreds of milliseconds,
|
||||
and the delay wobbles from second to second depending on how many sentences are
|
||||
enabled and what the CPU was doing.
|
||||
|
||||
Our NMEA-derived time sat **+160 ms** off, with hundreds of microseconds of noise.
|
||||
That's not the receiver being bad. That's a UART being a UART.
|
||||
|
||||
## PPS tells you *exactly when* that second began
|
||||
|
||||
The same receiver also raises a **single electrical edge** at the top of every
|
||||
second, accurate to nanoseconds. No protocol, no encoding, no serial port — just
|
||||
a voltage going high at the instant the second starts.
|
||||
|
||||
That edge is where every nanosecond of your accuracy comes from. All of it.
|
||||
|
||||
## What that means in practice
|
||||
|
||||
chrony uses them together, and the division of labour is total:
|
||||
|
||||
```
|
||||
refclock SHM 0 refid GPS ... noselect # NMEA: labels the second. Never the time source.
|
||||
refclock PPS /dev/pps0 ... lock GPS # PPS: IS the time source.
|
||||
```
|
||||
|
||||
The NMEA source is marked `noselect` — chrony is explicitly told *never to use it
|
||||
to set the clock*. Its only job is to answer "which second is this pulse?", and
|
||||
for that it merely has to be within half a second. It has an entire half-second
|
||||
of slack.
|
||||
|
||||
:::tip[The consequence that saves you a day]
|
||||
**Anything that improves NMEA and nothing else improves nothing.**
|
||||
|
||||
We raised the module's baud rate from 9600 → 115200, a 12× improvement to the
|
||||
serial path, and measured the result:
|
||||
|
||||
| | PPS offset | root dispersion |
|
||||
|---|---|---|
|
||||
| 9600 baud | −1 ns | 7.6 µs |
|
||||
| 115200 baud | −1 ns | 6.3 µs |
|
||||
|
||||
**Identical.** We'd improved the thing that doesn't carry the precision.
|
||||
|
||||
Worse: pinning gpsd to that baud later caused a
|
||||
[total GPS outage after a power cut](/how-to/survive-a-power-cut/). We nearly
|
||||
took the server down defending an optimisation worth nothing.
|
||||
:::
|
||||
|
||||
Baud rate, sentence count, SBAS, update rate — all of it lives on the NMEA side of
|
||||
the wall. Tune it if you enjoy tuning. Just don't expect the clock to notice.
|
||||
50
docs-site/src/content/docs/findings.md
Normal file
50
docs-site/src/content/docs/findings.md
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
---
|
||||
title: The findings, in brief
|
||||
description: Everything we discovered, with the numbers, on one page.
|
||||
---
|
||||
|
||||
For people who want the whole thing in ninety seconds.
|
||||
|
||||
## What we built
|
||||
|
||||
A GPS-disciplined Stratum 1 NTP server: **Raspberry Pi 4** + **BerryGPS-IMU v4**
|
||||
(u-blox CAM-M8C), PPS on GPIO18, `gpsd` + `chrony`. Final state: **RMS offset
|
||||
199 ns**, root delay ~1 ns, survives a cold power cut unattended. About $130 of
|
||||
parts, replacing an appliance that costs $1,500–$10,000.
|
||||
|
||||
## What the guides get wrong on a Pi 4
|
||||
|
||||
| Claim | Reality |
|
||||
|---|---|
|
||||
| "Use PTP for real precision" | **Impossible.** `ethtool -T eth0` → `PTP Hardware Clock: none`. No hardware timestamping exists on this NIC. |
|
||||
| "Isolate the PPS IRQ on a dedicated core" | **Not permitted.** GPIO IRQs demux through `pinctrl-bcm2835` and reject `smp_affinity`. |
|
||||
| "Install PREEMPT_RT" | **Made jitter 3× worse** until patched — it threads the handler that takes the timestamp. |
|
||||
| "Raise the GPS baud rate" | **Irrelevant.** PPS offset measured −1 ns at 9600 vs 115200. Identical. NMEA only *labels* the second. |
|
||||
|
||||
## What actually helped
|
||||
|
||||
| Change | RMS offset |
|
||||
|---|---|
|
||||
| Baseline | 823 ns |
|
||||
| chrony `filter 10` + `prefer` on the PPS refclock | 440 ns |
|
||||
| PREEMPT_RT + [`IRQF_NO_THREAD` patch](/reference/the-patch/) | **199 ns** |
|
||||
|
||||
## What we broke, and how we found it
|
||||
|
||||
Two failures that a reboot will never reveal. Only a **cold power cut** exposes
|
||||
them, which is why you must actually pull the plug:
|
||||
|
||||
1. **gpsd was being started by the dashboard.** It's socket-activated; chrony
|
||||
reads its *shared memory*, never its socket, so nothing else triggered it. The
|
||||
monitoring page was load-bearing for the time server.
|
||||
2. **Pinning gpsd's baud turned a module quirk into an outage.** The CAM-M8 keeps
|
||||
config in supercap-backed RAM and reverts to 9600 on power loss. With gpsd
|
||||
pinned to 115200 it came back talking to a module that wasn't listening: **no
|
||||
GPS at all.** Use `GPSD_OPTIONS="-n"` and let it auto-probe.
|
||||
|
||||
## And the instrument was bending the measurement
|
||||
|
||||
Our own dashboard cost **36% more PPS jitter** by forking `chronyc` four times a
|
||||
second onto CPU 0 — the one core the PPS interrupt is welded to and cannot be
|
||||
moved from. Batching it to one process and pinning it off CPU 0 erased the
|
||||
penalty entirely. [→ The observer effect](/explanation/the-observer-effect/)
|
||||
82
docs-site/src/content/docs/how-to/benchmark-pps-jitter.md
Normal file
82
docs-site/src/content/docs/how-to/benchmark-pps-jitter.md
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
---
|
||||
title: Benchmark PPS jitter honestly
|
||||
description: Measure the kernel's own pulse timestamps, run A/B/A, and don't let chrony's smoothing lie to you.
|
||||
sidebar:
|
||||
order: 3
|
||||
---
|
||||
|
||||
Every claim on this site was produced this way. If you want to disagree with us,
|
||||
disagree with these numbers.
|
||||
|
||||
## Don't use chrony's stats for this
|
||||
|
||||
`chronyc sourcestats` gives you a windowed, median-filtered, slowly-converging
|
||||
estimate. That is *exactly what you want* for disciplining a clock and *exactly
|
||||
what you don't want* for measuring a change you just made. It lags, it smooths,
|
||||
and it will happily hide a regression for several minutes.
|
||||
|
||||
Measure the kernel's PPS timestamps directly instead.
|
||||
|
||||
## The measurement
|
||||
|
||||
Each PPS assert should land exactly 1.000000000 s after the last one. The
|
||||
deviation from that is the jitter — nothing else.
|
||||
|
||||
```bash
|
||||
sudo apt install pps-tools
|
||||
|
||||
sudo timeout 62 ppstest /dev/pps0 | awk '
|
||||
/assert/ {
|
||||
split($0, a, "assert "); split(a[2], b, ","); t = b[1] + 0;
|
||||
if (prev > 0) {
|
||||
d = (t - prev - 1.0) * 1e9;
|
||||
n++; sum += d; sumsq += d*d;
|
||||
if (n == 1 || d > max) max = d;
|
||||
if (n == 1 || d < min) min = d;
|
||||
}
|
||||
prev = t
|
||||
}
|
||||
END {
|
||||
mean = sum/n; sd = sqrt(sumsq/n - mean*mean);
|
||||
printf "n=%d jitter_sd=%.0f ns p2p=%.0f ns\n", n, sd, max-min
|
||||
}'
|
||||
```
|
||||
|
||||
62 seconds gives you ~60 intervals. That's enough to see a 3× effect and not
|
||||
enough to see a 5% one — size your window to the effect you're hunting.
|
||||
|
||||
## Always run A/B/A
|
||||
|
||||
This is the part people skip, and it's the part that makes the number mean
|
||||
something.
|
||||
|
||||
A time server's behaviour drifts on the scale of minutes: the board warms up,
|
||||
satellites rise and set, the DOP changes. If you measure **A then B** and B is
|
||||
worse, you cannot distinguish "B is worse" from "the last five minutes were
|
||||
worse."
|
||||
|
||||
So measure **A → B → A**:
|
||||
|
||||
```
|
||||
round 1: feature OFF → 1304 ns
|
||||
round 2: feature ON → 1912 ns
|
||||
round 3: feature OFF → 1169 ns ← agrees with round 1. Now believe round 2.
|
||||
```
|
||||
|
||||
If the two A rounds disagree with each other by more than the A-to-B difference,
|
||||
**you have measured nothing** and you should go again with a longer window.
|
||||
|
||||
We caught our [dashboard's 36% tax](/explanation/the-observer-effect/) exactly
|
||||
this way, and we *disbelieved* two other apparent wins when the bracketing rounds
|
||||
refused to agree.
|
||||
|
||||
## Sanity check: is it even connected?
|
||||
|
||||
```console
|
||||
$ sudo ppstest /dev/pps0
|
||||
source 0 - assert 1783875773.176667184, sequence: 28
|
||||
source 0 - assert 1783875774.176667944, sequence: 29
|
||||
```
|
||||
|
||||
Sequence incrementing once a second = you have a pulse. No output = you have a
|
||||
blinking LED and a wire that goes nowhere. See [Hardware](/reference/hardware/).
|
||||
111
docs-site/src/content/docs/how-to/cross-compile-rt-kernel.md
Normal file
111
docs-site/src/content/docs/how-to/cross-compile-rt-kernel.md
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
---
|
||||
title: Cross-compile an RT kernel and deploy it to a headless Pi
|
||||
description: Build an RPi-native aarch64 PREEMPT_RT kernel on an x86 workstation in ~40 minutes, and install it so that a failed boot doesn't cost you a trip to the SD card slot.
|
||||
sidebar:
|
||||
order: 2
|
||||
---
|
||||
|
||||
Building natively on a Pi 4 takes hours. Cross-compiling on a normal workstation
|
||||
takes about forty minutes. And if you've never done it, the scary part isn't the
|
||||
build — it's that a bad kernel on a headless box means physically extracting the
|
||||
SD card. This page addresses both.
|
||||
|
||||
:::tip[Or skip it]
|
||||
We publish the built artifacts. See [Downloads](/reference/downloads/).
|
||||
:::
|
||||
|
||||
## 1. Toolchain
|
||||
|
||||
```bash
|
||||
sudo apt install crossbuild-essential-arm64 bc bison flex libssl-dev make \
|
||||
libc6-dev libncurses5-dev
|
||||
```
|
||||
|
||||
## 2. Source, matched to your running kernel
|
||||
|
||||
```bash
|
||||
git clone --depth=1 --branch rpi-6.12.y \
|
||||
https://github.com/raspberrypi/linux.git
|
||||
cd linux
|
||||
```
|
||||
|
||||
Use **Raspberry Pi's** tree, not vanilla. RPi's PREEMPT_RT is already merged in
|
||||
6.12 and the board's DT/overlays live there.
|
||||
|
||||
## 3. Configure
|
||||
|
||||
```bash
|
||||
export ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu-
|
||||
make bcm2711_defconfig # Pi 4
|
||||
scripts/config --enable PREEMPT_RT
|
||||
scripts/config --set-str LOCALVERSION "-rt-cuckoo"
|
||||
make olddefconfig
|
||||
```
|
||||
|
||||
Now apply [the patch](/how-to/patch-pps-gpio/) — this is the whole reason you're
|
||||
building a kernel rather than installing one.
|
||||
|
||||
:::note[Check that MMC and EXT4 are `=y`]
|
||||
`bcm2711_defconfig` builds the SD card driver and filesystem *into* the image, not
|
||||
as modules. That means **you don't need an initramfs** — which removes the single
|
||||
most common way a hand-built Pi kernel fails to boot.
|
||||
|
||||
```bash
|
||||
grep -E 'CONFIG_(MMC_BCM2835|EXT4_FS)=' .config # want =y, not =m
|
||||
```
|
||||
:::
|
||||
|
||||
## 4. Build
|
||||
|
||||
```bash
|
||||
make -j$(nproc) Image modules dtbs
|
||||
```
|
||||
|
||||
## 5. Stage the artifacts
|
||||
|
||||
```bash
|
||||
# Note the ABSOLUTE path. `~` does not expand inside a make variable —
|
||||
# INSTALL_MOD_PATH=~/out silently installs into a literal "~" directory
|
||||
# and you end up shipping an 80 KB tarball that contains nothing.
|
||||
make INSTALL_MOD_PATH=/home/you/out modules_install
|
||||
tar -C /home/you/out -czf rt-modules.tar.gz lib/modules/
|
||||
gzip -c arch/arm64/boot/Image > kernel-rt.img.gz
|
||||
```
|
||||
|
||||
## 6. Deploy without a rescue trip
|
||||
|
||||
The rule: **never overwrite the kernel that currently boots.**
|
||||
|
||||
```bash
|
||||
scp kernel-rt.img.gz rt-modules.tar.gz pi@host:/tmp/
|
||||
ssh pi@host
|
||||
sudo tar -C / -xzf /tmp/rt-modules.tar.gz
|
||||
zcat /tmp/kernel-rt.img.gz | sudo tee /boot/firmware/kernel-rt.img > /dev/null
|
||||
```
|
||||
|
||||
`kernel8.img` — the stock kernel — is untouched. Then add **one** line to
|
||||
`/boot/firmware/config.txt`:
|
||||
|
||||
```ini
|
||||
kernel=kernel-rt.img
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo reboot
|
||||
```
|
||||
|
||||
:::tip[The recovery path]
|
||||
If it doesn't come back: pull the SD card, mount the FAT boot partition on any
|
||||
machine, **delete that one line**, put it back. The stock kernel boots. That's the
|
||||
whole rollback — no initramfs to regenerate, no bootloader to repair, and it works
|
||||
from a Windows laptop if that's all you have.
|
||||
|
||||
Test the rollback *before* you need it.
|
||||
:::
|
||||
|
||||
## 7. Confirm
|
||||
|
||||
```console
|
||||
$ uname -a
|
||||
Linux gps-ntp 6.12.x-rt-cuckoo #1 SMP PREEMPT_RT ... aarch64
|
||||
```
|
||||
76
docs-site/src/content/docs/how-to/patch-pps-gpio.md
Normal file
76
docs-site/src/content/docs/how-to/patch-pps-gpio.md
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
---
|
||||
title: Patch pps-gpio for PREEMPT_RT
|
||||
description: Rebuild one kernel module in about a minute and get your PPS timestamp back into hard-IRQ context.
|
||||
sidebar:
|
||||
order: 1
|
||||
---
|
||||
|
||||
**Do this if:** you run PPS from a GPIO pin on a PREEMPT_RT kernel. Which is to
|
||||
say — do this if you followed any realtime-kernel time-server guide.
|
||||
[Why](/explanation/preempt-rt-made-it-worse/).
|
||||
|
||||
You do **not** need to rebuild the whole kernel. `pps-gpio` is a module.
|
||||
|
||||
## 1. Confirm you have the problem
|
||||
|
||||
```console
|
||||
$ uname -a | grep -o PREEMPT_RT
|
||||
PREEMPT_RT
|
||||
|
||||
$ ps -eo pid,class,rtprio,psr,comm | grep irq/.*pps
|
||||
239 RR 50 3 irq/41-pps@12.-1 ← the handler is a thread. That's the bug.
|
||||
```
|
||||
|
||||
If that `ps` prints nothing, your handler is already in hard-IRQ context and you
|
||||
have nothing to fix.
|
||||
|
||||
## 2. Patch
|
||||
|
||||
In your kernel source tree, `drivers/pps/clients/pps-gpio.c`, in
|
||||
`get_irqf_trigger_flags()`, just before the `return`:
|
||||
|
||||
```c
|
||||
/* The handler timestamps the pulse, so it has to run in hard-irq
|
||||
* context. Under PREEMPT_RT it would otherwise be force-threaded and
|
||||
* the timestamp taken after thread wakeup latency, adding microseconds
|
||||
* of jitter to an edge that should be good to nanoseconds.
|
||||
*/
|
||||
flags |= IRQF_NO_THREAD;
|
||||
|
||||
return flags;
|
||||
```
|
||||
|
||||
## 3. Build just the module
|
||||
|
||||
```bash
|
||||
make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- \
|
||||
M=drivers/pps/clients modules
|
||||
```
|
||||
|
||||
About a minute, versus ~40 for a full kernel.
|
||||
|
||||
## 4. Install and reboot
|
||||
|
||||
```bash
|
||||
scp drivers/pps/clients/pps-gpio.ko pi@host:/tmp/
|
||||
ssh pi@host 'sudo install -m644 /tmp/pps-gpio.ko \
|
||||
/lib/modules/$(uname -r)/kernel/drivers/pps/clients/pps-gpio.ko && \
|
||||
sudo depmod -a && sudo reboot'
|
||||
```
|
||||
|
||||
:::caution[The module must match the running kernel exactly]
|
||||
`vermagic` is checked at load. If you build against a different source tree than
|
||||
the running kernel, `modprobe` fails and — because `pps-gpio` is what creates
|
||||
`/dev/pps0` — chrony loses its refclock entirely. Build from the *same* tree that
|
||||
produced the kernel you're running, or rebuild both.
|
||||
:::
|
||||
|
||||
## 5. Verify
|
||||
|
||||
```console
|
||||
$ ps -eo pid,class,rtprio,psr,comm | grep irq/.*pps
|
||||
(nothing — back in hard-irq context)
|
||||
```
|
||||
|
||||
Then [measure it](/how-to/benchmark-pps-jitter/). You should see roughly a 3×
|
||||
improvement in raw PPS jitter, and about a 10× improvement in chrony's RMS offset.
|
||||
84
docs-site/src/content/docs/how-to/survive-a-power-cut.md
Normal file
84
docs-site/src/content/docs/how-to/survive-a-power-cut.md
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
---
|
||||
title: Survive a power cut
|
||||
description: Two failures that only a cold power-cycle finds. Both of ours were silent, and one of them was our own monitoring dashboard.
|
||||
sidebar:
|
||||
order: 4
|
||||
---
|
||||
|
||||
A time server's whole job is to be there. Ours had been up for days, Stratum 1,
|
||||
199 ns — and it would **not have survived a power outage**. Two independent bugs,
|
||||
neither visible from any amount of `systemctl status`.
|
||||
|
||||
Pull the plug. It's the only test that finds these.
|
||||
|
||||
## Failure 1: never pin the gpsd baud rate
|
||||
|
||||
Several guides tell you to reconfigure the GPS module to a higher baud rate and
|
||||
then pin gpsd to match:
|
||||
|
||||
```ini
|
||||
GPSD_OPTIONS="-n -s 115200" # ← don't
|
||||
```
|
||||
|
||||
Here's what happens. Most u-blox modules hold their config in **volatile RAM**.
|
||||
Cut the power and the module comes back at its factory **9600**. gpsd, pinned to
|
||||
115200, opens the port, talks to a device that isn't listening, and reports
|
||||
nothing. Not a degraded fix. **No GPS at all.** Your Stratum 1 server silently
|
||||
becomes a Stratum 3 client of the internet — and stays that way until a human
|
||||
notices.
|
||||
|
||||
Let gpsd auto-probe:
|
||||
|
||||
```ini
|
||||
GPSD_OPTIONS="-n"
|
||||
```
|
||||
|
||||
It sweeps the standard baud rates, finds the module wherever it landed, and comes
|
||||
back on its own.
|
||||
|
||||
:::note[And the baud change bought nothing anyway]
|
||||
We measured it: **−1 ns** difference in PPS offset between 9600 and 115200. Which
|
||||
makes sense — [the precision lives in the pulse, not the
|
||||
sentences](/explanation/where-precision-lives/). NMEA at 9600 has plenty of time
|
||||
to tell you *which* second it is. Faster serial changes nothing and costs you your
|
||||
power-cut resilience.
|
||||
:::
|
||||
|
||||
## Failure 2: your monitoring is load-bearing
|
||||
|
||||
`gpsd` ships with **socket activation**. It starts when something connects to port
|
||||
2947. And chrony never connects to port 2947 — it reads gpsd's *shared memory*.
|
||||
|
||||
So on a fresh boot, nothing starts gpsd. No gpsd, no NMEA, no `refclock SHM 0`, no
|
||||
second-numbering for the PPS pulses.
|
||||
|
||||
Ours *appeared* to work. It worked because **the dashboard** — our web status page
|
||||
— polls gpsd on 2947, and the dashboard starts at boot. The monitoring was
|
||||
socket-activating the thing it was monitoring. Stop the dashboard "to reduce load
|
||||
on the clock" and you'd have stopped the clock.
|
||||
|
||||
```console
|
||||
$ systemctl is-enabled gpsd.service
|
||||
disabled ← this is the bug
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo systemctl enable gpsd.service
|
||||
```
|
||||
|
||||
Check `gpsd.service`, not `gpsd.socket`.
|
||||
|
||||
## What a healthy recovery looks like
|
||||
|
||||
With both fixed, and a few internet NTP servers left in `chrony.conf` as a
|
||||
backstop, we cut the mains and watched:
|
||||
|
||||
| t+ | state |
|
||||
|---|---|
|
||||
| 0 s | power restored, boot |
|
||||
| ~35 s | chrony up, **Stratum 3** — leaning on internet NTP. Serving time. |
|
||||
| ~90 s | GPS fix acquired, NMEA flowing |
|
||||
| **165 s** | PPS trusted, internet sources demoted, **Stratum 1** |
|
||||
|
||||
No human involved. That middle window — serving slightly-worse time instead of no
|
||||
time — is why you keep the upstream servers configured even on a GPS clock.
|
||||
94
docs-site/src/content/docs/index.mdx
Normal file
94
docs-site/src/content/docs/index.mdx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
---
|
||||
title: What this is (and isn't)
|
||||
description: A field report on GPS Stratum 1 timekeeping on a Raspberry Pi 4 — what the guides get wrong, and the measurements that prove it.
|
||||
---
|
||||
|
||||
import { Aside, CardGrid, Card } from '@astrojs/starlight/components';
|
||||
|
||||
**This is not a build guide.**
|
||||
|
||||
Guides for building a GPS-disciplined Stratum 1 NTP server on a Raspberry Pi
|
||||
already exist. [geerlingguy/time-pi](https://github.com/geerlingguy/time-pi) and
|
||||
[josh-blake/pixie](https://github.com/josh-blake/pixie) are both good. Go read
|
||||
them. Come back when your jitter is bad.
|
||||
|
||||
This site is what happened when we followed that advice on a **Raspberry Pi 4**
|
||||
and measured everything: **most of it is wrong on this board**, one piece of it
|
||||
is wrong on *every* board, and the single change that helped most was a one-line
|
||||
kernel patch nobody has written down.
|
||||
|
||||
<Aside type="caution" title="n = 1">
|
||||
Every number here comes from **one** Raspberry Pi 4 with **one** GPS module.
|
||||
This is a field report, not a study. We're telling you what we measured, how we
|
||||
measured it, and where we were wrong — so you can check it against your own
|
||||
board rather than take our word for it. That's the whole point.
|
||||
</Aside>
|
||||
|
||||
## The short version
|
||||
|
||||
<CardGrid>
|
||||
<Card title="PREEMPT_RT made it 3× worse" icon="warning">
|
||||
The realtime kernel — the marquee upgrade — **tripled our PPS jitter**
|
||||
(2134 ns → 6947 ns). It force-threads interrupt handlers, and the PPS driver
|
||||
takes its timestamp *inside* the handler. We put a scheduler between the
|
||||
electrical edge and the clock.
|
||||
[→ Why](/explanation/preempt-rt-made-it-worse/)
|
||||
</Card>
|
||||
<Card title="You cannot pin the PPS interrupt" icon="error">
|
||||
On a Pi 4, GPIO interrupts are demuxed through `pinctrl-bcm2835` and refuse
|
||||
an `smp_affinity`. The "isolate the PPS IRQ on its own core" advice is
|
||||
**unachievable here** — and the only way to enable it is the very thing that
|
||||
costs you the accuracy.
|
||||
[→ Why](/explanation/the-interrupt-you-cannot-move/)
|
||||
</Card>
|
||||
<Card title="PTP is impossible on a Pi 4" icon="error">
|
||||
`ethtool -T eth0` → `PTP Hardware Clock: none`. There is no hardware
|
||||
timestamping. Software PTP is just a worse NTP. Don't chase it.
|
||||
[→ Why](/explanation/no-ptp-on-a-pi-4/)
|
||||
</Card>
|
||||
<Card title="Your dashboard is taxing your clock" icon="rocket">
|
||||
Ours cost **36% more PPS jitter** — by forking `chronyc` four times a second
|
||||
onto the one core the PPS interrupt is welded to. The instrument was bending
|
||||
the measurement.
|
||||
[→ Why](/explanation/the-observer-effect/)
|
||||
</Card>
|
||||
</CardGrid>
|
||||
|
||||
## What actually moved the needle
|
||||
|
||||
Almost none of the things we expected.
|
||||
|
||||
| Change | RMS offset |
|
||||
|---|---|
|
||||
| Baseline | 823 ns |
|
||||
| chrony: median `filter` + `prefer` on the PPS refclock | 440 ns |
|
||||
| PREEMPT_RT (unpatched) | **2468 ns** ← *worse* |
|
||||
| PREEMPT_RT + our `IRQF_NO_THREAD` patch | **199 ns** |
|
||||
|
||||
Baud rate, SBAS, CPU isolation, IRQ pinning: **noise, or actively harmful.**
|
||||
The full numbers and methodology are in [the measurements](/reference/measurements/).
|
||||
|
||||
## The one artifact worth stealing
|
||||
|
||||
If you take nothing else from this site, take
|
||||
[the kernel patch](/reference/the-patch/). Every person running GPIO-based PPS
|
||||
on a PREEMPT_RT kernel is, right now, silently eating microseconds of jitter and
|
||||
has no idea. It's four lines. It's upstreamable. It's the reason our RMS offset
|
||||
is 199 ns instead of 2468 ns.
|
||||
|
||||
## Why "The Cuckoo Escapement"
|
||||
|
||||
The **escapement** is the part of a mechanical clock that takes continuous energy
|
||||
and chops it into discrete, regular ticks. It's the single component that decides
|
||||
whether a clock is precise or worthless. That is *exactly* what a PPS interrupt
|
||||
handler does: it takes an electrical edge and turns it into one discrete
|
||||
timestamp. Our entire finding is that all the precision in the system lives in
|
||||
that one handler — and that the realtime kernel was putting a scheduler in front
|
||||
of it.
|
||||
|
||||
We didn't fix a time server. We fixed the escapement.
|
||||
|
||||
The **cuckoo** is the other half. The bird's whole job is to pop out and announce
|
||||
the hour; the PPS pulse's whole job is to pop out and announce the second. The
|
||||
bird *is* the pulse. And, well — everything you were told about this turned out
|
||||
to be a bit cuckoo.
|
||||
88
docs-site/src/content/docs/reference/configuration.md
Normal file
88
docs-site/src/content/docs/reference/configuration.md
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
---
|
||||
title: Final configuration
|
||||
description: The chrony, gpsd, kernel and systemd config this box actually runs.
|
||||
sidebar:
|
||||
order: 4
|
||||
---
|
||||
|
||||
## chrony
|
||||
|
||||
```ini
|
||||
# /etc/chrony/conf.d/gps.conf
|
||||
|
||||
# Coarse NMEA time from gpsd. Labels WHICH second it is. Never the time source.
|
||||
refclock SHM 0 refid GPS precision 1e-1 offset 0.0 delay 0.2 poll 3 noselect
|
||||
|
||||
# Kernel PPS: the precise source. Median-filter 10 pulses, lock to GPS for
|
||||
# second-numbering, prefer it as the reference.
|
||||
refclock PPS /dev/pps0 refid PPS precision 1e-9 poll 2 lock GPS filter 10 prefer
|
||||
|
||||
allow 10.0.0.0/8
|
||||
```
|
||||
|
||||
Plus, in `chrony.conf`:
|
||||
|
||||
```ini
|
||||
user root # needed to read gpsd's SHM segments (mode 0600, root-owned)
|
||||
```
|
||||
|
||||
:::note[Keep the internet servers]
|
||||
Leave a few upstream NTP servers configured. While the GPS cold-acquires after a
|
||||
power cut, chrony leans on them and serves *slightly less precise* time rather than
|
||||
*no* time — then demotes them the instant PPS becomes trustworthy. We watched it
|
||||
bridge a 165-second gap and promote itself back to Stratum 1 unattended. That
|
||||
graceful degradation is worth the four lines.
|
||||
:::
|
||||
|
||||
## gpsd
|
||||
|
||||
```ini
|
||||
# /etc/default/gpsd
|
||||
START_DAEMON="true"
|
||||
USBAUTO="false"
|
||||
DEVICES="/dev/ttyAMA0"
|
||||
GPSD_OPTIONS="-n" # -n = poll immediately. NEVER pin the baud with -s.
|
||||
```
|
||||
|
||||
```bash
|
||||
systemctl enable gpsd.service # NOT just gpsd.socket — see below
|
||||
```
|
||||
|
||||
:::danger[Two ways gpsd will betray you]
|
||||
1. **Socket activation isn't enough.** chrony reads gpsd's *shared memory*, never
|
||||
its socket — so nothing triggers the daemon to start. If it seems to work
|
||||
anyway, something *else* is connecting to port 2947 and starting it for you.
|
||||
For us that was the dashboard: our monitoring page was load-bearing for the
|
||||
time server. Check with `systemctl is-enabled gpsd.service`.
|
||||
|
||||
2. **Never pin the baud.** [The module reverts to 9600 on power
|
||||
loss](/how-to/survive-a-power-cut/), and a pinned gpsd then talks to a device
|
||||
that isn't listening. Let it auto-probe.
|
||||
:::
|
||||
|
||||
## Kernel cmdline
|
||||
|
||||
```
|
||||
isolcpus=2,3 irqaffinity=0,1 nohz=off cpuidle.off=1 skew_tick=1
|
||||
```
|
||||
|
||||
## systemd affinities
|
||||
|
||||
[cpu0 is sacred](/explanation/cpu0-is-sacred/) — it holds the PPS interrupt.
|
||||
|
||||
```ini
|
||||
# chrony.service.d/affinity.conf
|
||||
[Service]
|
||||
CPUSchedulingPolicy=rr
|
||||
CPUSchedulingPriority=20
|
||||
CPUAffinity=2
|
||||
|
||||
# gpsd.service.d/affinity.conf
|
||||
[Service]
|
||||
CPUAffinity=3
|
||||
|
||||
# everything else (dashboard, Caddy, exporters, cron…)
|
||||
[Service]
|
||||
CPUAffinity=1
|
||||
Nice=10
|
||||
```
|
||||
72
docs-site/src/content/docs/reference/downloads.md
Normal file
72
docs-site/src/content/docs/reference/downloads.md
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
---
|
||||
title: Downloads — prebuilt RT kernel
|
||||
description: A patched PREEMPT_RT kernel for the Raspberry Pi 4, so you don't need a cross-compile toolchain.
|
||||
sidebar:
|
||||
order: 5
|
||||
---
|
||||
|
||||
The only expensive part of [the patch](/reference/the-patch/) is the toolchain.
|
||||
Building natively on a Pi 4 takes hours; cross-compiling needs an x86 box and a
|
||||
setup session. So here's the artifact.
|
||||
|
||||
:::danger[Read this before you download]
|
||||
- **Raspberry Pi 4 / arm64 only.** `bcm2711_defconfig`. It will not boot a Pi 5 or
|
||||
a Pi 3.
|
||||
- **Unsigned, community-built.** We built this on a workstation. There is no chain
|
||||
of trust here beyond "we published the exact recipe and the checksums." If that
|
||||
isn't good enough for your environment — and for some environments it correctly
|
||||
isn't — [build it yourself](/how-to/cross-compile-rt-kernel/). It's forty
|
||||
minutes.
|
||||
- **Verify the checksums.** They're in `SHA256SUMS`.
|
||||
:::
|
||||
|
||||
## Artifacts
|
||||
|
||||
Published on the [releases page](https://git.supported.systems/warehack.ing/cuckoo-escapement/releases):
|
||||
|
||||
| File | What |
|
||||
|---|---|
|
||||
| `kernel-rt-<ver>.img.gz` | The kernel image, gzipped (Pi OS's own format) |
|
||||
| `rt-modules-<ver>.tar.gz` | Matching modules — **must** be installed with the image |
|
||||
| `install-rt-kernel.sh` | Installer. Adds a *new* image, never replaces `kernel8.img` |
|
||||
| `SHA256SUMS` | Checksums |
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
sha256sum -c SHA256SUMS
|
||||
sudo ./install-rt-kernel.sh
|
||||
sudo reboot
|
||||
```
|
||||
|
||||
The installer:
|
||||
|
||||
1. Untars the modules into `/lib/modules/`
|
||||
2. Writes the image as `/boot/firmware/kernel-rt.img` — **`kernel8.img` is left
|
||||
alone**
|
||||
3. Appends one line, `kernel=kernel-rt.img`, to `config.txt`
|
||||
|
||||
**Rollback is deleting that one line.** Mount the SD card's FAT partition on any
|
||||
machine, remove it, and the stock kernel boots. That's deliberate: you should never
|
||||
have to make a physical trip to a headless box because of a kernel you got from a
|
||||
website.
|
||||
|
||||
## What's in it
|
||||
|
||||
Raspberry Pi's `rpi-6.12.y` tree, `bcm2711_defconfig`, plus exactly two changes:
|
||||
|
||||
```bash
|
||||
scripts/config --enable PREEMPT_RT
|
||||
# + the IRQF_NO_THREAD patch in drivers/pps/clients/pps-gpio.c
|
||||
```
|
||||
|
||||
Nothing else. The full recipe is in
|
||||
[Cross-compile an RT kernel](/how-to/cross-compile-rt-kernel/), and you should be
|
||||
able to reproduce this byte-for-byte modulo build timestamps.
|
||||
|
||||
:::note[Pinned to a tested version]
|
||||
The published download always points at a kernel we have **actually booted and
|
||||
benchmarked** on a Pi 4 — not simply the newest upstream. Shipping a stranger an
|
||||
unvalidated kernel for a machine they may not be able to physically reach is not
|
||||
something we're willing to do.
|
||||
:::
|
||||
78
docs-site/src/content/docs/reference/hardware.md
Normal file
78
docs-site/src/content/docs/reference/hardware.md
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
---
|
||||
title: Hardware
|
||||
description: BerryGPS-IMU v4 / u-blox CAM-M8C, and the PPS pad that isn't on the header.
|
||||
sidebar:
|
||||
order: 3
|
||||
---
|
||||
|
||||
## The board
|
||||
|
||||
**BerryGPS-IMU v4** (Ozzmaker) on a **Raspberry Pi 4**. The GPS is a **u-blox
|
||||
CAM-M8C** — 72-channel M8 engine, concurrent GPS/GLONASS/Galileo/BeiDou, with an
|
||||
onboard antenna and a uFL connector for an external one.
|
||||
|
||||
Reported by the module itself:
|
||||
|
||||
```console
|
||||
$ ubxtool -p MON-VER
|
||||
swVersion ROM CORE 3.01 (107888)
|
||||
hwVersion 00080000 # M8 generation
|
||||
extension FWVER=SPG 3.01
|
||||
extension PROTVER=18.00
|
||||
extension GPS;GLO;GAL;BDS
|
||||
```
|
||||
|
||||
## The PPS pin is not on the header
|
||||
|
||||
This costs people hours, so: **the BerryGPS-IMU's PPS is not wired to any GPIO.**
|
||||
|
||||
The board's normal header connection carries power, the GPS UART (GPIO14/15), and
|
||||
the IMU's I²C — but the timepulse comes out of a **separate `T_PULSE` pad**, and
|
||||
you have to run a wire from it yourself.
|
||||
|
||||
The schematic confirms it: the CAM-M8C's TIMEPULSE pin goes through a 2N2222
|
||||
buffer that drives both the on-board **PPS LED** and the `T_PULSE` pad. Nothing
|
||||
routes it to the Pi.
|
||||
|
||||
:::caution[The blinking LED lies to you]
|
||||
The PPS LED blinks once a second as soon as the module has a fix — **whether or
|
||||
not the pulse is connected to anything**. It tells you the module is generating
|
||||
PPS. It tells you nothing about whether your Pi can see it.
|
||||
|
||||
We swept every plausible GPIO with interrupt-driven edge detection and found
|
||||
nothing, while the LED blinked away merrily. The signal existed; it just had
|
||||
nowhere to go.
|
||||
:::
|
||||
|
||||
We soldered a jumper from **`T_PULSE` → GPIO18** (physical pin 12), then:
|
||||
|
||||
```ini
|
||||
# /boot/firmware/config.txt
|
||||
dtoverlay=pps-gpio,gpiopin=18
|
||||
```
|
||||
|
||||
Verify with a hardware-timestamped check, not a polling loop — a 100 ms pulse is
|
||||
easy to miss by polling:
|
||||
|
||||
```console
|
||||
$ sudo ppstest /dev/pps0
|
||||
source 0 - assert 1783875773.176667184, sequence: 28
|
||||
source 0 - assert 1783875774.176667944, sequence: 29 # 1.000000760 s later
|
||||
```
|
||||
|
||||
## The UART needs freeing first
|
||||
|
||||
The Pi 4's *good* UART (PL011) is wired to **Bluetooth** by default; GPIO14/15 get
|
||||
the flaky mini-UART whose baud drifts with the CPU clock. And a serial console may
|
||||
be sitting on the port. Both must go:
|
||||
|
||||
```ini
|
||||
# /boot/firmware/config.txt
|
||||
enable_uart=1
|
||||
dtoverlay=disable-bt
|
||||
```
|
||||
|
||||
```ini
|
||||
# /boot/firmware/cmdline.txt — remove this:
|
||||
console=serial0,115200
|
||||
```
|
||||
75
docs-site/src/content/docs/reference/measurements.md
Normal file
75
docs-site/src/content/docs/reference/measurements.md
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
---
|
||||
title: The measurements
|
||||
description: Every number on this site, with the methodology that produced it. Check our work.
|
||||
sidebar:
|
||||
order: 2
|
||||
---
|
||||
|
||||
All numbers from **one** Raspberry Pi 4 + BerryGPS-IMU v4 (u-blox CAM-M8C), PPS on
|
||||
GPIO18. n = 1. Check them against your own board.
|
||||
|
||||
## Headline progression
|
||||
|
||||
| Change | RMS offset | Root dispersion |
|
||||
|---|---|---|
|
||||
| Baseline (stock kernel, stock chrony) | 823 ns | 16.8 µs |
|
||||
| chrony `filter 10` + `prefer` on PPS refclock | 440 ns | 5 µs |
|
||||
| PREEMPT_RT, unpatched | **2468 ns** ← *worse* | 11.6 µs |
|
||||
| **PREEMPT_RT + [`IRQF_NO_THREAD`](/reference/the-patch/)** | **199 ns** | 6.3 µs |
|
||||
|
||||
## Raw PPS jitter (kernel-timestamped)
|
||||
|
||||
| Kernel | jitter (σ) | peak-to-peak |
|
||||
|---|---|---|
|
||||
| Stock | 2134 ns | 11 µs |
|
||||
| PREEMPT_RT (threaded handler) | 6947 ns | 38 µs |
|
||||
| PREEMPT_RT + patch (hard-irq handler) | 2568 ns | 18 µs |
|
||||
|
||||
## The dashboard's tax
|
||||
|
||||
A/B/A, 62 s per round. [Why this matters](/explanation/the-observer-effect/).
|
||||
|
||||
| | Dashboard off | Dashboard on |
|
||||
|---|---|---|
|
||||
| Before fix | 1304 ns | 1912 / 2179 ns (**+36%**) |
|
||||
| After fix | 1437 ns | 1169 / 1450 ns (**no penalty**) |
|
||||
|
||||
## Things that did nothing
|
||||
|
||||
| Change | Result |
|
||||
|---|---|
|
||||
| Baud 9600 → 115200 | PPS offset **−1 ns** either way. Identical. |
|
||||
| SBAS disabled | No measurable change to PPS. |
|
||||
| `isolcpus` alone | Inconclusive-to-harmful (concentrates load onto the PPS core). |
|
||||
|
||||
## Methodology
|
||||
|
||||
**Don't trust chrony's own stats for this.** `chronyc sourcestats` reports a
|
||||
windowed, median-filtered figure that lags reality and hides what you're trying to
|
||||
see. Measure the kernel's PPS timestamps directly:
|
||||
|
||||
```bash
|
||||
sudo timeout 62 ppstest /dev/pps0 | awk '
|
||||
/assert/ {
|
||||
split($0, a, "assert "); split(a[2], b, ","); t = b[1] + 0;
|
||||
if (prev > 0) {
|
||||
d = (t - prev - 1.0) * 1e9; # deviation from exactly 1.000000000 s, in ns
|
||||
n++; sum += d; sumsq += d*d;
|
||||
if (d > max || n == 1) max = d;
|
||||
if (d < min || n == 1) min = d;
|
||||
}
|
||||
prev = t
|
||||
}
|
||||
END {
|
||||
mean = sum/n; sd = sqrt(sumsq/n - mean*mean);
|
||||
printf "n=%d jitter_sd=%.0f ns p2p=%.0f ns\n", n, sd, max-min
|
||||
}'
|
||||
```
|
||||
|
||||
Each pulse should be exactly 1.000000000 s after the last. The deviation *is* the
|
||||
jitter.
|
||||
|
||||
**Always run A/B/A**, never A/B. Clock behaviour drifts on the scale of minutes;
|
||||
if you measure on-then-off you cannot tell a real effect from thermal drift or a
|
||||
satellite geometry change. Go on → off → on, and require the two "on" rounds to
|
||||
agree before you believe the middle one.
|
||||
65
docs-site/src/content/docs/reference/the-patch.md
Normal file
65
docs-site/src/content/docs/reference/the-patch.md
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
---
|
||||
title: The kernel patch
|
||||
description: Four lines that keep the PPS timestamp in hard-IRQ context under PREEMPT_RT. The single most valuable artifact here.
|
||||
sidebar:
|
||||
order: 1
|
||||
---
|
||||
|
||||
If you take one thing from this site, take this.
|
||||
|
||||
```c
|
||||
--- a/drivers/pps/clients/pps-gpio.c
|
||||
+++ b/drivers/pps/clients/pps-gpio.c
|
||||
@@ -156,6 +156,13 @@ get_irqf_trigger_flags(const struct pps_gpio_device_data *data)
|
||||
IRQF_TRIGGER_FALLING : IRQF_TRIGGER_RISING);
|
||||
}
|
||||
|
||||
+ /* The handler timestamps the pulse, so it has to run in hard-irq
|
||||
+ * context. Under PREEMPT_RT it would otherwise be force-threaded and
|
||||
+ * the timestamp taken after thread wakeup latency, adding microseconds
|
||||
+ * of jitter to an edge that should be good to nanoseconds.
|
||||
+ */
|
||||
+ flags |= IRQF_NO_THREAD;
|
||||
+
|
||||
return flags;
|
||||
}
|
||||
```
|
||||
|
||||
## What it does
|
||||
|
||||
`pps-gpio` requests its interrupt with only the trigger flags — no
|
||||
`IRQF_NO_THREAD`. On a stock kernel that's fine, because handlers run in hard-IRQ
|
||||
context anyway. Under **PREEMPT_RT**, the kernel force-threads it, and since
|
||||
[the handler is where the timestamp is taken](/explanation/preempt-rt-made-it-worse/),
|
||||
the measurement moves behind the scheduler.
|
||||
|
||||
`IRQF_NO_THREAD` tells the kernel: *not this one*. The handler stays in hard-IRQ
|
||||
context; everything else keeps RT's preemptibility.
|
||||
|
||||
## What it's worth
|
||||
|
||||
| | RMS offset | raw PPS jitter |
|
||||
|---|---|---|
|
||||
| PREEMPT_RT, unpatched | 2468 ns | 6947 ns |
|
||||
| **PREEMPT_RT, patched** | **199 ns** | 2568 ns |
|
||||
|
||||
## Why you probably need it
|
||||
|
||||
As far as we can tell this is not applied anywhere. Which means **every person
|
||||
running GPIO-based PPS on a PREEMPT_RT kernel is, right now, silently eating
|
||||
microseconds of jitter** — and has no reason to suspect it, because nothing looks
|
||||
broken. chrony still reports Stratum 1. The dashboard still says locked. The
|
||||
number is just quietly, invisibly worse.
|
||||
|
||||
If that's you, this patch is free accuracy.
|
||||
|
||||
:::note[Upstreamable]
|
||||
This belongs upstream, not in a blog post. It's a correctness fix for any
|
||||
timestamping IRQ handler under RT, not a local hack. If you're a PPS maintainer
|
||||
reading this: please take it.
|
||||
:::
|
||||
|
||||
## How to apply it
|
||||
|
||||
See [Patch pps-gpio](/how-to/patch-pps-gpio/) — it's a module, so you can rebuild
|
||||
just the one `.ko` in about a minute rather than the whole kernel.
|
||||
192
docs-site/src/styles/brass.css
Normal file
192
docs-site/src/styles/brass.css
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
/* The Cuckoo Escapement — brass-and-cream palette for Starlight.
|
||||
*
|
||||
* Why this file exists: the subject is horology. Escapements, gear trains, the
|
||||
* mechanical business of chopping continuous energy into trustworthy ticks. So
|
||||
* the site should read like a watchmaker's bench, not a terminal: warm brass on
|
||||
* near-black, cream text, the feel of an old patent drawing.
|
||||
*
|
||||
* We override Starlight's CSS custom properties rather than rewriting its
|
||||
* components — that keeps us forward-compatible with Starlight updates.
|
||||
*
|
||||
* Palette anchors:
|
||||
* - background: near-black, faint warm tint (not the usual cold slate)
|
||||
* - accent: brass — links, focus rings, the gear in the logo
|
||||
* - text: cream, never pure white (easier on the eyes, warmer)
|
||||
* - red: reserved for the "this is wrong" callouts, of which there are many
|
||||
*/
|
||||
|
||||
:root {
|
||||
--sl-font-system-mono: "JetBrains Mono", "Fira Code", "SF Mono",
|
||||
Menlo, Consolas, "DejaVu Sans Mono", monospace;
|
||||
|
||||
/* Starlight derives every semantic color (asides, card icons) from a HUE
|
||||
* variable — `--sl-color-purple-low/-/-high` are all hsl(var(--sl-hue-purple)…).
|
||||
* Retune the hues once and the whole derived scale follows, in BOTH themes,
|
||||
* instead of overriding nine colors by hand and watching them drift apart.
|
||||
*
|
||||
* Stock purple (281) is the loudest thing on the page and it does not belong
|
||||
* on a brass bench: `:::tip` asides and half the card icons render violet.
|
||||
* Swap it for verdigris — the blue-green a brass movement actually goes as it
|
||||
* ages. Same job, right family.
|
||||
*/
|
||||
--sl-hue-purple: 172; /* was 281 (violet) → verdigris */
|
||||
--sl-hue-blue: 199; /* was 234 (indigo) → steel */
|
||||
--sl-hue-green: 145; /* was 101 (lime) → patina */
|
||||
--sl-hue-orange: 38; /* was 41 → brass. Already close. */
|
||||
--sl-hue-red: 8; /* the "this is wrong" callouts, of which there are many */
|
||||
}
|
||||
|
||||
/* Dark is the default — this is a bench at 2am. */
|
||||
:root[data-theme="dark"] {
|
||||
--sl-color-bg: #0b0d10;
|
||||
--sl-color-bg-nav: #0e1116;
|
||||
--sl-color-bg-sidebar: #0d1014;
|
||||
--sl-color-bg-inline-code: #1d1a14;
|
||||
|
||||
--sl-color-text: #e8e4dc;
|
||||
--sl-color-text-accent: #e8bd6b;
|
||||
|
||||
--sl-color-accent-low: #3d2f14;
|
||||
--sl-color-accent: #d9a441;
|
||||
--sl-color-accent-high: #f2d79b;
|
||||
|
||||
--sl-color-white: #f4f1ea;
|
||||
--sl-color-gray-1: #dbd6cc;
|
||||
--sl-color-gray-2: #b6b0a4;
|
||||
--sl-color-gray-3: #837d72;
|
||||
--sl-color-gray-4: #4f4a43;
|
||||
--sl-color-gray-5: #2d2a26;
|
||||
--sl-color-gray-6: #1a1815;
|
||||
|
||||
--sl-color-hairline: #2b2721;
|
||||
--sl-color-hairline-light: #3a352c;
|
||||
--sl-color-hairline-shade: #201d18;
|
||||
|
||||
}
|
||||
|
||||
:root[data-theme="light"] {
|
||||
--sl-color-bg: #faf7f1;
|
||||
--sl-color-bg-nav: #f2ede3;
|
||||
--sl-color-bg-sidebar: #f5f1e8;
|
||||
--sl-color-bg-inline-code: #ece5d6;
|
||||
|
||||
--sl-color-text: #2b2721;
|
||||
--sl-color-text-accent: #8a6417;
|
||||
|
||||
--sl-color-accent-low: #e8d7ac;
|
||||
--sl-color-accent: #a97c1f;
|
||||
--sl-color-accent-high: #5c430f;
|
||||
|
||||
--sl-color-white: #1a1815;
|
||||
--sl-color-gray-1: #2d2a26;
|
||||
--sl-color-gray-2: #4f4a43;
|
||||
--sl-color-gray-3: #837d72;
|
||||
--sl-color-gray-4: #b6b0a4;
|
||||
--sl-color-gray-5: #dbd6cc;
|
||||
--sl-color-gray-6: #ece7dd;
|
||||
|
||||
--sl-color-hairline: #ddd6c8;
|
||||
}
|
||||
|
||||
/* Monospace headings — this is an engineering document, not an essay. */
|
||||
h1, h2, h3, .site-title {
|
||||
font-family: var(--sl-font-system-mono);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* Inline code gets a warm plate, but not inside links or headings (where it
|
||||
fights the surrounding type). */
|
||||
:not(a):not(h1):not(h2):not(h3):not(h4) > code {
|
||||
background: var(--sl-color-bg-inline-code);
|
||||
border: 1px solid var(--sl-color-hairline);
|
||||
border-radius: 4px;
|
||||
padding: 0.1em 0.35em;
|
||||
}
|
||||
|
||||
/* Measurement tables are the whole argument of this site, so let the numbers
|
||||
line up and let them breathe. */
|
||||
table {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
table td:not(:first-child),
|
||||
table th:not(:first-child) {
|
||||
font-family: var(--sl-font-system-mono);
|
||||
font-size: 0.92em;
|
||||
}
|
||||
|
||||
/* --- "A Supported Systems Joint" — the maker's plate ---------------------
|
||||
*
|
||||
* Styled as the engraved backplate of a clock movement: a double hairline
|
||||
* (the classic brass-plate border), warm plate fill, engraved-looking small
|
||||
* caps. Shared verbatim with the dashboard footer, recolored to its palette.
|
||||
* See src/components/SupportedSystemsBadge.astro for the markup + rationale.
|
||||
*/
|
||||
.ss-plate {
|
||||
margin-top: 3.5rem;
|
||||
}
|
||||
.ss-plate__link {
|
||||
display: flex;
|
||||
gap: 1.1rem;
|
||||
align-items: center;
|
||||
padding: 1.25rem 1.4rem;
|
||||
text-decoration: none;
|
||||
color: var(--sl-color-gray-2);
|
||||
|
||||
/* Double hairline = brass plate edge. The outer ring is the box-shadow. */
|
||||
border: 1px solid var(--sl-color-hairline-light);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 0 0 3px var(--sl-color-bg), 0 0 0 4px var(--sl-color-hairline);
|
||||
|
||||
/* Faint brushed-brass sheen, top-left, the way a plate catches bench light. */
|
||||
background:
|
||||
radial-gradient(120% 140% at 0% 0%, rgba(217, 164, 65, 0.06), transparent 60%),
|
||||
var(--sl-color-bg-nav);
|
||||
transition: color 0.2s, border-color 0.2s;
|
||||
}
|
||||
.ss-plate__link:hover {
|
||||
color: var(--sl-color-text);
|
||||
border-color: var(--sl-color-accent-low);
|
||||
}
|
||||
|
||||
.ss-plate__logo {
|
||||
flex: 0 0 auto;
|
||||
width: 46px;
|
||||
height: auto;
|
||||
opacity: 0.85;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.ss-plate__link:hover .ss-plate__logo { opacity: 1; }
|
||||
|
||||
.ss-plate__copy { display: block; }
|
||||
.ss-plate__heading {
|
||||
display: block;
|
||||
margin-bottom: 0.3rem;
|
||||
font-family: var(--sl-font-system-mono);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
/* Engraved: small, wide-tracked caps, the way a name is cut into brass. */
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
color: var(--sl-color-white);
|
||||
}
|
||||
.ss-plate__body {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.55;
|
||||
max-width: 62ch;
|
||||
}
|
||||
.ss-plate__name { color: var(--sl-color-accent); }
|
||||
.ss-plate__cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--sl-color-accent);
|
||||
}
|
||||
.ss-plate__link:hover .ss-plate__cta svg { transform: translateX(2px); }
|
||||
.ss-plate__cta svg { transition: transform 0.2s; }
|
||||
|
||||
@media (max-width: 32rem) {
|
||||
.ss-plate__link { flex-direction: column; align-items: flex-start; }
|
||||
}
|
||||
119
docs-site/src/styles/terminal.css
Normal file
119
docs-site/src/styles/terminal.css
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
/* l2trace docs — terminal-palette overrides for Starlight.
|
||||
*
|
||||
* Why this file exists: l2trace's TUI uses a green-on-black terminal feel
|
||||
* by default; the docs should feel like an extension of the same product.
|
||||
* We override Starlight's CSS custom properties rather than rewriting
|
||||
* components — that keeps us forward-compatible with Starlight updates.
|
||||
*
|
||||
* Palette anchors:
|
||||
* - background: near-black, slight green tint (matches terminal phosphor)
|
||||
* - accent: soft green for links / focus rings
|
||||
* - amber: reserved for warnings and "audit time" callouts
|
||||
* - text: warm off-white, not pure white (easier on eyes)
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* Monospace stack used throughout — code, headings, and accent UI. */
|
||||
--sl-font-system-mono: "JetBrains Mono", "Fira Code", "SF Mono",
|
||||
Menlo, Consolas, "DejaVu Sans Mono", monospace;
|
||||
}
|
||||
|
||||
/* Dark theme is the default for the docs (matches the TUI). */
|
||||
:root[data-theme="dark"] {
|
||||
--sl-color-bg: #0a0e0a;
|
||||
--sl-color-bg-nav: #0d120d;
|
||||
--sl-color-bg-sidebar: #0c100c;
|
||||
--sl-color-bg-inline-code: #14201a;
|
||||
|
||||
--sl-color-text: #e3e8e0;
|
||||
--sl-color-text-accent: #7fdb7f;
|
||||
|
||||
--sl-color-accent-low: #1b3a1b;
|
||||
--sl-color-accent: #5fcf5f;
|
||||
--sl-color-accent-high: #b8f0b8;
|
||||
|
||||
--sl-color-white: #f1f5ee;
|
||||
--sl-color-gray-1: #d6dccf;
|
||||
--sl-color-gray-2: #b6bdaf;
|
||||
--sl-color-gray-3: #828a7e;
|
||||
--sl-color-gray-4: #4d544a;
|
||||
--sl-color-gray-5: #2c3129;
|
||||
--sl-color-gray-6: #1a1f18;
|
||||
|
||||
--sl-color-hairline: #233022;
|
||||
--sl-color-hairline-light: #2c3a2b;
|
||||
--sl-color-hairline-shade: #182218;
|
||||
|
||||
/* Asides/admonitions — re-color to match palette. */
|
||||
--sl-color-orange-high: #ffd98e;
|
||||
--sl-color-orange: #f0b25c;
|
||||
--sl-color-orange-low: #3a2a14;
|
||||
--sl-color-red-high: #ff9a8a;
|
||||
--sl-color-red: #e96252;
|
||||
--sl-color-red-low: #3a1714;
|
||||
}
|
||||
|
||||
/* Light theme — softer, but still recognizably "l2trace". */
|
||||
:root[data-theme="light"] {
|
||||
--sl-color-text-accent: #2f7a2f;
|
||||
--sl-color-accent-low: #d6f0d6;
|
||||
--sl-color-accent: #2f7a2f;
|
||||
--sl-color-accent-high: #173d17;
|
||||
--sl-color-bg-inline-code: #ebf2e9;
|
||||
}
|
||||
|
||||
/* Headings get a touch of monospace + slight tracking — newspaper-headline
|
||||
* energy in a terminal aesthetic. Body text stays in the default sans for
|
||||
* readability over long passages. */
|
||||
.sl-markdown-content h1,
|
||||
.sl-markdown-content h2,
|
||||
.sl-markdown-content h3 {
|
||||
font-family: var(--sl-font-system-mono);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* Site title in the header — monospace to set the tone immediately. */
|
||||
.site-title {
|
||||
font-family: var(--sl-font-system-mono);
|
||||
}
|
||||
|
||||
/* Inline code: green-tinted background, NO background color when inside
|
||||
* link text (Starlight default looks muddy there). */
|
||||
.sl-markdown-content :not(a, h1, h2, h3, h4, h5, h6) > code:not(pre code) {
|
||||
background: var(--sl-color-bg-inline-code);
|
||||
border-radius: 0.2rem;
|
||||
padding: 0.05rem 0.3rem;
|
||||
border: 1px solid var(--sl-color-hairline);
|
||||
}
|
||||
|
||||
/* SVG embeds (the TUI screenshots) need a subtle border so they don't
|
||||
* float disconnected on the dark page background. */
|
||||
.sl-markdown-content img[src$=".svg"] {
|
||||
border: 1px solid var(--sl-color-hairline);
|
||||
border-radius: 0.4rem;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
/* Side-by-side theme-comparison tables: don't let cells stretch tall, and
|
||||
* keep the SVGs constrained so the row stays readable on smaller screens.
|
||||
* Targeting tables that contain images directly is a bit blunt but right
|
||||
* for our usage — the only such tables in the docs are theme-comparison
|
||||
* grids on the TUI tour page. */
|
||||
.sl-markdown-content table:has(img) {
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sl-markdown-content table:has(img) td {
|
||||
vertical-align: middle;
|
||||
padding: 0.4rem;
|
||||
}
|
||||
|
||||
.sl-markdown-content table:has(img) img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
/* Pull off the .sl-markdown-content img[src$=".svg"] outer border since
|
||||
* the table cell already provides one — double borders look noisy. */
|
||||
border-width: 0;
|
||||
}
|
||||
5
docs-site/tsconfig.json
Normal file
5
docs-site/tsconfig.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["dist"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue