mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-29 09:23:16 -06:00
/api/update/check offered the update whenever client !== latest (raw string inequality, not semver) with no backoff. A device that can't APPLY the update (broken OTA client 1.7.12, signing/Fire OS) keeps reporting the same version and is told update_available=true on every poll; a fast poll loop saturates the event loop (prod loop-lag 49s). All requests share one NAT IP, so IP-keying is useless. server-only breaker (lib/ota-breaker.js), two independent axes: - RATE breaker (primary, immediate): a key checking >THRESHOLD (3) times within WINDOW (60s) is looping -> throttle update_available with exponential backoff (30s->2m->8m->cap 30m). Healthy devices poll ~12 min and never approach this, so rollout/stragglers are inherently safe -- NO grace-for-flood timer; slow == safe. - PHANTOM guard (immediate): unrecognized version, or a prerelease of an OLDER core (superseded old-minor beta e.g. 1.9.1-beta4), gets no-offer on the first check. A RECENT real older version (beta3 vs latest beta4; stable 1.7.12) stays offerable. - Never offers a downgrade (client >= latest -> no offer). KEYING (#144 option 3): keyed on device_id when present, else reported version. - server.js:581 accepts + logs ?device_id=, passes it to the breaker. - UpdateChecker.kt:122 appends &device_id=<config.deviceId> (existing registered id; omitted until provisioned). One-line client change. beta4+ clients get precise per-device throttling; stuck legacy clients sending only ?version= are caught by the version-keyed + rate + phantom logic. Response gains additive `reason` + `retry_after_seconds` (old clients ignore). BOUNDED STATE: a periodic sweep (startSweep, wired in server.js) evicts buckets idle > IDLE_RESET_MS so the keyed Map can't grow unbounded (churned device_ids); not reset-on-access only. SCOPE (deliberate): this targets the FAST flood + phantoms. The slow #144 drip (stable 1.7.12 polling ~every 12 min, ~20/hr) stays below >3/60s and is NOT throttled -- catching it needs #144 option-3 "skip-this-version after N cycles", which is intentionally NOT in this build. NOTE: carries a CLIENT/APK change -> versionCode must increment at the beta4 bump and the release keystore is required for the APK. The device_id path only helps devices that can install beta4+; the stuck legacy fleet is covered by the version-keyed path. Tests: unit (lib/ota-breaker, injected time) a-f + comparator + escalation + sweep + slow-drip-scope; HTTP integration (real endpoint, device_id passthrough). Full suite green serial AND parallel (234). OTA-only delta -- reconnect/reclaim/shed/content-ack/ block untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
71 lines
3.6 KiB
JavaScript
71 lines
3.6 KiB
JavaScript
'use strict';
|
|
|
|
// #144 — HTTP integration: the real /api/update/check endpoint with the breaker wired.
|
|
// Proves end-to-end behavior + the device_id passthrough/keying. Rapid requests stay
|
|
// within the 60s rate window, so THRESHOLD(3) trips on the 4th. Unique PORT 3991.
|
|
|
|
const { test, before, after } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const { spawn } = require('node:child_process');
|
|
const path = require('node:path');
|
|
const os = require('node:os');
|
|
const fs = require('node:fs');
|
|
const crypto = require('node:crypto');
|
|
|
|
const PORT = 3991;
|
|
const BASE = `http://127.0.0.1:${PORT}`;
|
|
const DATA_DIR = path.join(os.tmpdir(), 'st-ota-' + crypto.randomBytes(4).toString('hex'));
|
|
const LOG = path.join(os.tmpdir(), 'st-ota-' + crypto.randomBytes(4).toString('hex') + '.log');
|
|
let proc, LATEST;
|
|
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
const check = async (version, deviceId) => {
|
|
const q = `version=${encodeURIComponent(version)}` + (deviceId ? `&device_id=${encodeURIComponent(deviceId)}` : '');
|
|
const r = await fetch(`${BASE}/api/update/check?${q}`);
|
|
return r.json();
|
|
};
|
|
|
|
before(async () => {
|
|
// the breaker only reports update_available when an APK actually exists — give the
|
|
// test server a dummy one (resolveApkPath checks DATA_DIR/ScreenTinker.apk).
|
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
fs.writeFileSync(path.join(DATA_DIR, 'ScreenTinker.apk'), Buffer.alloc(1024, 1));
|
|
const logFd = fs.openSync(LOG, 'w');
|
|
proc = spawn('node', ['server.js'], { cwd: path.join(__dirname, '..'), env: { ...process.env, DATA_DIR, SELF_HOSTED: 'true', PORT: String(PORT), NODE_ENV: 'test' }, stdio: ['ignore', logFd, logFd] });
|
|
let up = false;
|
|
for (let i = 0; i < 80; i++) { try { const r = await fetch(BASE + '/api/status'); if (r.ok) { up = true; break; } } catch { /* */ } await sleep(250); }
|
|
if (!up) throw new Error('server did not boot:\n' + fs.readFileSync(LOG, 'utf8').slice(-2000));
|
|
LATEST = (await check('0.0.1')).latest_version; // an ancient version reads back the server's latest
|
|
});
|
|
after(() => { try { proc.kill('SIGKILL'); } catch { /* */ } });
|
|
|
|
test('a device already on latest gets no offer (up-to-date)', async () => {
|
|
const r = await check(LATEST);
|
|
assert.equal(r.update_available, false);
|
|
assert.equal(r.reason, 'up-to-date');
|
|
});
|
|
|
|
test('(a) phantom version (superseded old-core prerelease) -> instant no-offer over HTTP', async () => {
|
|
const r = await check('1.9.1-beta4');
|
|
assert.equal(r.update_available, false);
|
|
assert.equal(r.reason, 'superseded-prerelease');
|
|
});
|
|
|
|
test('(b/f) legacy client (no device_id) looping the same version trips the version-keyed breaker', async () => {
|
|
const v = '1.6.0'; // fresh offerable older version, no device_id
|
|
const results = [];
|
|
for (let i = 0; i < 5; i++) results.push(await check(v)); // rapid, within the 60s window
|
|
assert.ok(results.slice(0, 3).every(r => r.update_available === true), 'first 3 offered');
|
|
assert.equal(results[3].update_available, false, '4th trips');
|
|
assert.equal(results[3].reason, 'rate-backoff');
|
|
assert.ok(results[3].retry_after_seconds >= 1, 'response carries retry_after_seconds');
|
|
});
|
|
|
|
test('(e) device_id looping is throttled per-device; another device on the same version is unaffected', async () => {
|
|
const v = '1.5.0';
|
|
for (let i = 0; i < 3; i++) await check(v, 'devA');
|
|
const aTrip = await check(v, 'devA'); // devA 4th -> trips
|
|
assert.equal(aTrip.update_available, false, 'devA throttled');
|
|
const bOk = await check(v, 'devB'); // devB first check -> offered
|
|
assert.equal(bOk.update_available, true, 'devB (same version, different device) unaffected');
|
|
});
|