jaraco / jaraco/pluslife-analyzer

Detect and recover from a mid-test stall (frozen remaining time, no data, no completion)

Open
#8 3 comments 0 reactions 0 assignees View on GitHub
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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.