From 147ab6d3c8517d0343e9e0fa824ca0bdc3a96e6b Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Wed, 8 Jul 2026 23:21:26 -0500 Subject: [PATCH] fix(ota): treat legacy -patchN as a released version so the old fleet is offered updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The -patchN scheme (e.g. 1.9.2-patch3) parses as a semver prerelease, so decide()'s superseded-prerelease guard refused to offer a newer stable core (1.9.3) to the existing fleet — stranding every 1.9.2-patchN device on OTA (Force Update didn't help; the re-check re-returned superseded-prerelease). isReleased() now counts -patchN as a shipped release, so those devices get offered 1.9.3 via normal OTA, while GENUINE prereleases (-beta/-rc/-alpha) keep prerelease semantics and newer cores are never downgraded. 6 new tests + 14 existing OTA tests green (388/388 suite). Co-Authored-By: Claude Opus 4.8 (1M context) --- server/lib/ota-breaker.js | 9 +++- server/test/ota-breaker-patchn.test.js | 58 ++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 server/test/ota-breaker-patchn.test.js diff --git a/server/lib/ota-breaker.js b/server/lib/ota-breaker.js index 07245e0..c52bffd 100644 --- a/server/lib/ota-breaker.js +++ b/server/lib/ota-breaker.js @@ -59,6 +59,13 @@ function cmpParsed(a, b) { } function cmp(a, b) { const pa = parseVer(a), pb = parseVer(b); return (!pa || !pb) ? null : cmpParsed(pa, pb); } +// A '-patchN' suffix (e.g. 1.9.2-patch3) was the LEGACY release scheme — a shipped PRODUCTION patch, +// not a prerelease. Semver parses it into `pre`, but for OTA it must count as RELEASED so the +// superseded-prerelease guard below doesn't strand the old fleet: a 1.9.2-patchN device must still be +// offered a newer stable core (1.9.3). Genuine prereleases (-beta/-rc/-alpha) keep prerelease +// semantics. (Clean semver going forward emits no -patchN, so this only matters for the transition.) +function isReleased(p) { return p.pre === null || /^patch\d+$/i.test(p.pre); } + // decide(clientVersion, latestVersion, deviceId?, now?) -> // { update_available, reason, retry_after_seconds?, log? } function decide(clientVersion, latestVersion, deviceId = null, now = Date.now()) { @@ -69,7 +76,7 @@ function decide(clientVersion, latestVersion, deviceId = null, now = Date.now()) const full = cmpParsed(pc, pl); if (full === 0) return { update_available: false, reason: 'up-to-date' }; if (full > 0) return { update_available: false, reason: 'client-newer' }; // never offer a downgrade - if (pc.pre !== null && coreCmp(pc, pl) < 0) { // superseded old-core prerelease (e.g. 1.9.1-beta4) + if (!isReleased(pc) && coreCmp(pc, pl) < 0) { // GENUINE superseded old-core prerelease (e.g. 1.9.1-beta4) — a -patchN release is NOT one, so it still gets offered return { update_available: false, reason: 'superseded-prerelease', log: logOnce(clientVersion, `[ota] superseded prerelease '${clientVersion}' (older core than latest=${latestVersion}) — no offer`) }; } diff --git a/server/test/ota-breaker-patchn.test.js b/server/test/ota-breaker-patchn.test.js new file mode 100644 index 0000000..1ff4cab --- /dev/null +++ b/server/test/ota-breaker-patchn.test.js @@ -0,0 +1,58 @@ +// OTA breaker — the -patchN transition. The legacy '-patchN' release scheme (e.g. 1.9.2-patch3) parses +// as a semver prerelease, which made the superseded-prerelease guard STRAND the old fleet: with clean +// 1.9.3 as latest, a 1.9.2-patchN device was refused the update. isReleased() now treats -patchN as a +// shipped release so it's offered, while GENUINE prereleases (-beta/-rc/-alpha) keep prerelease semantics. +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const ota = require('../lib/ota-breaker'); +const LATEST = '1.9.3'; // the clean-semver release +const T = 1_000_000; + +test('-patchN fleet is OFFERED the newer stable (the transition fix)', () => { + ota.reset(); + for (const v of ['1.9.2-patch3', '1.9.2-patch4', '1.9.1-patch2', '1.9.2-PATCH3' /* case-insensitive */]) { + const d = ota.decide(v, LATEST, null, T); + assert.equal(d.update_available, true, `${v} -> 1.9.3 should be offered (was 'superseded-prerelease')`); + assert.equal(d.reason, 'offer'); + } +}); + +test('GENUINE older-core prereleases are STILL superseded (no offer) — unchanged', () => { + ota.reset(); + for (const v of ['1.9.1-beta4', '1.9.2-beta6', '1.9.0-rc1', '1.8.5-alpha2']) { + const d = ota.decide(v, LATEST, null, T); + assert.equal(d.update_available, false, `${v} is a genuine prerelease of an older core -> no offer`); + assert.equal(d.reason, 'superseded-prerelease'); + } +}); + +test('clean older releases still offered; equal is up-to-date; newer never downgraded', () => { + ota.reset(); + assert.equal(ota.decide('1.9.2', LATEST, null, T).reason, 'offer', 'clean 1.9.2 -> offered'); + assert.equal(ota.decide('1.9.1', LATEST, null, T).reason, 'offer', 'clean 1.9.1 -> offered'); + assert.equal(ota.decide('1.7.12', LATEST, null, T).reason, 'offer', 'old clean release -> offered'); + assert.equal(ota.decide('1.9.3', LATEST, null, T).reason, 'up-to-date'); + assert.equal(ota.decide('1.9.4', LATEST, null, T).reason, 'client-newer', 'never downgrade a newer core'); +}); + +test('a prerelease of a HIGHER core is client-newer, not offered (e.g. a 1.9.4-beta1 tester)', () => { + ota.reset(); + assert.equal(ota.decide('1.9.4-beta1', LATEST, null, T).reason, 'client-newer'); + // and a -patchN of a higher core is likewise newer (never a downgrade) + assert.equal(ota.decide('1.9.4-patch1', LATEST, null, T).reason, 'client-newer'); +}); + +test('regression: unrecognized/garbage still refused; the fix did not loosen the phantom guard', () => { + ota.reset(); + assert.equal(ota.decide('banana', LATEST, null, T).reason, 'unrecognized-version'); + assert.equal(ota.decide('', LATEST, null, T).reason, 'no-version'); +}); + +test('regression: with a PRERELEASE server (beta4 latest), superseded-prerelease still fires for older betas', () => { + ota.reset(); + // mirrors the existing ota-breaker.test.js scenario — a -patchN client change must not disturb it + const d = ota.decide('1.9.1-beta4', '1.9.2-beta4', null, T); + assert.equal(d.reason, 'superseded-prerelease'); + // a same-core older beta against a beta server is offerable (rate path), unchanged + assert.equal(ota.decide('1.9.2-beta3', '1.9.2-beta4', null, T).reason, 'offer'); +});