// Author: cirakg & claude ai // Summary: Watchdog stress build: the largest code of every 2D symbology, encoded, packed, turned into runs and drawn, at twice the normal steps per event; the PIN and change-queue work at the same margin. // // Two versions of this module exist; each build keeps exactly one: // * (:no_stress), in every normal build: ON is false. // * (:stress), only with stress.jungle (`make watchdog`): the app skips // the vault and runs StressTest.Job instead of the list. // Connect IQ stops code that runs too long in one event ("Watchdog // Tripped"), but only in the simulator's app runs, never in unit tests. So // this build runs every 2D encoder, the cache packer and the run finder on // the biggest inputs the app accepts, with TWO job steps per runner step: // a pass shows that each step uses at most half of the watchdog budget // (120,000 bytecodes per event on the 512 KB watches, 240,000 elsewhere, // where the runner already does two steps per tick). Codes that fit the // screen are then drawn the way CodeView draws them (RunsPainter: straight // from the runs, or from a bitmap painted in steps), in one onUpdate. // Before the last code, VaultCase items measure the vault work after a PIN // and on the change queue (LockJob, ChangeQueueJob, ManageRun replacing // 4 KB of contents or a long secret, counting and describing the queue, // 0.9.0 items) at the same margin. // docker/scripts/app-diag.sh watchdog reads the DIAG stress lines. import Toybox.Application; import Toybox.Graphics; import Toybox.Lang; import Toybox.System; import Toybox.WatchUi; (:stress) module StressTest { const ON = true; // Steps per runner step: the safety margin being tested. const MARGIN = 2; // The last code's text, which stays on the screen to be decoded. const SCREEN_TEXT = "OTPW stress"; // One item: an encoder for the largest input of its kind, through // Codes.DisplayJob (encode, pack, runs) like CodeView, MARGIN steps at a time. class Item extends Jobs.Job { var name as String; var steps as Number = 0; var job as Codes.DisplayJob; var symbology as Number; var painter as RunsPainter? = null; // Highest memory use seen between steps. var peak as Number = 0; function initialize(label as String, type as Number, encoder as Codes.MatrixJob) { Job.initialize(); name = label; symbology = type; job = new Codes.DisplayJob(encoder, null); } // The display job, then (when there are runs) the painter. function step(budget as Number) as Boolean { for (var i = 0; i < MARGIN; i++) { steps++; var used = System.getSystemStats().usedMemory; peak = used > peak ? used : peak; var p = painter; if (p != null) { if (p.step(budget)) { return true; } } else if (job.step(budget)) { var runs = job.runs; if (runs == null) { return true; } painter = new RunsPainter(runs, Codes.minModulePx(symbology)); } } return false; } } // A RAM store for the vault cases. Values are kept as they are, without // the copies Application.Storage (or the tests' MemoryBackend) makes, so // the store adds no VM loops to what is measured, and two full vaults // fit without the storage limits. class RamBackend extends Persist.Backend { var values as Dictionary = {} as Dictionary; function initialize() { Backend.initialize(); } function getValue(key as String) as Application.Storage.ValueType { return values[key] as Application.Storage.ValueType; } function setValue(key as String, value as Application.Storage.ValueType) as Void { values[key] = value; } function deleteValue(key as String) as Void { values.remove(key); } } // One case of vault work after a PIN or on a queue change. Setup builds // the vaults in small steps (one entry, queue item or KDF step each). // The measured run then does the work at MARGIN: what runs in one event // runs twice in it, and jobs run on two identical vaults (A and B), one // step of each per step of this job, in stages (one job per vault each). // * UNLOCK: each vault has a PIN, LOCKS unlocked entries with a queued // "Require PIN" each, `pending` pending locked entries and a locked // session. B's unlock derivation finishes during setup; A's runs in // the measured run. Its last step finishes both unlocks, as // PinView.onUnlockDone does; then the LockJobs run. // * COUNT / DESCRIBE: one locked vault. COUNT's queue holds a REPLACE // of a locked code with `material` bytes of checked contents and // LOCKS - 1 LOCKs, and the work is ChangeQueue.changed() + // countToConfirm(). DESCRIBE's holds LOCKS edits with names of the // largest size, and the work is DescribeJob (QueueMenu), stepped on // the one vault MARGIN times per step. // * REPLACE_QUEUED: a locked code entry, a REPLACE with `material` // digits queued by ManageRun (as the settings queue it) during // setup; the measured run applies it with ChangeQueueJob. // * REPLACE_DIRECT: an unlocked code entry, and the same REPLACE // applied at once (ManageRun, not confirmed, as SettingsJob runs it). // * LEGACY: a locked code entry and a 0.9.0 queue item (a bare box, // aad "OTPW-Q-1") replacing it with `material` raw digits; locked, // with both unlock derivations done in setup. Stages: DescribeJob // (the menu), the unlock and LockJob, ChangeQueueJob ("Confirm all" // with the menu's keys). // * SECRET: a locked OTP entry, and a REPLACE with a valid Base32 // secret padded with spaces to ManageRun.MAX_SECRET_TEXT characters: // queued (ManageRun), then confirmed (ChangeQueueJob). // * SPACES: an unlocked code entry and a REPLACE of `material` spaces // (refused: "Contents: missing"). class VaultCase extends Jobs.Job { static const UNLOCK = 0; static const COUNT = 1; static const DESCRIBE = 2; static const REPLACE_QUEUED = 3; static const REPLACE_DIRECT = 4; static const LEGACY = 5; static const SECRET = 6; static const SPACES = 7; // Unlocked entries, one queued item each (ChangeQueue.MAX_ITEMS). static const LOCKS = 16; static const PIN = "1234"; var name as String; var kind as Number; var pending as Number; var material as Number; // False during setup, true in the measured run. var measuring as Boolean = false; var steps as Number = 0; var measured as Number = 0; var peak as Number = 0; var result as String = ""; private var _vaults as Array = [] as Array; private var _pins as Array = [] as Array; private var _targets as Array = [] as Array; private var _phase as Number = 0; private var _n as Number = 0; private var _job as PinJob? = null; private var _setupRun as ManageRun? = null; // The measured stage, its jobs (one per vault) and which are done. private var _stage as Number = -1; private var _jobs as Array = [] as Array; private var _done as Array = [] as Array; // LEGACY: the keys each vault's menu read. private var _keys as Array> = [] as Array>; function initialize(label as String, type as Number, pendingCount as Number, materialBytes as Number) { Job.initialize(); name = label; kind = type; pending = pendingCount; material = materialBytes; } function step(budget as Number) as Boolean { var used = System.getSystemStats().usedMemory; peak = used > peak ? used : peak; if (measuring) { measured++; return measure(budget); } steps++; return setup(budget); } private function vaultCount() as Number { return kind == COUNT || kind == DESCRIBE ? 1 : 2; } // Entries per vault. private function entryCount() as Number { return kind >= REPLACE_QUEUED ? 1 : LOCKS; } // One bounded setup step; true when every vault is ready. private function setup(budget as Number) as Boolean { var v = _vaults.size() > 0 ? _vaults[_vaults.size() - 1] : null; if (_phase == 0) { v = new Vault(new RamBackend(), new TimeSource()); _vaults.add(v); _targets = [] as Array; _job = v.beginSetPin(Bytes.fromUtf8(PIN), Kdf.MIN_ITERATIONS); _phase = 1; } else if (_phase == 1) { if ((_job as PinJob).step(budget)) { (v as Vault).finishSetPin(_job as PinJob); _job = null; _phase = 2; _n = 0; } } else if (_phase == 2) { _targets.add((v as Vault).add(newEntry())); _n++; if (_n == entryCount()) { if (kind <= DESCRIBE || kind == LEGACY) { (v as Vault).lock(); } _phase = 3; _n = 0; } } else if (_phase == 3) { // Locked entries added while locked wait under "p." (pending). if (_n < pending) { (v as Vault).add(Fixtures.otp("Pending " + _n, "acct", "Stress", Entry.OTP_TOTP, Hashes.SHA1, 6, 30, "12345678901234567890", true)); _n++; } else { _phase = 4; _n = 0; } } else if (_phase == 4) { if (queueStep(v as Vault)) { if (kind != UNLOCK && kind != LEGACY) { _phase = 0; return _vaults.size() == vaultCount(); } _pins.add((v as Vault).beginUnlock(Bytes.fromUtf8(PIN))); // UNLOCK: the last vault (A) is unlocked in the measured run. if (kind == UNLOCK && _vaults.size() == vaultCount()) { return true; } _phase = 5; } } else if (_pins[_pins.size() - 1].step(budget)) { _phase = 0; return _vaults.size() == vaultCount(); } return false; } private function newEntry() as Entry { if (kind == SECRET) { return Fixtures.otp("Bank", "acct", "Stress", Entry.OTP_TOTP, Hashes.SHA1, 6, 30, "12345678901234567890", true); } if (kind >= REPLACE_QUEUED || (kind != UNLOCK && _n == 0)) { // The code a REPLACE targets; locked unless replaced directly. var e = Fixtures.code("Card", Codes.QR, "1234567890"); e.locked = kind != REPLACE_DIRECT && kind != SPACES; return e; } return Fixtures.otp("Stress " + _n, "acct", "Stress", Entry.OTP_TOTP, Hashes.SHA1, 6, 30, "12345678901234567890", false); } // The REPLACE of the replace cases, as ManageForm.read gives it. private function replaceAction(v as Vault) as ManageAction { var a = new ManageAction(); a.id = _targets[0]; a.action = ManageAction.REPLACE; if (kind == SECRET) { // 200 Base32 characters (125 bytes) in groups, spaces around. var group = repeat(" ", 16) + "AAAA"; var groups = 200 / 4; a.text = repeat(" ", ManageRun.MAX_SECRET_TEXT - groups * group.length()) + repeat(group, groups); } else if (kind == SPACES) { a.text = repeat(" ", material); } else { a.text = repeat("0123456789", (material + 9) / 10).substring(0, material) as String; } return a; } // One step of queueing; true when the vault's queue is complete. private function queueStep(v as Vault) as Boolean { if (kind == REPLACE_DIRECT || kind == SECRET || kind == SPACES) { return true; } if (kind == LEGACY) { // As 0.9.0 queued it: raw contents inside one box. var old = new ManageAction(); old.id = _targets[0]; old.action = ManageAction.REPLACE; old.label = "Card"; old.material = Bytes.fromUtf8(replaceAction(v).text as String); var stored = ChangeQueue.items(v); stored.add(SealedBox.sealKeep(v.keyManager.deviceKeys(), Bytes.fromUtf8(ChangeQueue.AAD_V1), old.toBytes(), [] as Array)); // setup: nothing to wipe v.storage().setValue(ChangeQueue.KEY, stored); ChangeQueue.changed(); return true; } if (kind == REPLACE_QUEUED) { var run = _setupRun; if (run == null) { _setupRun = new ManageRun(v, replaceAction(v), false); return false; } if (!run.step(1)) { return false; } _setupRun = null; if (!run.queued) { result = " setup_problem=" + run.message; } return true; } var a = new ManageAction(); a.id = _targets[_n]; a.label = "Stress " + _n; a.action = ManageAction.LOCK; if (kind == DESCRIBE) { a.action = ManageAction.EDIT; a.name = repeat("N", Entry.MAX_NAME_BYTES); a.account = repeat("A", Entry.MAX_NAME_BYTES); a.group = repeat("G", Entry.MAX_GROUP_BYTES); a.label = Forms.label(a.name, a.account); } else if (kind == COUNT && _n == 0) { // Checked contents, as ManageRun queues them. a.action = ManageAction.REPLACE; a.material = Bytes.fromUtf8(repeat("abcdefghij", (material + 9) / 10).substring(0, material) as String); a.decoded = true; } ChangeQueue.add(v, a); _n++; return _n == LOCKS; } // The measured run. private function measure(budget as Number) as Boolean { if (kind == COUNT) { for (var i = 0; i < MARGIN; i++) { ChangeQueue.changed(); result = " to_confirm=" + ChangeQueue.countToConfirm(_vaults[0]); } return true; } if (_stage < 0) { if (kind == UNLOCK) { // A's derivation; its last step finishes both unlocks. if (!_pins[_vaults.size() - 1].step(budget)) { return false; } Diag.data("stress", "kdf_done name=" + name); } return nextStage(); } var all = true; for (var i = 0; i < _jobs.size(); i++) { if (!_done[i]) { _done[i] = _jobs[i].step(1); all = all && _done[i]; } } if (!all) { return false; } summary(); return nextStage(); } // Start the next stage's jobs (A first); true when there is none. private function nextStage() as Boolean { _stage++; var stages = kind == LEGACY ? 3 : (kind == SECRET ? 2 : 1); if (_stage >= stages) { return true; } var previous = _jobs; _jobs = [] as Array; _done = [] as Array; var last = _vaults.size() - 1; for (var i = last; i >= 0; i--) { var v = _vaults[i]; var job = new Jobs.Job(); // replaced below if (kind == DESCRIBE) { _jobs.add(new DescribeJob(v)); _done.add(false); job = new DescribeJob(v); } else if (kind == UNLOCK) { result += " v" + i + "_unlocked=" + v.finishUnlock(_pins[i]); job = new LockJob(v, true); } else if (kind == REPLACE_QUEUED || (kind == SECRET && _stage == 1)) { job = new ChangeQueueJob(v, null); } else if (kind == LEGACY) { if (_stage == 0) { job = new DescribeJob(v); } else if (_stage == 1) { _keys.add((previous[last - i] as DescribeJob).keys); result += " v" + i + "_unlocked=" + v.finishUnlock(_pins[i]); job = new LockJob(v, true); } else { job = new ChangeQueueJob(v, _keys[last - i]); } } else { job = new ManageRun(v, replaceAction(v), false); } _jobs.add(job as Jobs.Job); _done.add(false); } return false; } private function summary() as Void { var job = _jobs[0]; var v = _vaults[_vaults.size() - 1]; result += " [" + _stage + "]"; if (job instanceof LockJob) { ChangeQueue.takeLockLines(); // as the PIN screen does when it closes result += " lock_lines=" + job.lines.size() + " pending_left=" + v.pendingIds().size() + " ids=" + v.ids().size() + " queue_left=" + ChangeQueue.count(v); } else if (job instanceof ChangeQueueJob) { result += " lines=" + job.lines.size() + " first='" + job.lines[0] + "' queue_left=" + ChangeQueue.count(v); } else if (job instanceof DescribeJob) { result += " described=" + job.lines.size(); } else if (job instanceof ManageRun) { result += " failed=" + job.failed + " queued=" + job.queued + " message='" + job.message + "'"; } } } // The screen: progress while encoding, the runs once an item is done. class StressView extends WatchUi.View { var title as String = "Stress test"; var painter as RunsPainter? = null; private var _first as Boolean = true; function initialize() { View.initialize(); } function onUpdate(dc as Graphics.Dc) as Void { dc.setColor(Graphics.COLOR_BLACK, Graphics.COLOR_WHITE); dc.clear(); var p = painter; if (p != null) { var drawn = p.draw(dc); Diag.data("stress", "drawn name=" + title + " runs=" + p.runs.runs.size() + " direct=" + p.direct() + " drawn=" + drawn); painter = null; } else { dc.drawText(dc.getWidth() / 2, dc.getHeight() / 2, Graphics.FONT_XTINY, title, Graphics.TEXT_JUSTIFY_CENTER); } if (_first) { _first = false; Diag.firstFrame(); start(); } } } var view as StressView? = null; var items as Array = [] as Array; var current as Item? = null; var currentCase as VaultCase? = null; var runner as Jobs.JobRunner? = null; function initialView() as [WatchUi.Views] or [WatchUi.Views, WatchUi.InputDelegates] { var v = new StressView(); view = v; return [v]; } // The largest inputs: QR version 40 (bytes) and a long digit string at // level H, Data Matrix 144 x 144 (ASCII) and Base256, Aztec 32 layers // (text and binary), PDF417 as big as this screen takes; then the // largest Data Matrix and QR that fit the screen, to be drawn (on most // watches the QR is drawn from a bitmap, RunsPainter's other path). function start() as Void { var letters = repeat("abcdefghij", 290); var digits = repeat("0123456789", 80); var binary = repeat("é€", 120); var screen = System.getDeviceSettings(); var w = screen.screenWidth; var h = screen.screenHeight; var round = screen.screenShape == System.SCREEN_SHAPE_ROUND; items = [ new Item("qr_v40", Codes.QR, new Qr.QrJob(letters, 0, 0, -1)), new Item("qr_digits_h", Codes.QR, new Qr.QrJob(digits, 3, 0, -1)), new Item("dm_144", Codes.DATAMATRIX, new DataMatrix.DataMatrixJob(letters.substring(0, 1500) as String)), new Item("dm_base256", Codes.DATAMATRIX, new DataMatrix.DataMatrixJob(binary)), new Item("aztec_text", Codes.AZTEC, new Aztec.AztecJob(letters.substring(0, 1400) as String, 0)), new Item("aztec_binary", Codes.AZTEC, new Aztec.AztecJob(binary.substring(0, 160) as String, 3)), new Item("pdf417_text", Codes.PDF417, new Pdf417.Pdf417Job(letters.substring(0, w > 300 ? 900 : 250) as String, 1, w, h, round)), new Item("pdf417_digits", Codes.PDF417, new Pdf417.Pdf417Job(digits.substring(0, 400) as String, 1, w, h, round)), new Item("dm_fit", Codes.DATAMATRIX, new DataMatrix.DataMatrixJob(letters.substring(0, largestDataMatrix(w, h, round)) as String)), // Single-event vault work (VaultCase). new VaultCase("queue_count_big", VaultCase.COUNT, 0, Entry.MAX_PAYLOAD_BYTES), new VaultCase("queue_describe", VaultCase.DESCRIBE, 0, 0), new VaultCase("unlock_locks", VaultCase.UNLOCK, 0, 0), new VaultCase("unlock_pending", VaultCase.UNLOCK, Vault.MAX_ENTRIES - VaultCase.LOCKS, 0), new VaultCase("replace_code_4k", VaultCase.REPLACE_QUEUED, 0, Entry.MAX_PAYLOAD_BYTES), new VaultCase("replace_code_4k_direct", VaultCase.REPLACE_DIRECT, 0, Entry.MAX_PAYLOAD_BYTES), new VaultCase("queue_legacy_4k", VaultCase.LEGACY, 0, Entry.MAX_PAYLOAD_BYTES), new VaultCase("replace_secret_1k", VaultCase.SECRET, 0, 0), new VaultCase("replace_spaces_4k", VaultCase.SPACES, 0, Entry.MAX_PAYLOAD_BYTES), // Last, so it stays on the screen: app-diag.sh decodes it. new Item("qr_fit", Codes.QR, new Qr.QrJob(SCREEN_TEXT, 1, largestQrVersion(w, h, round), -1)) ] as Array; Diag.data("stress", "start used_kb=" + System.getSystemStats().usedMemory / 1024); next(); } function next() as Void { if (items.size() == 0) { Diag.data("stress", "all done"); return; } var item = items[0]; items = items.slice(1, null) as Array; if (item instanceof VaultCase) { currentCase = item; (view as StressView).title = item.name; WatchUi.requestUpdate(); var r = new Jobs.JobRunner(item, Kdf.ITERATIONS_PER_STEP, new Lang.Method(StressTest, :onCaseReady)); runner = r; r.start(); return; } current = item; (view as StressView).title = item.name; WatchUi.requestUpdate(); var r = new Jobs.JobRunner(item, 1, new Lang.Method(StressTest, :onItemDone)); runner = r; r.start(); } function onItemDone() as Void { var item = current as Item; var runs = item.job.runs; Diag.data("stress", "done name=" + item.name + " steps=" + item.steps + " peak_kb=" + item.peak / 1024 + " total_kb=" + System.getSystemStats().totalMemory / 1024 + " error=" + item.job.error + " size=" + (runs != null ? runs.width + "x" + runs.height : "-")); (view as StressView).painter = item.painter; WatchUi.requestUpdate(); // The next item starts in its own event, after the draw. var r = new Jobs.JobRunner(new Jobs.Job(), 1, new Lang.Method(StressTest, :next)); runner = r; r.start(); } // Setup done: the measured run starts with a fresh runner, so its first // event holds no setup step. function onCaseReady() as Void { var c = currentCase as VaultCase; c.measuring = true; Diag.data("stress", "start name=" + c.name + " setup_steps=" + c.steps + " used_kb=" + System.getSystemStats().usedMemory / 1024); var r = new Jobs.JobRunner(c, Kdf.ITERATIONS_PER_STEP, new Lang.Method(StressTest, :onCaseDone)); runner = r; r.start(); } function onCaseDone() as Void { var c = currentCase as VaultCase; Diag.data("stress", "done name=" + c.name + " setup_steps=" + c.steps + " measured_steps=" + c.measured + " peak_kb=" + c.peak / 1024 + " total_kb=" + System.getSystemStats().totalMemory / 1024 + c.result); currentCase = null; var r = new Jobs.JobRunner(new Jobs.Job(), 1, new Lang.Method(StressTest, :next)); runner = r; r.start(); } function repeat(text as String, times as Number) as String { var out = ""; for (var i = 0; i < times; i++) { out += text; } return out; } // The largest QR version that fits this screen at 2 px per module. function largestQrVersion(w as Number, h as Number, round as Boolean) as Number { for (var v = 40; v > 1; v--) { var total = 17 + 4 * v + 8; if (Codes.fits(total, total, 2, w, h, round)) { return v; } } return 1; } // An ASCII payload length that fills the largest Data Matrix fitting // this screen at 2 px per module. function largestDataMatrix(w as Number, h as Number, round as Boolean) as Number { var best = 1; for (var i = 0; i < DataMatrix.SIZES.size(); i++) { var s = DataMatrix.SIZES[i] as Array; if (Codes.fits(s[0] + 2, s[0] + 2, 2, w, h, round)) { best = s[3]; } } return best; } } (:no_stress) module StressTest { const ON = false; function initialView() as [WatchUi.Views] or [WatchUi.Views, WatchUi.InputDelegates] { return [new WatchUi.View()]; } }