jaraco / jaraco/pluslife-analyzer
Detect and recover from a mid-test stall (frozen remaining time, no data, no completion)
- Dominant language
- JavaScript
- Stars
- 1
- Forks
- 0
- PR merge metrics
- No merged PRs in 30d
Description
## Symptom
A test stalls part way through: "Remaining time" freezes, the reaction/temperature
graphs stop growing, and nothing ever changes again. Because the upstream app
never reaches its DONE state, our completion hook never fires — no screenshot, no
JSON export — and the partial data collected so far is unreachable (the export
button lives on the results screen).
Observed on 2026-08-21 (~27 min into a ~35 min run) and previously in plain Chrome,
so it is not specific to this wrapper. Suspected trigger: a hiccup in the BLE link
on either side.
## What the upstream app actually does
From `https://virus.sucks/_js/pluslife_app.js` (minified, no sourcemap; class names
below are the minified ones):
- **Two different state machines, both called `state`-ish:**
- `pluslife-app._state` — *connection* state:
`0 Not connected, 1 Connecting, 2 Connected, 3 Connection lost, 4 Disconnecting, 5 Reconnecting`.
- `test-view.data.state` — *test* state (enum `dy`):
`0 UNINITIALIZED, 1 IDLE, 2 TESTING, 3 DONE, 4 BLOCKED_ALREADY_TESTING, 5 BLOCKED_NOT_READY`.
- `test-view.data` (class `my`) is the test controller and holds everything we care
about: `state`, `testID`, `_kitConfig`, `testData {samples, temperatureSamples}`,
`testResult`, `_startTime`, `_lastSampleTime`, plus `remainingTime()`,
`progressPercentage()` and — importantly — **`downloadJSON()`**.
- `remainingTime()` is `fullTestTime() - (now - _startTime)`; it is pure wall clock.
It only *repaints* because `handleSample()`/`handleTemperature()` call
`host.requestUpdate()`. **So "Remaining time froze" literally means "no packets
are arriving from the dock" — nothing more.**
- The BLE transport (`kf`) subscribes to notifications once in `_connect()`. Its
1 s writer routine notices `!gattServer.connected` and retries `gattServer.connect()`
up to 5 times, but on success it only re-acquires the **write** characteristic —
it never re-runs `getCharacteristic(c305)` / `startNotifications()`. It also
installs a `gattserverdisconnected` listener that does nothing but `console.log`.
- `Ml.isConnected` only flips to false via an explicit `disconnect()` call, which
the writer routine makes only after 5 failed reconnects ("Retry count exceeded").
That combination explains the freeze exactly: if the GATT link drops and Chromium
(or the writer routine) re-establishes it, the app is "connected", the UI shows the
green **Connected** badge, requests are written — but the notification subscription
is gone, so nothing is ever received. The 2 s `housekeeping` routine keeps firing
device-status requests that each time out after 5 s; `gi` swallows the rejection, so
it loops silently forever. No error, no Reconnect button, no recovery.
## What we can hook
All reachable from `src/automation.js` (shadow-DOM piercing already in place):
```js
const app = document.querySelector('pluslife-app');
const tv = deepQuery('test-view');
const ctl = tv.data; // test controller
app._state; // connection state
app.pluslife; // transport: connected(), disconnect(reason), deviceSN
ctl.state, ctl.testData, ctl._lastSampleTime, ctl.remainingTime()
ctl.downloadJSON(); // works in ANY state; testResult is simply undefined
app._reconnect(); // same as the "Reconnect" button
```
## Plan
**(a) Detect.** In `tick()`, when `ctl.state === 2` (TESTING), track the newest of
`ctl._lastSampleTime` and `testData.temperatureSamples.at(-1).time`. Samples arrive
roughly every 30 s, so ~90–120 s without one is a stall. (Also worth logging
`app._state` and `app.pluslife.connected()` at that moment to confirm the diagnosis
above on a real occurrence.)
**(b1) Recover — reconnect to the running test.** This looks genuinely promising:
`test-view` stays mounted for connection states 2–5, so the controller, its
`testData` and its payload subscriptions all survive a disconnect. Force
`await app.pluslife.disconnect('stall watchdog')` → `_handleConnectionStatus(false)`
→ `_state = 3` (Connection lost) → the existing reconnect watchdog clicks
**Reconnect** → `_connect()` runs fresh, re-subscribing notifications, against the
same device (it throws if a different one is picked). Our Electron
`select-bluetooth-device` handler auto-picks the remembered dock, and the tick
already carries user activation, so `requestDevice()` is allowed. If the dock is
still running the test, samples should simply resume into the existing arrays.
Open question: whether the dock replays the samples missed during the gap or only
streams forward (leaving a hole in the curve). Also `resumeTest()` — the path taken
when the app comes back and finds `BLOCKED_ALREADY_TESTING` — **clears `testData`**,
so we must avoid the full reinitialize path and keep the controller alive.
**(b2) Give up locally and keep what we have.** Independently of (b1), and as a
fallback after N failed reconnects (or on user command), call `ctl.downloadJSON()`
directly and fire our normal `plBridge.testComplete()` screenshot path. The export
does not require the results screen and tolerates a missing `testResult`. Guard:
`downloadJSON()` dereferences `_kitConfig.name` and
`getFirstTimestamp().toISOString()`, so bail out if there are no temperature samples
yet. Filenames should mark the data as partial.
Worth adding regardless of (b1): a **File → Save current test data** menu item, so a
stalled (or any in-progress) run can always be salvaged by hand.
## How to test it
No hardware-free reproduction is available, but the failure does not have to be
reproduced faithfully to exercise the recovery path — the app has a user-facing
**Disconnect from device** button while connected. On the next real run:
1. Start a test, let a few minutes of curve accumulate.
2. Click **Disconnect from device** (or run `app.pluslife.disconnect('test')` in the
console) to simulate the drop, then **Reconnect**.
3. Check whether data resumes, whether the curve keeps its history, and whether the
test still completes normally. That answers the (b1) open questions.
4. Separately, `test-view.data.downloadJSON()` mid-test verifies (b2) on its own.
A harsher variant — toggling the Mac's Bluetooth off and on mid-test — should
reproduce the real drop, including whatever Chromium does on its own.
## Related
The keep-awake watchdog reads the wrong `_state` (#7), which means the Mac may sleep
during exactly the connection-lost window this issue is about.
Contributor guide
No contributing guide indexed for this repository
Research direction
Start in src/automation.js, especially tick(), and trace the exposed app.pluslife and test-view.data fields used by the watchdog. Exercise the recovery path with the user-facing Disconnect from device and Reconnect controls, then verify that samples resume, existing data is preserved, and the test can complete. Separately verify ctl.downloadJSON() during a test and confirm partial data can be saved.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- electron, javascript
- Domain
- desktop, networking
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100