Audit fixes and dependency upgrade (ink 7, React 19, js-yaml 5, TS 7) #1

Merged
kleb merged 30 commits from chore/audit-and-dependency-upgrade into main 2026-07-27 17:28:30 +02:00
Owner

Audit of kPong plus a full dependency upgrade, focused on stability, UI/UX, user flow, security and maintainability.

Findings came from a 65-agent audit across six dimensions, each finding then attacked by an adversarial verifier: 46 confirmed, 8 refuted. Everything below was reproduced before being fixed.

Dependencies

ink 5.2.1 → 7.1.1, react 18.3.1 → 19.2.8, js-yaml 4.1.1 → 5.2.2, TypeScript 5.9.3 → 7.0.2, knip 5.88.1 → 6.29.0, @types/react → 19.2.17. bun audit goes from 4 vulnerabilities (2 high) to 0.

Three upgrades had traps that typechecking could not catch, so each was verified by running the app:

  • js-yaml 5 ships no ESM default export. import yaml from "js-yaml" throws at startup, and allowSyntheticDefaultImports hides it from tsc — a silent total boot failure. Converted to named imports.
  • Ink 7 reports byte 0x7f as backspace, not delete. The existing "Delete falls back to Backspace at end-of-line" workaround therefore became an active bug that deleted the wrong character.
  • Ink 7 renders only the final frame when stdout is not a TTY. kPong never exits on its own, so a piped session would have looked hung. interactive: true preserves the previous behaviour.

TypeScript 7 ships the CLI only — no tsserver, no compiler API. knip 5 crashes on it, so knip 6 lands in the same commit. The picomatch/smol-toml/yaml overrides pinned knip transitives below what knip 6 requires and are removed; ws is pinned to 8.21.1 because ink's own range does not reach the patched version.

Data loss

An unreadable kpong.yaml left config null, which fell through to the first-run wizard — and completing that wizard overwrote the file the user needed to repair and ran replaceAllTargets, whose first statement is DELETE FROM targets. A YAML typo cost the user every stored target.

A config that exists but will not load is now a repair situation: a recovery view shows the path, the parse error, and that stored targets are intact, and the existing file watcher clears it as soon as the file is fixed. The wizard additionally backs up any config it is about to replace and seeds targets only into an empty table.

Also: parseConfigText no longer coerces a non-mapping document to {} (an empty file, a bare scalar and a sequence each produced a complete default config); the legacy import no longer writes kpong.yaml inside a SQLite transaction that cannot roll it back; config writes go through a temp file and rename; the config read is bounded and must be a regular file; legacy targets with no enabled: key no longer import as paused; deleteTarget is atomic and bulk replacement purges orphaned samples.

Stability

  • A down host was recorded as a healthy 0 ms. pingHost trusted any ms token regardless of exit code, and iputils prints 100% packet loss, time 0ms. Now gated on exit code plus a locale-independent 100% check.
  • The scheduler only ever probed the first N targets, N = maxConcurrentPings. Ticks past the cap returned without queueing, and interval timers stay phase-locked, so the same trailing targets starved forever. Reproduced with 6 targets and a cap of 2: only two were ever probed. Now a deferral queue plus a start stagger. The in-flight counter also leaked, because the decrement sat behind a generation guard.
  • The schema-version gate was dead code. initializeDatabase ran before the check and its ON CONFLICT DO UPDATE stamped a newer version back down to 1.
  • Retention never ran during a session and had no UI; it now runs hourly, immediately on change, has a Settings field, and a per-target row cap bounds growth when retention is "keep forever".
  • WAL + busy_timeout; failed sample writes requeue instead of being dropped; the scheduler no longer restarts on UI-only config edits; uncaught errors unmount cleanly instead of leaving the terminal in raw mode.

UI and UX

  • The sparkline mapped a null sample to 0, drawing packet loss with the same glyph as the fastest ping in the window. Loss now has its own glyph.
  • unreachable rendered in the muted "unknown" colour and as "Unknown" in the details pane — identical to a never-probed target.
  • The activity feed sized messages from the terminal width minus a constant unrelated to the pane it renders in, so every notice wrapped in the narrow column.
  • The table claimed terminal rows already spoken for by the form overlay and confirm card, so Yoga squashed the overflow at 80x24.
  • The footer advertised normal-mode keys while in a form, where those letters are swallowed by the text input.
  • Target names silently rejected any non-ASCII character, so "Zürich" could not be typed and nothing said why. Settings number fields report their range instead of silently clamping.

Maintainability

App.tsx drops from 1589 to ~1030 lines: the five keyboard controllers and the settings-field builder move to their own modules. AlertService.evaluate no longer both mutates its argument and returns a value. One describeError convention replaces ten String(error) calls and one error.message. A duplicated watch helper is removed and the copy that is actually called is now the one under test.

Quality gates

oxlint + oxfmt added; bun run check = typecheck + lint + format check + knip + tests, and CI runs the same plus bun audit. All 45 lint findings are fixed rather than silenced, except where the rule is wrong for this code — the control-character regexes are the theme sanitisation, and six exhaustive-deps reports are mount-only effects or the scheduler effect whose purpose is to not re-subscribe. Each suppression carries its reason at the site.

Formatting is a separate commit. One trap worth flagging for reviewers: oxfmt collapses runs of literal whitespace in JSX text, and in a terminal UI those runs are layout. Every load-bearing space now lives in a string expression; verified by diffing rendered frames before and after, byte-identical for both the empty and populated views.

Repo hygiene

.gitignore now covers any *.db plus WAL sidecars. dist/ is cleared before a build — it was holding a 3.6 MB kpong.db with real monitored hostnames next to the executable, which is the directory a maintainer would zip. The Windows icon is now actually applied, via a separate script because Bun rejects the flag on non-Windows targets. Forgejo CI added.

scripts/purge-db-from-history.sh is provided unrun, for the kpong.db blob still reachable in history from main. It rewrites history and needs a force-push, so it is deliberately a manual step.

Verification

96 tests pass (was 64 passing / 2 failing), typecheck clean, lint clean, format clean, knip clean, bun audit clean, the compiled binary builds and runs.

Not covered by tests: the keyboard paths, since nothing drives useInput. A manual pass on Backspace/Delete mid-string and at end-of-line, and Esc in each mode, is worth doing on Windows Terminal.

Audit of kPong plus a full dependency upgrade, focused on stability, UI/UX, user flow, security and maintainability. Findings came from a 65-agent audit across six dimensions, each finding then attacked by an adversarial verifier: 46 confirmed, 8 refuted. Everything below was reproduced before being fixed. ## Dependencies ink 5.2.1 → 7.1.1, react 18.3.1 → 19.2.8, js-yaml 4.1.1 → 5.2.2, TypeScript 5.9.3 → 7.0.2, knip 5.88.1 → 6.29.0, @types/react → 19.2.17. `bun audit` goes from 4 vulnerabilities (2 high) to 0. Three upgrades had traps that typechecking could not catch, so each was verified by running the app: - **js-yaml 5 ships no ESM default export.** `import yaml from "js-yaml"` throws at startup, and `allowSyntheticDefaultImports` hides it from `tsc` — a silent total boot failure. Converted to named imports. - **Ink 7 reports byte `0x7f` as `backspace`**, not `delete`. The existing "Delete falls back to Backspace at end-of-line" workaround therefore became an active bug that deleted the wrong character. - **Ink 7 renders only the final frame when stdout is not a TTY.** kPong never exits on its own, so a piped session would have looked hung. `interactive: true` preserves the previous behaviour. TypeScript 7 ships the CLI only — no `tsserver`, no compiler API. knip 5 crashes on it, so knip 6 lands in the same commit. The `picomatch`/`smol-toml`/`yaml` overrides pinned knip transitives *below* what knip 6 requires and are removed; `ws` is pinned to 8.21.1 because ink's own range does not reach the patched version. ## Data loss An unreadable `kpong.yaml` left `config` null, which fell through to the first-run wizard — and completing that wizard overwrote the file the user needed to repair and ran `replaceAllTargets`, whose first statement is `DELETE FROM targets`. A YAML typo cost the user every stored target. A config that exists but will not load is now a repair situation: a recovery view shows the path, the parse error, and that stored targets are intact, and the existing file watcher clears it as soon as the file is fixed. The wizard additionally backs up any config it is about to replace and seeds targets only into an empty table. Also: `parseConfigText` no longer coerces a non-mapping document to `{}` (an empty file, a bare scalar and a sequence each produced a complete default config); the legacy import no longer writes `kpong.yaml` inside a SQLite transaction that cannot roll it back; config writes go through a temp file and rename; the config read is bounded and must be a regular file; legacy targets with no `enabled:` key no longer import as paused; `deleteTarget` is atomic and bulk replacement purges orphaned samples. ## Stability - **A down host was recorded as a healthy 0 ms.** `pingHost` trusted any `ms` token regardless of exit code, and iputils prints `100% packet loss, time 0ms`. Now gated on exit code plus a locale-independent `100%` check. - **The scheduler only ever probed the first N targets**, N = `maxConcurrentPings`. Ticks past the cap returned without queueing, and interval timers stay phase-locked, so the same trailing targets starved forever. Reproduced with 6 targets and a cap of 2: only two were ever probed. Now a deferral queue plus a start stagger. The in-flight counter also leaked, because the decrement sat behind a generation guard. - **The schema-version gate was dead code.** `initializeDatabase` ran before the check and its `ON CONFLICT DO UPDATE` stamped a newer version back down to 1. - Retention never ran during a session and had no UI; it now runs hourly, immediately on change, has a Settings field, and a per-target row cap bounds growth when retention is "keep forever". - WAL + `busy_timeout`; failed sample writes requeue instead of being dropped; the scheduler no longer restarts on UI-only config edits; uncaught errors unmount cleanly instead of leaving the terminal in raw mode. ## UI and UX - The sparkline mapped a null sample to 0, drawing packet loss with the same glyph as the fastest ping in the window. Loss now has its own glyph. - `unreachable` rendered in the muted "unknown" colour and as "Unknown" in the details pane — identical to a never-probed target. - The activity feed sized messages from the terminal width minus a constant unrelated to the pane it renders in, so every notice wrapped in the narrow column. - The table claimed terminal rows already spoken for by the form overlay and confirm card, so Yoga squashed the overflow at 80x24. - The footer advertised normal-mode keys while in a form, where those letters are swallowed by the text input. - Target names silently rejected any non-ASCII character, so "Zürich" could not be typed and nothing said why. Settings number fields report their range instead of silently clamping. ## Maintainability `App.tsx` drops from 1589 to ~1030 lines: the five keyboard controllers and the settings-field builder move to their own modules. `AlertService.evaluate` no longer both mutates its argument and returns a value. One `describeError` convention replaces ten `String(error)` calls and one `error.message`. A duplicated watch helper is removed and the copy that is actually called is now the one under test. ## Quality gates oxlint + oxfmt added; `bun run check` = typecheck + lint + format check + knip + tests, and CI runs the same plus `bun audit`. All 45 lint findings are fixed rather than silenced, except where the rule is wrong for this code — the control-character regexes are the theme sanitisation, and six `exhaustive-deps` reports are mount-only effects or the scheduler effect whose purpose is to not re-subscribe. Each suppression carries its reason at the site. Formatting is a separate commit. One trap worth flagging for reviewers: oxfmt collapses runs of literal whitespace in JSX text, and in a terminal UI those runs are layout. Every load-bearing space now lives in a string expression; verified by diffing rendered frames before and after, byte-identical for both the empty and populated views. ## Repo hygiene `.gitignore` now covers any `*.db` plus WAL sidecars. `dist/` is cleared before a build — it was holding a 3.6 MB `kpong.db` with real monitored hostnames next to the executable, which is the directory a maintainer would zip. The Windows icon is now actually applied, via a separate script because Bun rejects the flag on non-Windows targets. Forgejo CI added. `scripts/purge-db-from-history.sh` is provided **unrun**, for the `kpong.db` blob still reachable in history from `main`. It rewrites history and needs a force-push, so it is deliberately a manual step. ## Verification 96 tests pass (was 64 passing / 2 failing), typecheck clean, lint clean, format clean, knip clean, `bun audit` clean, the compiled binary builds and runs. Not covered by tests: the keyboard paths, since nothing drives `useInput`. A manual pass on Backspace/Delete mid-string and at end-of-line, and Esc in each mode, is worth doing on Windows Terminal.
Closes all four reported advisories (2 high, 2 moderate): the js-yaml
merge-key DoS pair via js-yaml 5, and the ws pair via an explicit 8.21.1
override, since ink's range alone does not reach the patched version.

js-yaml 5 ships no ESM default export, so the two `import yaml from
"js-yaml"` sites become named imports. allowSyntheticDefaultImports meant
tsc could not have caught this; it would only have failed at startup.

Ink 7 reports byte 0x7f as `backspace` rather than `delete`, so the
end-of-line fallback in deleteAtCursor now maps the real Delete key onto a
backspace. Removed it, and dropped the ink 5 Escape/meta workaround from the
four input controllers. Ink 7 also calls devtools.initialize(), which the
vendored shim did not export.

TypeScript 7 requires removing baseUrl (nothing resolved through it) and
knip 6, since knip 5 crashes on the TS 7 compiler API. The picomatch,
smol-toml and yaml overrides pinned knip transitives *below* what knip 6
requires and are now removed, resolving them upward.

Entrypoint now renders into the alternate screen instead of writing a
terminal reset that erased the user's scrollback, forces interactive mode so
a piped session does not look hung, and unmounts on uncaught errors so a
crash cannot leave the terminal in raw mode.
An unreadable kpong.yaml left config null, which fell through to the
first-run wizard. Finishing that wizard overwrote the file the user needed
to repair and ran replaceAllTargets, whose first statement is DELETE FROM
targets. A YAML typo therefore cost the user every stored target.

A config that exists but will not load is now a repair situation: a recovery
view shows the path, the parse error, and that stored targets are intact,
and the existing file watcher clears it as soon as the file is fixed. The
wizard additionally backs up any config it is about to replace and seeds
targets only into an empty table.

parseConfigText no longer coerces a non-mapping document to {}: an empty
file, a bare scalar and a sequence each produced a complete default config,
so a damaged file was indistinguishable from a healthy one.

Also in this area: legacy targets with no `enabled:` key imported as paused
because undefined coerced to 0; the legacy import wrote kpong.yaml inside a
SQLite transaction that could not roll it back; config writes now go through
a temp file and rename; the config read is bounded and must be a regular
file; deleteTarget is atomic and bulk replacement purges orphaned samples;
CSV text columns are guarded against spreadsheet formula injection;
isCompiledBinary compares the executable name rather than searching the
whole path for "bun"; and Windows desktop alerts use a self-disposing
balloon instead of a modal MessageBox, capped at three pending helpers.
At the concurrency limit the scheduler returned without pinging, queueing or
emitting anything. Interval timers keep targets phase-locked, so the same
trailing targets lost the race on every tick and displayed as "never"
indefinitely. Reproduced with 6 targets and maxConcurrentPings 2: only the
first two were ever probed. Ticks now defer onto a FIFO that drains as slots
free, and first probes are staggered so targets sharing an interval do not
collide repeatedly.

The in-flight counter also leaked: the decrement sat behind a generation
guard that returned early for probes belonging to a superseded generation,
so only a full stop() reset it. The decrement is now authoritative.

pruneSamples only ran when the database was first opened, and retentionDays
defaults to null, so nothing was ever pruned during a session and there was
no UI to change it. Pruning now runs hourly off the flush path and
immediately when the setting changes, there is a Storage > Retention (days)
field, and a per-target row cap bounds growth when retention is "keep
forever".

A failed sample write dropped the batch; transient SQLite busy errors now
requeue it within a bounded backlog.

Settings number fields report their valid range instead of silently clamping
to it, matching the sibling Max Concurrent Pings field.

Test teardown restored KPONG_HOME by assigning a possibly-undefined value,
which stores the literal string "undefined"; getAppDirectory accepted it as
a path, leaking into every later test file in the run.
The sparkline mapped a null sample to 0, which drew packet loss with the
same lowest block as the fastest ping in the window, so an outage and an
excellent result were indistinguishable. Loss now has its own glyph, an
all-loss window renders as loss rather than a bare "--", and a genuine
sub-millisecond reading is no longer misreported as loss.

An "unreachable" host rendered in the muted unknown colour and, in the
details pane, as "Unknown" — the same treatment as a target that has never
been probed. A confirmed outage is now Down/danger, and dns_error is
distinguished too.

The activity feed sized messages from the terminal width minus a constant
unrelated to the pane it renders in, so every notice wrapped in the narrow
right-hand column. It now budgets against its own card and measures the
locale-dependent timestamp instead of assuming its width; the badge and
timestamp no longer shrink, which was clipping "INF" to "IN".

The table claimed terminal rows already spoken for by the form overlay and
confirm card, and the fixed-height root box made Yoga squash the overflow,
dropping and overprinting lines at 80x24.

The footer advertised normal-mode keys while in a form, where those letters
are swallowed by the text input.

Target names rejected any non-ASCII character silently, so "Zürich" could
not be typed and nothing said why; names are stored via bound parameters and
rendered as text, so only control characters need excluding. The host field
now states its accepted character set, which validateTargetHost owned but
which was unreachable because the keystroke filter rejected them first.
App.tsx owned its own state plus five keyboard controllers and a 200-line
settings-field builder, none of which touched that state. Those move to
inputControllers.tsx and settingsFields.ts; the shared SettingsField type
moves to appSupport.ts so neither has to import from App and form a cycle.
App.tsx drops from 1589 to ~1030 lines with no behaviour change.

AlertService.evaluate both mutated its argument and returned a value, so
callers had to read both in the right order to be correct. It now returns
{ event, spikeActive } and takes a Readonly state.

Error notices used String(error) in ten places and error.message in one, so
the same failure rendered as "Error: ENOENT ..." or "ENOENT ..." depending
on which path produced it. One describeError helper now covers all of them.

appSupport.ts carried a copy of shouldHandleWatchEvent identical to the
private one in fileReloadWatcher.ts, and the test exercised the copy rather
than the function actually called at the watch site. The real one is now
exported and under test. reconcileStatesWithConfig and validateHostInput had
no callers outside their own tests.

Repo hygiene: .gitignore now covers any *.db plus the WAL sidecars this
change introduces, since getAppDirectory falls back to cwd in dev and a
database with real hostnames was committed once before. `dist/` is cleared
before a build — it was holding a 3.6 MB kpong.db and a kpong.yaml next to
the executable, which is what a maintainer would zip. The Windows icon is
now actually applied, via a separate script because Bun rejects the flag on
non-Windows targets, and .images is tracked so a fresh clone can build.
Added a Forgejo CI workflow matching the remote, and corrected the README's
claim that the devtools shim exists because Ink declares an optional peer.

scripts/purge-db-from-history.sh is provided, unrun, for the committed
database still reachable in history. It rewrites history and needs a
force-push, so it is deliberately a manual step.
Formatting only; no behaviour change. Kept as its own commit so later diffs
are not buried in reflow.

printWidth is 110 rather than the default 80 because that is where this code
already sits (99th percentile line length was 112), so the reflow stays
close to the author's existing shape.

One thing needed fixing first: oxfmt collapses runs of literal whitespace in
JSX text, and in a terminal UI those runs are layout. The empty-state
indents ("  No targets configured yet."), the double-space separators in the
settings key hints, and the trailing spaces between adjacent Text nodes in
the remove-confirm card are now written as string expressions, which no
formatter will touch. Verified by diffing the rendered frames before and
after: byte-identical for both the empty and populated views.
Add oxlint and oxfmt gates and fix everything they found
Some checks failed
CI / check (push) Has been cancelled
CI / check (pull_request) Has been cancelled
612f135c07
Gate is oxlint's correctness + suspicious categories as errors; pedantic was
left off after measuring it at 219 findings of mostly style preference
against 36 actionable ones.

Fixed, not silenced:
- 25 redundant `?? {}` fallbacks in object spreads (spreading undefined is
  already a no-op)
- two `throw new Error(...)` in the config parser that discarded the original
  failure; they now pass `{ cause }`
- dead `normalizeCursorState` in textInput
- `loadStoredTarget` and `numberField` were redeclared on every call despite
  capturing nothing; both are now module-scope
- the notifications `useMemo` was keyed on a bump counter while reading a
  mutable service, so it could only ever go stale. getFeed() is a six-element
  slice, so it is read directly and the counter is now explicitly write-only.

Annotated where the lint rule is wrong for this code, with the reason at the
site: the two control-character regexes are the theme-file sanitisation, and
six exhaustive-deps reports are mount-only effects or the scheduler effect
whose whole purpose is to not re-subscribe on identity changes. Adding those
dependencies would restart every probe on every render.

react-in-jsx-scope is off because tsconfig uses the automatic JSX runtime.

oxlint has no no-restricted-syntax, so AGENTS.md's "colours come from
ResolvedTheme" rule is enforced by tests/conventions.test.ts instead, which
also runs in CI. Verified it fails on an injected literal rather than passing
vacuously.

The autofix introduced toSorted/toReversed, which need lib ES2023 — caught by
typecheck, not by the linter. Target moves to ES2024, which Bun implements in
full and which also allows Promise.withResolvers in the scheduler test.
Collaborator

kReview review

Verdict: no findings

No findings to address in the reviewed diff.

Overall risk is low; no changed line demonstrates a concrete correctness, security, or data-loss regression with the available evidence.

Excluded as generated or vendored (not reviewed): bun.lock, vendor/react-devtools-core-shim/index.cjs, vendor/react-devtools-core-shim/index.js.

Reviewed by kReview at fedc1276e5. This comment is conservative and based only on the PR diff, metadata, and supplied repository context.

Est. cost ~$31.61 total (9.3M in / 115.2k out) · this run ~$0.10 (128.4k in / 1.1k out) / gpt-5.6-sol.

<!-- codex-forgejo-review --> <!-- codex-forgejo-review-head:fedc1276e5a989538ee8ca7a5c4a9358ee609368 --> ## kReview review **Verdict:** no findings No findings to address in the reviewed diff. Overall risk is low; no changed line demonstrates a concrete correctness, security, or data-loss regression with the available evidence. _Excluded as generated or vendored (not reviewed):_ `bun.lock`, `vendor/react-devtools-core-shim/index.cjs`, `vendor/react-devtools-core-shim/index.js`. _Reviewed by kReview at `fedc1276e5`. This comment is conservative and based only on the PR diff, metadata, and supplied repository context._ _Est. cost ~$31.61 total (9.3M in / 115.2k out) · this run ~$0.10 (128.4k in / 1.1k out) / gpt-5.6-sol._ <!-- codex-forgejo-review-state:eyJoZWFkU2hhIjoiZmVkYzEyNzZlNWE5ODk1MzhlZThjYTdhNWM0YTkzNThlZTYwOTM2OCIsInN1bW1hcnkiOiJPdmVyYWxsIHJpc2sgaXMgbG93OyBubyBjaGFuZ2VkIGxpbmUgZGVtb25zdHJhdGVzIGEgY29uY3JldGUgY29ycmVjdG5lc3MsIHNlY3VyaXR5LCBvciBkYXRhLWxvc3MgcmVncmVzc2lvbiB3aXRoIHRoZSBhdmFpbGFibGUgZXZpZGVuY2UuIiwiZmluZGluZ3MiOltdLCJjdW11bGF0aXZlQ29zdCI6eyJ1c2QiOjMxLjYwNzcwMTAwMDAwMDAwMiwiaW5wdXRUb2tlbnMiOjkzMjk4MjUsIm91dHB1dFRva2VucyI6MTE1MTYyLCJtb2RlbCI6ImdwdC01LjYtc29sIn19 -->
Address PR feedback
Some checks failed
CI / check (push) Has been cancelled
CI / check (pull_request) Has been cancelled
482fb98a2c
All four findings validated against the code before changing anything; all
four were real.

Windows answers an ICMP error with "Reply from <gateway>: Destination host
unreachable", counts that reply as *received* so loss reads 0%, prints a 0 ms
round-trip summary, and still exits 0. Neither the exit code nor the
total-loss marker catches it, so a down host was still recorded as a healthy
0 ms sample — the comment above the guard claimed this case was covered when
it was not. Failure markers in the output are now checked directly. The scan
strips the host first, or a target legitimately named "timeout.example.com"
would make every successful reply look like a failure.

The persistence retry requeued the whole sample batch, but those samples had
already been folded into the rolling window and evaluated for spikes, so a
retry counted each one twice and skewed the displayed history and baseline.
Retries now operate on a separate queue of already-built rows. Pruning also
moved out of the write's try block: it is maintenance, and a pruning failure
was requeueing rows that had already been inserted.

stop() reset the shared in-flight counter while aborted probes were still
settling, so the next generation could start a full complement while the old
ones were still running, and each old probe then decremented the new
generation's count — allowing more than maxConcurrentPings concurrently. The
counter now drains on its own, since every probe that increments decrements
exactly once. The existing test asserted a bound of 4 against a cap of 2 and
so tolerated the violation; it now asserts the cap.

Retention accepted "7days" and "1.5" because Number.parseInt stops at the
first non-digit, silently saving 7 and 1. It now requires a whole-number
match, with tests covering the settings fields, which previously had none.
Never truncate the config on a failed write; harden scheduler limits
Some checks failed
CI / check (push) Has been cancelled
CI / check (pull_request) Has been cancelled
00e7eb6117
writeFileAtomically wrapped the temp-file creation and the rename in one try
whose catch did a direct writeFileSync. A failure in the *first* phase — full
disk, permission error, anything that stops the temp file being written —
therefore truncated a config that was still perfectly good, and then failed
again for the same underlying reason. That is the exact data loss the helper
exists to prevent.

The phases are now separate. A temp-write failure removes the temp file and
rethrows, leaving the original untouched. The rename is still retried, since
on Windows it can transiently lose to a virus scanner or indexer, but there
is no direct-write fallback: it would fail for the same reason the rename
did, only after the original had been destroyed. A save that reports an error
while the previous config stays readable is the better outcome. Covered by a
test that occupies the temp path with a directory and asserts the original
file is byte-identical afterwards.

The scheduler's concurrency limit moves from a per-target captured value onto
the service. Note this is hardening, not a live bug: a stale generation could
call dispatchDeferred with its old, higher limit, but the run() it dispatched
re-checked against its own captured limit — which for a new-generation target
was already the new lower value — and simply re-deferred. The limit was never
actually exceeded. Verified by a test that reproduces the described scenario
and passes both before and after. Reading one authoritative value makes the
invariant hold by construction rather than by that second check, and avoids
pointless queue rotation.

Also adds the settings-field tests that were missing, including the retention
parsing fixed in the previous commit.
Keep databases separate on a path change; prune on its own clock
Some checks failed
CI / check (push) Has been cancelled
CI / check (pull_request) Has been cancelled
4f026f5baa
storage.sqlite.path is editable in kpong.yaml, which is watched, so it can
change mid-session. DatabaseConnection swapped the handle, but runtime state
was reconciled rather than re-hydrated, so the previous database's samples
and baselines carried over for matching target ids — and any rows sitting in
the write-retry queue were then appended into the replacement, merging two
unrelated histories. A path change now discards the queued rows (they belong
to the old file) and re-hydrates state from the newly opened database.

Verified by running against ./first.db, editing the path to ./second.db
mid-session, and confirming the two files hold disjoint timestamp ranges.

Retention was driven from the sample-write path, which returns early when
nothing is queued. With every target paused, or the app simply idle, it never
ran — so a retention change did nothing and expired history stayed
indefinitely. It now runs on its own interval and immediately when the
setting changes, independent of whether samples are flowing.
Detect ping failures by phrase, not by stripping the hostname
Some checks failed
CI / check (push) Has been cancelled
CI / check (pull_request) Has been cancelled
189214da69
The previous fix scanned for single-word markers and removed the target host
from the text first, to stop a host named "timeout.example.com" from making
every successful reply look like a failure. That trade cut the other way: for
a host named exactly "unreachable", the strip also deleted the genuine
"Destination host unreachable" text, and since that Windows reply exits 0 and
reports 0% loss, the misleading 0 ms summary was accepted as healthy.

Both directions are fixed by making every marker a multi-word phrase. Host
names cannot contain spaces (validateTargetHost rejects them), so an echoed
hostname can never fabricate a marker, and no stripping is needed. The marker
list is now a phrase/status table shared by hasFailureMarker and
classifyFailure instead of two parallel lists that could drift, and it covers
the per-platform phrasings: Windows "Request timed out" and "Destination
net/host unreachable", iputils "Network is unreachable" and "Name or service
not known", macOS "Request timeout for icmp_seq" and "cannot resolve".

Tested from both directions, including the exact single-label host
"unreachable" that this finding named.
Back off between config rename retries
Some checks failed
CI / check (push) Has been cancelled
CI / check (pull_request) Has been cancelled
475e072779
The five rename attempts ran back to back with no delay, so all of them
landed inside the same brief antivirus or indexer lock they were meant to
outlast — effectively no recovery window. They now wait 10, 25, 50 and 100 ms
between attempts, and only for the error codes that indicate a transient
lock; anything else fails immediately instead of being retried pointlessly.

The original file stays intact in every failure path, as before.
Honour configured retention over the row cap; alert on live thresholds
Some checks failed
CI / check (push) Has been cancelled
CI / check (pull_request) Has been cancelled
2cad71a46a
The 100,000-row-per-target cap ran unconditionally after time-based pruning,
so it silently overrode whatever retention the user configured. At one sample
per second the cap is reached in about 28 hours, meaning a 30-day retention
setting lost everything older than that despite being well inside the
window. The cap was only ever meant as a backstop for "keep forever" — its
own comment said so while the code did otherwise. The policy now lives in one
place, applyRetentionPolicy, which applies the cap only when retentionDays is
null, with a test covering both branches.

Alerts were evaluated against the ResolvedTargetConfig the scheduler captured
when it started. Since the scheduler signature deliberately excludes spike
thresholds — so that tuning them does not restart every probe — that captured
copy kept the old thresholds until some unrelated field triggered a restart,
and spike tuning appeared to do nothing. Evaluation now uses the live config
already present in runtime state.
Address PR feedback: localized replies, write backoff, spike ranges
Some checks failed
CI / check (push) Has been cancelled
CI / check (pull_request) Has been cancelled
d2311e975c
A localized Windows unreachable reply still passed every guard: the English
marker list does not match it, Windows counts the ICMP error as received so
loss reads 0%, and the process exits 0. The remaining signal is that the only
latency token left is the statistics summary's zero. A parsed exactly-zero
reading never comes from a real reply — sub-millisecond replies print
"time<1ms" on Windows and fractional milliseconds elsewhere — so zero is no
longer accepted as a healthy sample.

A permanently unwritable database retried at the 50 ms flush interval and
pushed an error notice each time, roughly twenty failed writes and twenty
notices per second for as long as the app stayed open. Retries now back off
exponentially to a 30 s ceiling, only the first failure is reported, and
recovery is announced once. The retry timer is cleared on unmount.

The five Spike settings still used Math.max(0, v), so entering -5 reported
success and silently saved 0 — the same silent-clamp behaviour already fixed
for interval, timeout and history size. They now carry explicit ranges and
report out-of-range input, leaving the draft untouched.
Distinguish reply times from summary times; make backoff authoritative
Some checks failed
CI / check (push) Has been cancelled
CI / check (pull_request) Has been cancelled
1c4d9dfbd6
Rejecting every zero latency was wrong in the other direction: some ping
implementations report exactly 0 for a genuinely fast reply, and that target
would have been recorded as unknown. The real distinction is not the value
but where it came from — a per-reply time field or a statistics summary.

extractReplyLatencyMs now requires the separator to sit directly against the
number, which is how every implementation formats a reply time ("time=23ms",
"Zeit=23ms", "time<1ms", "time=0.000 ms") and how none of them format a
summary ("Minimum = 0ms", "Mittelwert = 0ms", "rtt min/avg/max = 0.0/…",
iputils' trailing "time 0ms"). That is a property of the ping binary's format
strings rather than its translations, so it holds for localized output — which
is what the localized Windows unreachable reply needed, since it exits 0,
reports 0% loss, and matches no English marker. A genuine "time=0.000 ms" is
now accepted again.

The write backoff only guarded against duplicate timers, so every 50 ms flush
still called straight through to appendSamples while a database was
unwritable. The next permitted attempt time is now checked by the attempt
itself, so all callers honour it.

Config saves preserve the original file's permission bits: openSync creates
the temp file at 0666 minus umask and the rename replaces the inode, so a
0600 config silently became 0644 on every save.
Preserve config permissions across an atomic save
Some checks failed
CI / check (push) Has been cancelled
CI / check (pull_request) Has been cancelled
7284bec24f
Correction: the previous commit message claimed this fix was included. It was
not — only the ping parsing and write-backoff changes were. This is the actual
implementation.

The rename swaps in a new inode, so the saved config inherits the temp file's
permissions instead of the original's. openSync creates at 0o666 minus the
umask, so a config the user had restricted to 0o600 silently became 0o644 on
the next settings save, exposing it to other local accounts. The original's
mode is now read before the write and applied to the replacement, with a test
that restricts the file and asserts the bits survive a save.
Address PR feedback: queued rows on delete, deleting a broken config
Some checks failed
CI / check (push) Has been cancelled
CI / check (pull_request) Has been cancelled
7251a99b26
Rows can outlive their target: if a write is failing they sit in the retry
queue, and deleteTarget only removes what is already in SQLite. A later
successful retry would insert history for a target that no longer exists, and
with no foreign key nothing would reject it. Queued rows and samples are now
dropped for a deleted target, and for anything not seeded by the wizard's
bulk replacement.

The recovery view told the user that deleting the broken config starts setup
again, but the watcher ignored deletions outright, so the screen just stayed
put. A deletion now returns to the wizard when we are in the recovery state,
and is still ignored during a healthy session, where the in-memory config
remains valid and tearing the session down would be worse. Verified by
corrupting a config, confirming the recovery view, deleting the file, and
watching it hand over to the wizard.
Re-trigger review for an unreviewed head
Some checks failed
CI / check (push) Has been cancelled
CI / check (pull_request) Has been cancelled
ac823dcd88
No code change. The review for 7251a99 died when the review gateway's
short-lived Forgejo token expired mid-request (401), and every retry since has
returned skipped-unchanged-head — the gateway believes that head was already
reviewed, so it will not run again for that SHA. This empty commit produces a
fresh SHA so the work in 7251a99 actually gets reviewed.
Address PR feedback: row clamp, CSV line feed, astral name characters
Some checks failed
CI / check (push) Has been cancelled
CI / check (pull_request) Has been cancelled
c7e902a1ec
The table's row budget subtracted the overlay's rows but then clamped the
result at three, so a large overlay still forced three data rows to render on
top of it — at 80x24 in adding mode the budget is negative — and Yoga squashed
the overflow exactly as before. The clamp is now zero: when the overlay needs
the whole pane the table shows no rows and reports what is hidden above and
below, which is honest rather than overprinted.

The CSV formula guard covered CR and tab but not LF, which OWASP lists as a
trigger; a target id beginning with a newline stayed formula-capable. Added,
with a test covering every leading character in the guard.

isAcceptableNameChar counted UTF-16 units, so a single astral code point —
any emoji — was two units and rejected, contradicting the rule that only
control characters are excluded. It now counts code points, and the same
correction was needed on the four keystroke guards in the input controllers,
which rejected the character upstream before the predicate ever saw it.
Verified end to end with an emoji target name.
Drops .forgejo/workflows/ci.yml and the doc lines that referred to it. The
gate itself is unchanged and still available locally as `bun run check`.

The comment in main.tsx that mentions CI is about ink's own environment
detection, not this workflow, so it stays.
getSchemaVersion mapped an unparseable version onto 0, the same value that
means "no stamp at all", so a database holding "abc" looked fresh and one
holding "1junk" looked current — either way the app went on to read and write
a schema it could not vouch for, and the DO NOTHING stamp left the bad value
in place. A row that exists must now hold a whole number or the open fails
with a repair message, which the recovery view surfaces.

Accepting astral characters in target names was only half a change: the
cursor helpers still stepped and sliced by UTF-16 unit, so moving into an
emoji and pressing backspace removed one surrogate and left the other
stranded in the name. The helpers now share a code-point index space, and the
three call sites that derived a cursor from a string length were converted
with it.
"0" passed the digit-only check and read back as the same value used for a
genuinely fresh database, so it bypassed the version gate — and because the
stamp is written with DO NOTHING, it stayed bypassed on every later startup.
An existing row must now hold a positive integer; zero is reserved for an
absent table or row.

The config temp file was always "<config>.tmp". Two kPong instances sharing a
directory is a supported arrangement, so concurrent saves could write into the
same temp file, rename each other's content into place, and delete a file the
other writer owned. Each save now uses a unique sibling name opened with "wx",
and cleanup only ever touches the path it created.

The atomic-write test no longer blocks a fixed temp path, since there isn't
one: it occupies the config path itself with a non-empty directory, which
fails the rename after the temp file exists — the point where the old
direct-write fallback destroyed the destination — and asserts the contents
survive with no temp files left behind.
The deferred queue used includes() for membership and shift() to drain. Up to
MAX_TARGETS (10,000) targets are supported, and once concurrency saturates
every one of them defers on every tick, so both operations turned each
interval into quadratic work on the event loop — enough to stall the TUI. It
now uses a Set for membership and a moving head index, compacted when the
consumed prefix dominates.

Widening target names to arbitrary Unicode left fitCell measuring UTF-16
length, so a CJK name — two terminal cells per character — shifted every
column to its right, and combining marks mismeasured the other way. Fitting
and truncation now work in terminal cells via string-width, the same measure
ink itself lays out with, promoted from a transitive dependency to a declared
one. The identical bug in the activity feed's truncation is fixed with the
same helper.

journal_mode is written into the database file, and openDatabase applied it
before the caller could run assertSupportedSchema — so merely attempting to
open a database from a newer build converted it to WAL and left sidecars
behind, despite the open being rejected. WAL now happens after the schema is
accepted; the pragmas that remain at open time are connection-scoped and do
not touch the file.
The field-switch handler still seeded the cursor from a UTF-16 length, which
the other three initialisation paths had already been converted away from.
For a name holding an astral character that lands the cursor one past the
end, so the first Left press is spent clamping back into range and appears to
do nothing.
writeSync reports how many bytes it took and may take fewer than offered — on
a nearly full filesystem, for example. The return value was ignored, so a
short write was fsynced and then renamed over a valid config, installing
truncated YAML. That is the precise outcome the atomic write exists to
prevent. It now loops until the encoded buffer is fully written and treats a
zero-byte write as an error, leaving the original untouched.

Not changed: kReview re-reported the form-field cursor as still using a
UTF-16 length. That was fixed in the previous commit and countCodePoints is
present at the reviewed head, so there is nothing to change.
Renaming onto the config path replaced a symlinked kpong.yaml with a regular
file, detaching a config kept in a dotfiles repository: the real file stopped
receiving saves and external edits went somewhere the app no longer read. The
link is now resolved first and the temp file is created beside the resolved
target, so the rename stays on one filesystem and the link survives.

The staggered first probe ran alongside an interval that started immediately,
so a target whose stagger exceeded its interval was probed twice in quick
succession. The interval now starts after that first probe, which also keeps
the offset for the life of the target instead of only at startup.

Switching databases cleared the queues but left the write backoff from the
old file in place, so writes to the replacement could stay blocked for up to
the 30 s ceiling — most likely right after the user switched *because* the
old database was failing.
getOrOpen commits the connection swap once schema preparation returns, but
the legacy import and target load ran afterwards. A database that passes the
schema check can still fail those — an incompatible targets table, say — and
by then the working connection had been closed and its queued writes
discarded, while the old config stayed live. Both steps now run against the
candidate inside the preparation callback, so a failure leaves the previous
connection untouched. When the path has not changed there is no candidate and
nothing to protect, so they run directly instead.

The startup stagger multiplied by the raw target index with no ceiling, so
with the supported 10,000 targets the last first-probe was scheduled 70
seconds out. Slots now repeat within a one-second window, which still spreads
the load; the deferral queue is what enforces concurrency.
The previous fix bounded the window by wrapping the index into 142 fixed
slots. That capped the delay, but it reintroduced the very condition the
stagger exists to prevent: at 10,000 targets roughly 70 of them share each
slot, and since the interval starts after the staggered first probe, those
groups stay phase-locked with each other permanently.

The gap between first probes is now derived from how many enabled targets
there are — min(7 ms, window / count) — so the window stays bounded while
every target keeps a distinct offset. Small configs are unaffected and keep
the full 7 ms spacing.

I had dismissed this finding because its stated symptom (a 70-second delay
for the last target) did not apply to the slot-based code. The symptom was
wrong; the suggested fix was right, and the slot scheme had a real defect I
had missed.
Two problems in the build scripts.

The prebuild step ran `rmSync('dist', {recursive: true})`. A compiled binary
resolves its runtime paths from its own directory, so anyone who runs
dist/kpong keeps their kpong.yaml, kpong.db and exports/ in dist/ — and the
next build erased all of it. I had added that step to keep build output clean
and did not consider that the directory holds live data. It now removes only
names the build itself produces.

`build:all` also cross-compiled to Windows with --windows-icon. Bun documents
that the Windows metadata flags depend on Windows APIs and cannot be used when
cross-compiling, so the whole all-platform build failed on its first command on
any Linux or macOS host. The icon now lives in a separate Windows-host-only
script and build:all uses the icon-free Windows target.
My previous fix replaced the recursive dist/ wipe with an allowlist that
removed only names matching /^kpong(-[a-z0-9-]+)?(\.exe)?$/. I validated it
against the default filenames, which was the wrong test: storage.sqlite.path
and ui.themeFile are user-configurable and resolved against the app directory,
so a config with `path: ./kpong-metrics` put a live SQLite database at exactly
a name the allowlist deleted.

No name-based rule can work here. A compiled binary keeps its data next to
itself, and the user chooses those names. Since bun build --compile overwrites
its own outfile, the cleanup was buying tidiness at the cost of data loss, so
the prebuild hook is gone entirely. Stale binaries from a renamed target now
linger until removed by hand, which is the correct trade.
kleb merged commit a80267bce4 into main 2026-07-27 17:28:30 +02:00
kleb deleted branch chore/audit-and-dependency-upgrade 2026-07-27 17:28:30 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
kleb/kPong!1
No description provided.