v3.28.0 review: the batch failure classification
- A non-ValueError exception from the install run (a card removed mid-chain, a transport error) was recorded as a 'skipped' attempt with the error text - but the chain had started and died, which is a failure. Only a ValueError (a request/eligibility error: the preset's ISD keyset, the plan) is a skip now; anything else is a 'failed' record carrying its error. The PWA's failed row falls back to that error when there is no failing step / status word to show. - The panel's Clear button is disabled while a batch is active (the server refuses the clear with 409 anyway) - no pointless error alert. - api.md and the repo AGENTS document the two outcomes. Tests: a transport error mid-chain is a failure (and the card stays seen), the plan's input validation (the block size, a missing cap_hex), the failed row without a step, the shared ram_format field, and the Clear state. 915 frontend + 921 Python; sw cache simple-v438 (unchanged).
This commit is contained in:
+3
-1
@@ -1163,7 +1163,9 @@ interrupt it; the state lives in memory and a server restart ends the run.
|
|||||||
`phase` is `idle` (no run), `waiting_card`, `installing`, `stopping`,
|
`phase` is `idle` (no run), `waiting_card`, `installing`, `stopping`,
|
||||||
`stopped` or `finished`. A failed attempt carries `failed_step` (e.g.
|
`stopped` or `finished`. A failed attempt carries `failed_step` (e.g.
|
||||||
`LOAD (3/7)`), `por_sw` (the card's own status word), `por_status`,
|
`LOAD (3/7)`), `por_sw` (the card's own status word), `por_status`,
|
||||||
`por_error` and `sw`; a skipped one carries `reason`. `current` names the
|
`por_error` and `sw` — or, when the chain died before a verdict (a lost
|
||||||
|
card, a transport error), just `error`; a skipped one carries `reason`
|
||||||
|
(a request/eligibility error: the install never started). `current` names the
|
||||||
card being installed (`{iccid, preset, started}`) and `progress` mirrors the
|
card being installed (`{iccid, preset, started}`) and `progress` mirrors the
|
||||||
RAM progress payload while a chain runs. The report keeps the last 500
|
RAM progress payload while a chain runs. The report keeps the last 500
|
||||||
attempts (`attempts_total` counts them all).
|
attempts (`attempts_total` counts them all).
|
||||||
|
|||||||
+4
-1
@@ -10213,6 +10213,9 @@ function ramBatchRender() {
|
|||||||
stopBtn.textContent = phase === 'stopping' ? t('Stopping...') : t('Stop');
|
stopBtn.textContent = phase === 'stopping' ? t('Stopping...') : t('Stop');
|
||||||
stopBtn.disabled = phase === 'stopping';
|
stopBtn.disabled = phase === 'stopping';
|
||||||
}
|
}
|
||||||
|
// the server refuses a clear while the run is active
|
||||||
|
const clearBtn = document.getElementById('ram-batch-clear');
|
||||||
|
if (clearBtn) clearBtn.disabled = active;
|
||||||
const stateEl = document.getElementById('ram-batch-state');
|
const stateEl = document.getElementById('ram-batch-state');
|
||||||
if (stateEl) {
|
if (stateEl) {
|
||||||
if (!active) {
|
if (!active) {
|
||||||
@@ -10261,7 +10264,7 @@ function ramBatchAttemptsHtml(attempts) {
|
|||||||
const tone = a.result === 'installed' ? 'text-emerald-600 dark:text-emerald-400'
|
const tone = a.result === 'installed' ? 'text-emerald-600 dark:text-emerald-400'
|
||||||
: (a.result === 'failed' ? 'text-red-600 dark:text-red-400' : 'text-amber-600 dark:text-amber-400');
|
: (a.result === 'failed' ? 'text-red-600 dark:text-red-400' : 'text-amber-600 dark:text-amber-400');
|
||||||
const detail = a.result === 'failed'
|
const detail = a.result === 'failed'
|
||||||
? [a.failed_step, a.por_sw || a.sw].filter(Boolean).join(' \u2014 ')
|
? [a.failed_step, a.por_sw || a.sw || a.error].filter(Boolean).join(' \u2014 ')
|
||||||
: (a.reason || '');
|
: (a.reason || '');
|
||||||
html += '<tr class="border-b border-gray-100 dark:border-slate-800">'
|
html += '<tr class="border-b border-gray-100 dark:border-slate-800">'
|
||||||
+ '<td class="py-0.5 pr-2 text-gray-400 dark:text-slate-500 whitespace-nowrap">' + esc(when) + '</td>'
|
+ '<td class="py-0.5 pr-2 text-gray-400 dark:text-slate-500 whitespace-nowrap">' + esc(when) + '</td>'
|
||||||
|
|||||||
@@ -140,6 +140,18 @@ test('ramBatchAttemptsHtml renders every verdict with its detail', () => {
|
|||||||
// newest first
|
// newest first
|
||||||
assert.ok(out.indexOf('8970119000004600114') < out.indexOf('8970119000004600098'));
|
assert.ok(out.indexOf('8970119000004600114') < out.indexOf('8970119000004600098'));
|
||||||
assert.match(app.ramBatchAttemptsHtml([]), /No cards processed yet\./);
|
assert.match(app.ramBatchAttemptsHtml([]), /No cards processed yet\./);
|
||||||
|
// a failure without a step (a lost card mid-chain) shows its error
|
||||||
|
assert.match(app.ramBatchAttemptsHtml([{ iccid: '8970119000004600122', result: 'failed',
|
||||||
|
error: 'card removed' }]), /card removed/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the install and the batch send the form\'s ram_format', () => {
|
||||||
|
const fields = extractFunc(html, 'ramInstallCapFields');
|
||||||
|
assert.match(fields, /ram_format: \(document\.getElementById\('ram-format'\) \|\| \{\}\)\.value \|\| 'auto'/);
|
||||||
|
// the single install merges the shared fields into its request body
|
||||||
|
assert.match(extractFunc(html, 'ramInstallCap', true), /Object\.assign\(\{/);
|
||||||
|
assert.match(extractFunc(html, 'ramBatchStart', true),
|
||||||
|
/pysimFetchOk\('\/api\/ram-batch\/start', fields\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ramBatchRender shows the phase, the counts and the stop button', () => {
|
test('ramBatchRender shows the phase, the counts and the stop button', () => {
|
||||||
@@ -152,12 +164,14 @@ test('ramBatchRender shows the phase, the counts and the stop button', () => {
|
|||||||
setEl('ram-batch-notice', '');
|
setEl('ram-batch-notice', '');
|
||||||
setEl('ram-batch-counts', '');
|
setEl('ram-batch-counts', '');
|
||||||
setEl('ram-batch-attempts', '');
|
setEl('ram-batch-attempts', '');
|
||||||
|
setEl('ram-batch-clear', '');
|
||||||
// waiting for a card
|
// waiting for a card
|
||||||
app.setLast({ active: true, phase: 'waiting_card', attempts: [], counts: {}, attempts_total: 0, notice: '' });
|
app.setLast({ active: true, phase: 'waiting_card', attempts: [], counts: {}, attempts_total: 0, notice: '' });
|
||||||
app.ramBatchRender();
|
app.ramBatchRender();
|
||||||
assert.strictEqual(els['ram-batch-state'].textContent, 'Waiting for a card...');
|
assert.strictEqual(els['ram-batch-state'].textContent, 'Waiting for a card...');
|
||||||
assert.ok(els['ram-batch-stop'].classes['hidden'] === undefined);
|
assert.ok(els['ram-batch-stop'].classes['hidden'] === undefined);
|
||||||
assert.ok(els['ram-batch-start'].classes['hidden']);
|
assert.ok(els['ram-batch-start'].classes['hidden']);
|
||||||
|
assert.ok(els['ram-batch-clear'].disabled); // the server refuses a clear while active
|
||||||
// installing card X with the live step
|
// installing card X with the live step
|
||||||
app.setLast({ active: true, phase: 'installing', attempts: [], counts: { installed: 2, failed: 1, skipped: 0 },
|
app.setLast({ active: true, phase: 'installing', attempts: [], counts: { installed: 2, failed: 1, skipped: 0 },
|
||||||
attempts_total: 3, notice: 'Card 897… installed.', current: { iccid: '8970119000004600098', preset: 'Alfa' } });
|
attempts_total: 3, notice: 'Card 897… installed.', current: { iccid: '8970119000004600098', preset: 'Alfa' } });
|
||||||
@@ -177,6 +191,7 @@ test('ramBatchRender shows the phase, the counts and the stop button', () => {
|
|||||||
assert.strictEqual(els['ram-batch-state'].textContent, 'Batch stopped');
|
assert.strictEqual(els['ram-batch-state'].textContent, 'Batch stopped');
|
||||||
assert.ok(els['ram-batch-start'].classes['hidden'] === undefined);
|
assert.ok(els['ram-batch-start'].classes['hidden'] === undefined);
|
||||||
assert.ok(els['ram-batch-stop'].classes['hidden']);
|
assert.ok(els['ram-batch-stop'].classes['hidden']);
|
||||||
|
assert.ok(!els['ram-batch-clear'].disabled); // clearable again
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ramBatchApplyStatus fetches only when the summary changed', async () => {
|
test('ramBatchApplyStatus fetches only when the summary changed', async () => {
|
||||||
|
|||||||
@@ -2640,12 +2640,18 @@ def _ram_batch_handle_card(state, server):
|
|||||||
started = time.time()
|
started = time.time()
|
||||||
result = None
|
result = None
|
||||||
error = None
|
error = None
|
||||||
|
request_error = False
|
||||||
try:
|
try:
|
||||||
with _CARD_LOCK:
|
with _CARD_LOCK:
|
||||||
result = _ram_install_run(server, body, state['plan'], server.scc)
|
result = _ram_install_run(server, body, state['plan'], server.scc)
|
||||||
|
except ValueError as e:
|
||||||
|
# A request/eligibility error (the preset's ISD keyset, the plan): the
|
||||||
|
# install never started - the card is skipped.
|
||||||
|
error = str(e)
|
||||||
|
request_error = True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# A refused keyset, a lost card, a transport error: the card's outcome
|
# The chain started and died (a lost card, a transport error): the
|
||||||
# is a failure - the batch never retries it.
|
# attempt failed.
|
||||||
error = str(e)
|
error = str(e)
|
||||||
finally:
|
finally:
|
||||||
with _RAM_BATCH_LOCK:
|
with _RAM_BATCH_LOCK:
|
||||||
@@ -2667,11 +2673,16 @@ def _ram_batch_handle_card(state, server):
|
|||||||
'cntr_low': bool(result.get('cntr_low')),
|
'cntr_low': bool(result.get('cntr_low')),
|
||||||
'error': result.get('error') or ''}
|
'error': result.get('error') or ''}
|
||||||
rec.update(_ram_batch_failure_fields(result))
|
rec.update(_ram_batch_failure_fields(result))
|
||||||
else:
|
elif request_error:
|
||||||
outcome = 'skipped'
|
outcome = 'skipped'
|
||||||
rec = {'iccid': iccid, 'preset': name, 'result': outcome,
|
rec = {'iccid': iccid, 'preset': name, 'result': outcome,
|
||||||
'duration_ms': duration,
|
'duration_ms': duration,
|
||||||
'reason': error or 'the install could not start'}
|
'reason': error or 'the install could not start'}
|
||||||
|
else:
|
||||||
|
outcome = 'failed'
|
||||||
|
rec = {'iccid': iccid, 'preset': name, 'result': outcome,
|
||||||
|
'duration_ms': duration,
|
||||||
|
'error': error or 'the install failed'}
|
||||||
with _RAM_BATCH_LOCK:
|
with _RAM_BATCH_LOCK:
|
||||||
state['seen'][iccid] = outcome
|
state['seen'][iccid] = outcome
|
||||||
_ram_batch_record(state, rec)
|
_ram_batch_record(state, rec)
|
||||||
|
|||||||
@@ -180,6 +180,23 @@ class BatchCardTests(unittest.TestCase):
|
|||||||
self.assertIn('needs a default keyset', rec['reason'])
|
self.assertIn('needs a default keyset', rec['reason'])
|
||||||
self.assertEqual(st['seen']['8970119000004600098'], 'skipped')
|
self.assertEqual(st['seen']['8970119000004600098'], 'skipped')
|
||||||
|
|
||||||
|
def test_a_transport_error_mid_chain_is_a_failure(self):
|
||||||
|
st = srv._ram_batch_new_state(_plan())
|
||||||
|
preset = _preset('8970119000004600098')
|
||||||
|
|
||||||
|
def fake_run(*a, **k):
|
||||||
|
raise OSError('card removed')
|
||||||
|
|
||||||
|
with mock.patch.object(srv, '_ram_install_run', fake_run):
|
||||||
|
srv._ram_batch_handle_card(st, _fake_server('8970119000004600098',
|
||||||
|
_FakeStore([preset])))
|
||||||
|
rec = st['attempts'][0]
|
||||||
|
# the chain started and died: a failure, not a skip
|
||||||
|
self.assertEqual(rec['result'], 'failed')
|
||||||
|
self.assertIn('card removed', rec['error'])
|
||||||
|
self.assertEqual(st['counts']['failed'], 1)
|
||||||
|
self.assertEqual(st['seen']['8970119000004600098'], 'failed')
|
||||||
|
|
||||||
def test_a_card_already_processed_is_reported_not_reinstalled(self):
|
def test_a_card_already_processed_is_reported_not_reinstalled(self):
|
||||||
st = srv._ram_batch_new_state(_plan())
|
st = srv._ram_batch_new_state(_plan())
|
||||||
preset = _preset('8970119000004600098', 'Alfa')
|
preset = _preset('8970119000004600098', 'Alfa')
|
||||||
@@ -225,6 +242,26 @@ class BatchCardTests(unittest.TestCase):
|
|||||||
self.assertFalse(srv._ram_batch_summary()['active'])
|
self.assertFalse(srv._ram_batch_summary()['active'])
|
||||||
|
|
||||||
|
|
||||||
|
class RamInstallPlanTests(unittest.TestCase):
|
||||||
|
"""The shared card-independent plan (v3.28.0): the block-size/hex checks
|
||||||
|
and the transport knobs the single install and the batch both use."""
|
||||||
|
|
||||||
|
def test_the_plan_validates_the_input(self):
|
||||||
|
with mock.patch.object(srv, '_cap_parse', lambda h: ('A1', 'A2', b'\x01')), \
|
||||||
|
mock.patch.object(srv, '_cap_apdu_sequence', lambda *a, **k: ['80E60200']):
|
||||||
|
plan = srv._ram_install_plan({'cap_hex': 'AA', 'load_block_size': '120'})
|
||||||
|
self.assertEqual(plan['block_size'], 120)
|
||||||
|
self.assertEqual(plan['spi2'], '01')
|
||||||
|
self.assertEqual(plan['ram_format'], 'auto')
|
||||||
|
self.assertEqual(plan['seq'], ['80E60200'])
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
srv._ram_install_plan({'cap_hex': 'AA', 'load_block_size': 300})
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
srv._ram_install_plan({'cap_hex': 'AA', 'load_block_size': 'x'})
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
srv._ram_install_plan({'cap_hex': ''})
|
||||||
|
|
||||||
|
|
||||||
class BatchLifecycleTests(unittest.TestCase):
|
class BatchLifecycleTests(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
srv._RAM_BATCH = None
|
srv._RAM_BATCH = None
|
||||||
|
|||||||
Reference in New Issue
Block a user