@esp-vision/device-core 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +8 -5
  3. package/package.json +1 -1
  4. package/schemas/device-actions-v1.json +1 -1
  5. package/scripts/generate-action-schema.mjs +2 -1
  6. package/scripts/npm-command.mjs +17 -0
  7. package/scripts/release-npm.mjs +2 -1
  8. package/src/browser.js +5 -2
  9. package/src/browser_rom.js +55 -5
  10. package/src/device_session.js +85 -9
  11. package/src/evmux.js +15 -0
  12. package/src/firmware_flash.js +84 -26
  13. package/src/firmware_partitions.js +57 -0
  14. package/src/firmware_updates.js +5 -2
  15. package/src/node_rom.js +49 -7
  16. package/src/node_rom.py +92 -5
  17. package/src/node_rom_port.js +27 -19
  18. package/src/node_serial.py +75 -12
  19. package/src/node_transport.js +63 -14
  20. package/src/rom_identity.js +9 -0
  21. package/tests/check-browser.mjs +23 -4
  22. package/tests/check-package.mjs +15 -7
  23. package/tests/device-checks.mjs +89 -7
  24. package/tests/firmware-fixtures.mjs +60 -0
  25. package/tests/fixtures/device-checks.py +357 -1
  26. package/tests/hardware-check.mjs +3 -2
  27. package/tests/test-browser-rom.mjs +169 -3
  28. package/tests/test-ci-check-selection.mjs +31 -0
  29. package/tests/test-device-functional-checks.mjs +275 -3
  30. package/tests/test-device-v4-alignment.mjs +261 -7
  31. package/tests/test-firmware-flash.mjs +22 -20
  32. package/tests/test-firmware-install.mjs +8 -35
  33. package/tests/test-firmware-source.mjs +10 -7
  34. package/tests/test-host-checks.mjs +47 -0
  35. package/tests/test-install-erase.mjs +153 -0
  36. package/tests/test-node-rom-port.mjs +81 -1
  37. package/tests/test-node-rom.mjs +159 -4
  38. package/tests/test-node-serial.mjs +231 -2
  39. package/tests/test-npm-release-policy.mjs +3 -9
@@ -1,16 +1,21 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  import test from 'node:test'
3
3
  import assert from 'node:assert/strict'
4
- import { readFile, mkdtemp, rm } from 'node:fs/promises'
4
+ import { readFile, mkdtemp, rm, writeFile } from 'node:fs/promises'
5
5
  import { join } from 'node:path'
6
6
  import { EV_REPL_INPUT_MAX_BYTES, evReplExecInputBytes } from '../src/evrepl.js'
7
7
  import { tmpdir } from 'node:os'
8
+ import { existsSync } from 'node:fs'
9
+ import { createHash } from 'node:crypto'
10
+ import { spawnSync } from 'node:child_process'
11
+ import { fileURLToPath } from 'node:url'
8
12
  import { parseArgs } from './hardware-check.mjs'
9
- import { DEVICE_CHECKS, parseCheckResult, runDeviceChecks, saveJpeg } from './device-checks.mjs'
13
+ import { DEFAULT_ESPDL_MODEL, DEFAULT_ESPDL_SHA256, DEVICE_CHECKS, parseCheckResult, runDeviceChecks, saveJpeg } from './device-checks.mjs'
10
14
  import { jpegFixture } from './jpeg-fixtures.mjs'
11
15
 
12
16
  function devicePeer({ results = {}, board = 'TEST', screenFails = false } = {}) {
13
17
  const files = new Map(), calls = [], listeners = new Map()
18
+ let modelNonce, modelBytes, modelPath, modelOffset = 0
14
19
  return {
15
20
  calls, files, listeners,
16
21
  async inspect({ fields }) { calls.push(['inspect', fields]); return { identity: { board, firmwareVersion: 'test' }, capabilities: { protocol: { evMuxVersion: 4 }, features: ['storage.writer.lease'], limits: { replInputMax: EV_REPL_INPUT_MAX_BYTES } }, runtime: { vmReady: true }, storage: { flash: { present: true, mounted: false, writable: false } } } },
@@ -21,6 +26,26 @@ function devicePeer({ results = {}, board = 'TEST', screenFails = false } = {})
21
26
  }),
22
27
  python: { async execute({ source }) {
23
28
  calls.push(['execute', source])
29
+ if (source.includes("={'_source':[],'_fixture_nonce':")) modelNonce = JSON.parse(source.slice(source.indexOf("'_fixture_nonce':") + 17, -1))
30
+ const model = /\['model_command'\]\((.*)\)$/.exec(source)
31
+ if (model) {
32
+ const [operation, ...args] = JSON.parse('[' + model[1] + ']')
33
+ let data
34
+ if (operation === 'begin') {
35
+ ;[modelNonce, modelBytes, modelPath] = args
36
+ modelOffset = 0
37
+ data = { size: modelBytes, offset: 0, path: modelPath }
38
+ } else if (operation === 'append') {
39
+ const [offset, encoded] = args, bytes = Buffer.from(encoded, 'base64').length
40
+ modelOffset = offset + bytes
41
+ data = { offset, end: modelOffset, bytes, current: modelOffset }
42
+ } else if (operation === 'mount') data = { path: modelPath, bytes: modelBytes, sha256: args[0] }
43
+ else data = { bytes: 0, offset: 0, mounted: false }
44
+ return { ok: true, output: '__EVHILACK__' + JSON.stringify([modelNonce, operation, data]) }
45
+ }
46
+ if (source.startsWith('assert ') && source.includes('__EVHILACK__')) {
47
+ return { ok: true, output: JSON.parse(source.slice(source.indexOf(';print(') + 7, -1)) }
48
+ }
24
49
  const name = /\['run_check'\]\("([a-z_]+)"/.exec(source)?.[1]
25
50
  if (!name) return { ok: true, output: '' }
26
51
  const result = results[name] ?? { name, status: 'PASS', data: { width: 240, height: 320 } }
@@ -147,10 +172,257 @@ test('identity and RAM checks do not require optional storage inspection support
147
172
  })
148
173
 
149
174
  test('P4 issue regressions are included in the strict target-check path', async () => {
150
- const required = ['camera_display_lifecycle', 'image_copy_to_fb', 'image_transform_argument', 'sensor_snapshot_buffer', 'memory_policy']
175
+ const required = ['camera_display_lifecycle', 'image_copy_to_fb', 'image_transform_argument', 'sensor_snapshot_buffer', 'memory_policy', 'isp_ccm_frames', 'isp_lsc_display']
151
176
  assert.ok(required.every(name => DEVICE_CHECKS.includes(name)))
152
177
  const device = devicePeer(), reports = []
153
178
  await runDeviceChecks(device, parseArgs(['--tcp', 'test', '--checks', required.join(','), '--require-all']),
154
179
  { nonce: 'issue910', report: value => reports.push(value) })
155
180
  assert.deepEqual(reports.at(-1), { check: 'summary', passed: required.length, failed: 0, skipped: 0 })
156
181
  })
182
+
183
+ test('ESP-DL inference streams a model into RAM, respects the live REPL limit and never touches persistent storage', async () => {
184
+ const directory = await mkdtemp(join(tmpdir(), 'esp-vision-model-'))
185
+ const model = join(directory, 'model.espdl'), bytes = Buffer.from(Array.from({ length: 1024 }, (_, index) => index & 255))
186
+ try {
187
+ await writeFile(model, bytes)
188
+ const device = devicePeer(), reports = [], inspect = device.inspect
189
+ device.inspect = async request => {
190
+ assert.ok(!request.fields.includes('storage'))
191
+ const info = await inspect(request)
192
+ info.capabilities.limits.replInputMax = 256
193
+ return info
194
+ }
195
+ await runDeviceChecks(device, parseArgs(['--tcp', 'test', '--checks', 'espdl_inference', '--model', model, '--require-all']), { nonce: 'abc123', report: value => reports.push(value) })
196
+ const commands = device.calls.filter(call => call[0] === 'execute').map(call => call[1])
197
+ assert.ok(commands.every(source => evReplExecInputBytes(source) <= 256))
198
+ const uploaded = commands.filter(source => source.includes("['model_command'](\"append\",")).map(source => Buffer.from(JSON.parse('[' + source.slice(source.indexOf('](') + 2, -1) + ']')[2], 'base64'))
199
+ assert.deepEqual(Buffer.concat(uploaded), bytes)
200
+ assert.ok(commands.some(source => source.includes("['model_command'](\"mount\",\"" + createHash('sha256').update(bytes).digest('hex'))))
201
+ assert.ok(commands.some(source => source.includes('modelPath\\\":\\\"/evhil_abc123/model.espdl')))
202
+ assert.ok(commands.some(source => source.includes("['model_command'](\"close\")")))
203
+ assert.equal(reports.find(result => result.check === 'espdl_inference').data.modelBytes, bytes.length)
204
+ assert.equal(reports.find(result => result.check === 'espdl_inference').data.modelStorage, 'ram-vfs')
205
+ assert.equal(device.files.size, 0)
206
+ } finally { await rm(directory, { recursive: true, force: true }) }
207
+ })
208
+
209
+ test('missing ESP-DL model skips only optional acceptance and malformed expected hashes are rejected', async () => {
210
+ assert.throws(() => parseArgs(['--tcp', 'test', '--model-sha256', 'bad']), { code: 'ARGUMENT' })
211
+ for (const strict of [false, true]) {
212
+ const device = devicePeer(), results = []
213
+ const run = runDeviceChecks(device, parseArgs(['--tcp', 'test', '--checks', 'espdl_inference', '--model', join(tmpdir(), 'missing-esp-vision.espdl'), ...(strict ? ['--require-all'] : [])]), { nonce: 'abc123', report: value => results.push(value) })
214
+ if (strict) await assert.rejects(run, { code: 'DEVICE_CHECKS_FAILED' })
215
+ else await run
216
+ assert.equal(results.find(result => result.check === 'espdl_inference').status, strict ? 'FAIL' : 'SKIP')
217
+ assert.ok(!device.calls.some(call => call[0] === 'execute'))
218
+ }
219
+ })
220
+
221
+ test('ESP-DL model hash mismatch is rejected before device mutation', async () => {
222
+ const directory = await mkdtemp(join(tmpdir(), 'esp-vision-model-'))
223
+ try {
224
+ const model = join(directory, 'model.espdl'), device = devicePeer(), reports = []
225
+ await writeFile(model, Buffer.from([1, 2, 3]))
226
+ await assert.rejects(runDeviceChecks(device, parseArgs(['--tcp', 'test', '--checks', 'espdl_inference', '--model', model, '--model-sha256', '0'.repeat(64)]), { nonce: 'abc123', report: value => reports.push(value) }), { code: 'DEVICE_CHECKS_FAILED' })
227
+ assert.equal(reports.find(result => result.check === 'espdl_inference').error.code, 'MODEL_HASH_MISMATCH')
228
+ assert.equal(device.calls.length, 0)
229
+ } finally { await rm(directory, { recursive: true, force: true }) }
230
+ })
231
+
232
+ test('partial model upload, inference failure and cleanup failure cannot report success', async () => {
233
+ const directory = await mkdtemp(join(tmpdir(), 'esp-vision-model-'))
234
+ try {
235
+ const model = join(directory, 'model.espdl')
236
+ await writeFile(model, Buffer.alloc(1024))
237
+ for (const stage of ['append_model', 'espdl_inference', 'close_model']) {
238
+ const device = devicePeer(), execute = device.python.execute, reports = []
239
+ device.python.execute = async request => {
240
+ if (request.source.includes(`['model_command']("${stage.replace('_model', '')}"`) || (stage === 'espdl_inference' && request.source.includes("['run_check'](\"espdl_inference\""))) {
241
+ device.calls.push(['failed-execute', request.source])
242
+ throw new Error('injected failure')
243
+ }
244
+ return execute(request)
245
+ }
246
+ await assert.rejects(runDeviceChecks(device, parseArgs(['--tcp', 'test', '--checks', 'espdl_inference', '--model', model, '--require-all']), { nonce: 'abc123', report: value => reports.push(value) }), { code: 'DEVICE_CHECKS_FAILED' })
247
+ assert.equal(reports.find(result => result.check === 'espdl_inference').status, 'FAIL')
248
+ assert.ok(device.calls.some(call => call[1]?.includes("['model_command'](\"close\")")))
249
+ assert.ok(device.calls.some(call => call[1]?.includes(".get('close_model',lambda:None)()")))
250
+ }
251
+ } finally { await rm(directory, { recursive: true, force: true }) }
252
+ })
253
+
254
+ test('model transfer cancellation releases RAM resources with an independent cleanup signal', async () => {
255
+ const directory = await mkdtemp(join(tmpdir(), 'esp-vision-model-'))
256
+ try {
257
+ const model = join(directory, 'model.espdl'), device = devicePeer(), execute = device.python.execute, controller = new AbortController()
258
+ await writeFile(model, Buffer.alloc(1024))
259
+ let cleaned = false
260
+ device.python.execute = async request => {
261
+ if (request.source.includes("['model_command'](\"append\",")) controller.abort(new Error('cancelled transfer'))
262
+ if (request.source.includes("['model_command'](\"close\")")) { assert.equal(request.signal.aborted, false); cleaned = true }
263
+ return execute(request)
264
+ }
265
+ await assert.rejects(runDeviceChecks(device, parseArgs(['--tcp', 'test', '--checks', 'espdl_inference', '--model', model]), { signal: controller.signal, nonce: 'abc123', report() {} }), /cancelled transfer/)
266
+ assert.equal(cleaned, true)
267
+ } finally { await rm(directory, { recursive: true, force: true }) }
268
+ })
269
+
270
+ // Installed npm packages run these tests without the repository's models directory.
271
+ async function modelCase(callback, size = 1024) {
272
+ const directory = await mkdtemp(join(tmpdir(), 'esp-vision-model-ack-'))
273
+ try {
274
+ const model = join(directory, 'model.espdl'), bytes = typeof size === 'number' ? Buffer.alloc(size, 42) : Buffer.from(size)
275
+ await writeFile(model, bytes)
276
+ await callback({ model, bytes, device: devicePeer(), reports: [] })
277
+ } finally { await rm(directory, { recursive: true, force: true }) }
278
+ }
279
+
280
+ test('model upload retries a complete public execute only for uncertain TIMEOUT and reports recovery', async () => {
281
+ for (const executed of [false, true]) await modelCase(async ({ model, device, reports }) => {
282
+ const execute = device.python.execute, attempts = []
283
+ let first = true
284
+ device.python.execute = async request => {
285
+ if (request.source.includes('[\'model_command\']("append",')) {
286
+ attempts.push(request.source)
287
+ if (first) {
288
+ first = false
289
+ if (executed) await execute(request)
290
+ throw Object.assign(new Error('uncertain acknowledgement'), { code: 'TIMEOUT' })
291
+ }
292
+ }
293
+ return execute(request)
294
+ }
295
+ await runDeviceChecks(device, parseArgs(['--tcp', 'test', '--checks', 'espdl_inference', '--model', model, '--require-all']), { nonce: 'acktest', report: value => reports.push(value) })
296
+ assert.equal(attempts[0], attempts[1])
297
+ const data = reports.find(result => result.check === 'espdl_inference').data
298
+ assert.equal(data.modelRetries, 1)
299
+ assert.deepEqual(data.modelRetryOffsets, [{ offset: 0, retries: 1 }])
300
+ assert.equal(attempts.length, data.modelChunks + 1)
301
+ })
302
+ })
303
+
304
+ test('model upload limits timeout attempts to three and never retries cancellation or semantic errors', async () => {
305
+ for (const mode of ['timeout', 'error', 'abort']) await modelCase(async ({ model, device, reports }) => {
306
+ const execute = device.python.execute, controller = new AbortController()
307
+ let attempts = 0
308
+ device.python.execute = async request => {
309
+ if (request.source.includes('[\'model_command\']("append",')) {
310
+ attempts++
311
+ const error = Object.assign(new Error(mode), { code: mode === 'error' ? 'DATA_CONFLICT' : 'TIMEOUT' })
312
+ if (mode === 'abort') controller.abort(error)
313
+ throw error
314
+ }
315
+ return execute(request)
316
+ }
317
+ await assert.rejects(runDeviceChecks(device, parseArgs(['--tcp', 'test', '--checks', 'espdl_inference', '--model', model, '--require-all']), { signal: controller.signal, nonce: 'acktest', report: value => reports.push(value) }))
318
+ assert.equal(attempts, mode === 'timeout' ? 3 : 1)
319
+ })
320
+ })
321
+
322
+ test('every model operation requires a semantic ACK even when Python prints an exception with ok true', async () => {
323
+ for (const operation of ['begin', 'append', 'mount', 'close', 'cleanup']) await modelCase(async ({ model, device, reports }) => {
324
+ const execute = device.python.execute
325
+ let attempts = 0
326
+ device.python.execute = async request => {
327
+ if (request.source.includes(`['model_command']("${operation}"`) || (operation === 'cleanup' && request.source.startsWith('assert '))) {
328
+ attempts++
329
+ return { ok: true, output: 'Traceback (most recent call last):\nOSError: injected failure\n' }
330
+ }
331
+ return execute(request)
332
+ }
333
+ await assert.rejects(runDeviceChecks(device, parseArgs(['--tcp', 'test', '--checks', 'espdl_inference', '--model', model, '--require-all']), { nonce: 'acktest', report: value => reports.push(value) }), { code: 'DEVICE_CHECKS_FAILED' })
334
+ assert.equal(attempts, 1)
335
+ assert.ok(reports.some(result => result.status === 'FAIL'))
336
+ })
337
+ })
338
+
339
+ test('upload ACK rejects stale nonce, wrong offset, duplicate markers and malformed output without retry', async () => {
340
+ for (const invalid of ['nonce', 'offset', 'duplicate', 'malformed']) await modelCase(async ({ model, device, reports }) => {
341
+ const execute = device.python.execute
342
+ let attempts = 0
343
+ device.python.execute = async request => {
344
+ const response = await execute(request)
345
+ if (request.source.includes('[\'model_command\']("append",')) {
346
+ attempts++
347
+ const ack = JSON.parse(response.output.slice('__EVHILACK__'.length))
348
+ if (invalid === 'nonce') ack[0] = 'stale'
349
+ if (invalid === 'offset') ack[2].current++
350
+ response.output = '__EVHILACK__' + JSON.stringify(ack)
351
+ if (invalid === 'duplicate') response.output += '\n' + response.output
352
+ if (invalid === 'malformed') response.output = '__EVHILACK__broken'
353
+ }
354
+ return response
355
+ }
356
+ await assert.rejects(runDeviceChecks(device, parseArgs(['--tcp', 'test', '--checks', 'espdl_inference', '--model', model]), { nonce: 'acktest', report: value => reports.push(value) }), { code: 'DEVICE_CHECKS_FAILED' })
357
+ assert.equal(attempts, 1)
358
+ assert.equal(reports.find(result => result.check === 'espdl_inference').error.code, 'MODEL_ACK_INVALID')
359
+ })
360
+ })
361
+
362
+ test('begin timeout before its body preserves the original error and still acknowledges empty cleanup', async () => {
363
+ await modelCase(async ({ model, device, reports }) => {
364
+ const execute = device.python.execute
365
+ device.python.execute = async request => {
366
+ if (request.source.includes('[\'model_command\']("begin",')) throw Object.assign(new Error('begin preparation timeout'), { code: 'TIMEOUT' })
367
+ return execute(request)
368
+ }
369
+ await assert.rejects(runDeviceChecks(device, parseArgs(['--tcp', 'test', '--checks', 'espdl_inference', '--model', model]), { nonce: 'acktest', report: value => reports.push(value) }), { code: 'DEVICE_CHECKS_FAILED' })
370
+ assert.equal(reports.find(result => result.check === 'espdl_inference').error.code, 'TIMEOUT')
371
+ assert.equal(reports.at(-1).failed, 1)
372
+ })
373
+ })
374
+
375
+ test('full-sized model upload reserves the largest offset and final block within both live REPL budgets', async () => {
376
+ const modelBytes = existsSync(DEFAULT_ESPDL_MODEL) ? await readFile(DEFAULT_ESPDL_MODEL) : Buffer.alloc(495840, 42)
377
+ for (const limit of [256, 511]) await modelCase(async ({ model, device, reports, bytes }) => {
378
+ const inspect = device.inspect
379
+ device.inspect = async request => { const result = await inspect(request); result.capabilities.limits.replInputMax = limit; return result }
380
+ try { await runDeviceChecks(device, parseArgs(['--tcp', 'test', '--checks', 'espdl_inference', '--model', model]), { nonce: '0123456789abcdef', report: value => reports.push(value) }) }
381
+ catch { assert.fail(JSON.stringify({ limit, reports })) }
382
+ const commands = device.calls.filter(call => call[0] === 'execute').map(call => call[1])
383
+ assert.ok(commands.every(source => evReplExecInputBytes(source) <= limit))
384
+ const chunks = commands.filter(source => source.includes('[\'model_command\']("append",')).map(source => JSON.parse('[' + source.slice(source.indexOf('](') + 2, -1) + ']'))
385
+ let offset = 0
386
+ for (const [, actualOffset, encoded] of chunks) { assert.equal(actualOffset, offset); offset += Buffer.from(encoded, 'base64').length }
387
+ assert.equal(offset, bytes.length)
388
+ assert.deepEqual(Buffer.concat(chunks.map(chunk => Buffer.from(chunk[2], 'base64'))), bytes)
389
+ assert.equal(reports.find(result => result.check === 'espdl_inference').data.modelChunks, limit === 511 ? 1517 : 3673)
390
+ }, modelBytes)
391
+ })
392
+
393
+ test('repository checkouts provide the default ESP-DL model', { skip: !existsSync(new URL('../../../models/', import.meta.url)) }, async () => {
394
+ const bytes = await readFile(DEFAULT_ESPDL_MODEL)
395
+ assert.equal(bytes.length, 495840)
396
+ assert.equal(createHash('sha256').update(bytes).digest('hex'), DEFAULT_ESPDL_SHA256)
397
+ })
398
+
399
+ test('ISP cleanup releases camera and collects memory even when display deinit fails', t => {
400
+ const python = [process.env.PYTHON, 'python3', 'python'].filter(Boolean).find(command => spawnSync(command, ['-c', 'pass'], { windowsHide: true }).status === 0)
401
+ if (!python) return t.skip('Python is required to execute the device-side cleanup fixture')
402
+ const source = `
403
+ import runpy, sys, types
404
+ fixture = runpy.run_path(sys.argv[1])
405
+ for display_fails, camera_fails in ((False, False), (True, False), (False, True), (True, True)):
406
+ events = []
407
+ class Display:
408
+ def deinit(self):
409
+ events.append('display')
410
+ if display_fails:
411
+ raise RuntimeError('display cleanup failed')
412
+ def shutdown():
413
+ events.append('camera')
414
+ if camera_fails:
415
+ raise RuntimeError('camera cleanup failed')
416
+ sys.modules['sensor'] = types.SimpleNamespace(shutdown=shutdown)
417
+ sys.modules['gc'] = types.SimpleNamespace(collect=lambda: events.append('collect'))
418
+ try:
419
+ fixture['isp_release_camera_lcd'](Display())
420
+ except RuntimeError:
421
+ assert display_fails or camera_fails
422
+ else:
423
+ assert not display_fails and not camera_fails
424
+ assert events == ['display', 'camera', 'collect'], events
425
+ `
426
+ const result = spawnSync(python, ['-c', source, fileURLToPath(new URL('./fixtures/device-checks.py', import.meta.url))], { encoding: 'utf8', windowsHide: true })
427
+ assert.equal(result.status, 0, result.stderr)
428
+ })
@@ -10,6 +10,37 @@ function fixture(options = {}) {
10
10
  return new DeviceSession({ transport: { async writeBytes() {} }, EvMuxParser, makeEvMuxFrame, EvRepl, ...options })
11
11
  }
12
12
 
13
+ async function flushMicrotasks() {
14
+ for (let i = 0; i < 20; i++) await Promise.resolve()
15
+ }
16
+
17
+ async function advance(t, ms) {
18
+ t.mock.timers.tick(ms)
19
+ await flushMicrotasks()
20
+ }
21
+
22
+ function startupFixture(t, { signal, progressMs = 12000, totalMs = 12000 } = {}) {
23
+ t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 0 })
24
+ const stats = { writes: 0, requests: 0, disconnects: 0, settled: false, firstRequestAt: null }
25
+ const diagnostics = []
26
+ const session = fixture({
27
+ transport: { async writeBytes() { stats.writes += 1 }, async disconnect() { stats.disconnects += 1 } },
28
+ handlers: { diagnostic: event => diagnostics.push(event) },
29
+ })
30
+ session.parser.setLimits({ frameProgressTimeoutMs: progressMs, frameTotalTimeoutMs: totalMs })
31
+ session.rpc = async () => {
32
+ stats.requests += 1
33
+ stats.firstRequestAt = Date.now()
34
+ throw new Error('first request reached')
35
+ }
36
+ const result = session.negotiate({ role: 'usb', signal }).then(
37
+ () => { throw new Error('unexpected successful fixture negotiation') },
38
+ error => { stats.settled = true; return error },
39
+ )
40
+ t.after(() => { session.close(); session.parser.reset() })
41
+ return { session, stats, diagnostics, result }
42
+ }
43
+
13
44
  test('Unicode Python is ASCII on the wire and length preflight includes escapes', async () => {
14
45
  let command
15
46
  const repl = new EvRepl({ writeEvmux() {} })
@@ -73,16 +104,239 @@ test('startup drain parses split frames without adopting old sessions', async ()
73
104
  session.close()
74
105
  })
75
106
 
76
- test('unfinished startup frames close the transport before any request', async () => {
77
- let session, writes = 0, disconnects = 0
107
+ test('startup waits for a valid frame split across 299/301 ms without accepting old sessions', async t => {
108
+ const f = startupFixture(t)
109
+ const frame = makeEvMuxFrame({ channel: 'user.rpc', type: 'event', method: 'hello',
110
+ sessionId: 'a'.repeat(32), vmEpoch: 9,
111
+ payload: JSON.stringify({ evMuxVersion: 4, session: { id: 'a'.repeat(32), vmEpoch: 9 }, vmEpoch: 9 }) })
112
+ await advance(t, 299)
113
+ f.session.feedBytes(frame.subarray(0, 20))
114
+ await advance(t, 1)
115
+ assert.equal(f.stats.settled, false)
116
+ assert.equal(f.stats.requests, 0)
117
+ assert.equal(f.session.draining, true)
118
+ assert.equal(f.session.sessionId, null)
119
+ assert.equal(f.session.vmEpoch, null)
120
+ const snapshot = f.session.parser.getDiagnostics()
121
+ assert.equal(snapshot.partialFrame.startedAt, 299)
122
+ snapshot.partialFrame.totalDeadline = 0
123
+ assert.equal(f.session.parser.getDiagnostics().partialFrame.totalDeadline, 12299)
124
+ await advance(t, 1)
125
+ const staleLog = makeEvMuxFrame({ channel: 'log.idf', payload: 'old startup text' })
126
+ f.session.feedBytes(Buffer.concat([frame.subarray(20), staleLog]))
127
+ assert.match((await f.result).message, /first request reached/)
128
+ assert.equal(f.stats.firstRequestAt, 301)
129
+ assert.equal(f.stats.disconnects, 0)
130
+ assert.equal(f.session.sessionId, null)
131
+ assert.equal(f.session.vmEpoch, null)
132
+ assert.equal(f.session.parser.getDiagnostics().droppedFrames, 2)
133
+ assert.equal(f.session.parser.getDiagnostics().bufferedBytes, 0)
134
+ assert.equal(f.session.startupDrainWait, null)
135
+ assert.equal(f.session.draining, false)
136
+ })
137
+
138
+ test('a frame complete before 300 ms adds no startup wait', async t => {
139
+ const f = startupFixture(t)
140
+ await advance(t, 299)
141
+ f.session.feedBytes(makeEvMuxFrame({ channel: 'log.idf', payload: 'complete' }))
142
+ await advance(t, 1)
143
+ assert.match((await f.result).message, /first request reached/)
144
+ assert.equal(f.stats.firstRequestAt, 300)
145
+ assert.equal(f.session.startupDrainWait, null)
146
+ assert.equal(f.diagnostics.some(event => event.event === 'negotiate.startup_frame_wait'), false)
147
+ })
148
+
149
+ for (const [kind, makePartial] of [
150
+ ['header', frame => frame.subarray(0, 20)],
151
+ ['body', frame => frame.subarray(0, -1)],
152
+ ]) {
153
+ test(`a truncated startup ${kind} times out before any request, even after the parser empties it`, async t => {
154
+ const f = startupFixture(t, { progressMs: 500, totalMs: 2000 })
155
+ const frame = makeEvMuxFrame({ channel: 'user.rpc', type: 'event', method: 'hello', payload: '{}' })
156
+ await advance(t, 299)
157
+ f.session.feedBytes(makePartial(frame))
158
+ await advance(t, 1)
159
+ assert.equal(f.session.draining, true)
160
+ assert.equal(f.stats.requests, 0)
161
+ await advance(t, 498)
162
+ assert.equal(f.stats.settled, false)
163
+ await advance(t, 1)
164
+ assert.equal((await f.result).code, 'SERIAL_STARTUP_INCOMPLETE_FRAME')
165
+ assert.equal(f.stats.requests, 0)
166
+ assert.equal(f.stats.writes, 0)
167
+ assert.equal(f.stats.disconnects, 1)
168
+ assert.equal(f.session.state, 'disconnected')
169
+ assert.equal(f.session.startupDrainWait, null)
170
+ const error = f.diagnostics.find(event => event.event === 'negotiate.startup_incomplete')
171
+ assert.equal(error.parser.incompleteFrameTimeouts, 1)
172
+ assert.equal(error.parser.bufferedBytes, 0)
173
+ })
174
+ }
175
+
176
+ test('successive coalesced startup frames cannot extend the initial partial-frame deadline', async t => {
177
+ const f = startupFixture(t, { progressMs: 1000, totalMs: 500 })
178
+ const frame = makeEvMuxFrame({ channel: 'log.idf', payload: 'ongoing output' })
179
+ await advance(t, 299)
180
+ f.session.feedBytes(frame.subarray(0, 20))
181
+ await advance(t, 1)
182
+ await advance(t, 1)
183
+ f.session.feedBytes(Buffer.concat([frame.subarray(20), frame.subarray(0, 20)]))
184
+ await advance(t, 299)
185
+ f.session.feedBytes(Buffer.concat([frame.subarray(20), frame.subarray(0, 20)]))
186
+ await advance(t, 198)
187
+ assert.equal(f.stats.settled, false)
188
+ assert.equal(f.session.draining, true)
189
+ assert.equal(f.stats.requests, 0)
190
+ await advance(t, 1)
191
+ assert.equal((await f.result).code, 'SERIAL_STARTUP_INCOMPLETE_FRAME')
192
+ assert.equal(Date.now(), 799)
193
+ assert.equal(f.stats.requests, 0)
194
+ assert.equal(f.stats.writes, 0)
195
+ assert.equal(f.stats.disconnects, 1)
196
+ const error = f.diagnostics.find(event => event.event === 'negotiate.startup_incomplete')
197
+ assert.equal(error.parser.partialFrame.startedAt, 600)
198
+ assert.equal(error.parser.incompleteFrameTimeouts, 0)
199
+ })
200
+
201
+ test('back-to-back adapter notifications cannot send hello between a completed tail and another prefix', async t => {
202
+ const f = startupFixture(t)
203
+ const frame = makeEvMuxFrame({ channel: 'log.idf', payload: 'adjacent chunks' })
204
+ await advance(t, 299)
205
+ f.session.feedBytes(frame.subarray(0, 20))
206
+ await advance(t, 1)
207
+ await advance(t, 1)
208
+ f.session.feedBytes(frame.subarray(20))
209
+ f.session.feedBytes(frame.subarray(0, 20))
210
+ await flushMicrotasks()
211
+ assert.equal(f.stats.requests, 0)
212
+ assert.equal(f.session.draining, true)
213
+ assert.notEqual(f.session.startupDrainWait, null)
214
+ await advance(t, 1)
215
+ f.session.feedBytes(frame.subarray(20))
216
+ assert.match((await f.result).message, /first request reached/)
217
+ assert.equal(f.stats.firstRequestAt, 302)
218
+ assert.equal(f.session.startupDrainWait, null)
219
+ })
220
+
221
+ test('actual startup progress refreshes the idle budget within the fixed total deadline', async t => {
222
+ const f = startupFixture(t, { progressMs: 100, totalMs: 2000 })
223
+ const frame = makeEvMuxFrame({ channel: 'log.idf', payload: 'progress' })
224
+ await advance(t, 299)
225
+ f.session.feedBytes(frame.subarray(0, 20))
226
+ await advance(t, 1)
227
+ await advance(t, 50)
228
+ f.session.feedBytes(frame.subarray(20, 21))
229
+ await advance(t, 99)
230
+ assert.equal(f.stats.settled, false)
231
+ await advance(t, 1)
232
+ assert.equal((await f.result).code, 'SERIAL_STARTUP_INCOMPLETE_FRAME')
233
+ assert.equal(Date.now(), 450)
234
+ assert.equal(f.stats.requests, 0)
235
+ assert.equal(f.stats.disconnects, 1)
236
+ })
237
+
238
+ test('a receive timeout inside the initial 300 ms cannot become an empty successful drain', async t => {
239
+ const f = startupFixture(t, { progressMs: 100, totalMs: 1000 })
240
+ await advance(t, 1)
241
+ f.session.feedBytes(new TextEncoder().encode('\x1eEVMUX/1 h='))
242
+ await advance(t, 100)
243
+ assert.equal(f.session.parser.hasPartialFrame(), false)
244
+ assert.equal(f.session.parser.getDiagnostics().incompleteFrameTimeouts, 1)
245
+ await advance(t, 199)
246
+ assert.equal((await f.result).code, 'SERIAL_STARTUP_INCOMPLETE_FRAME')
247
+ assert.equal(f.stats.requests, 0)
248
+ assert.equal(f.stats.writes, 0)
249
+ assert.equal(f.stats.disconnects, 1)
250
+ })
251
+
252
+ test('a complete tail received after an expired idle budget cannot bypass the queued timeout', async t => {
253
+ const f = startupFixture(t, { progressMs: 100, totalMs: 2000 })
254
+ const frame = makeEvMuxFrame({ channel: 'log.idf', payload: 'late tail' })
255
+ await advance(t, 299)
256
+ f.session.feedBytes(frame.subarray(0, 20))
257
+ await advance(t, 1)
258
+ // Model a busy event loop: the wall clock passes the idle deadline before
259
+ // either timer callback runs, then an already queued read delivers the tail.
260
+ t.mock.timers.setTime(400)
261
+ f.session.feedBytes(frame.subarray(20))
262
+ assert.equal((await f.result).code, 'SERIAL_STARTUP_INCOMPLETE_FRAME')
263
+ assert.equal(f.stats.requests, 0)
264
+ assert.equal(f.stats.writes, 0)
265
+ assert.equal(f.stats.disconnects, 1)
266
+ })
267
+
268
+ test('dripping bytes of one startup frame cannot extend its total budget', async t => {
269
+ const f = startupFixture(t, { progressMs: 150, totalMs: 400 })
270
+ const frame = makeEvMuxFrame({ channel: 'log.idf', payload: 'progress' })
271
+ await advance(t, 299)
272
+ f.session.feedBytes(frame.subarray(0, 20))
273
+ await advance(t, 1)
274
+ for (let i = 0; i < 3; i++) {
275
+ await advance(t, 100)
276
+ f.session.feedBytes(frame.subarray(20 + i, 21 + i))
277
+ }
278
+ await advance(t, 98)
279
+ assert.equal(f.stats.settled, false)
280
+ await advance(t, 1)
281
+ assert.equal((await f.result).code, 'SERIAL_STARTUP_INCOMPLETE_FRAME')
282
+ assert.equal(Date.now(), 699)
283
+ assert.equal(f.stats.requests, 0)
284
+ assert.equal(f.stats.writes, 0)
285
+ assert.equal(f.stats.disconnects, 1)
286
+ })
287
+
288
+ for (const action of ['cancel', 'close']) {
289
+ test(`${action} promptly clears the startup boundary waiter without sending a request`, async t => {
290
+ const controller = new AbortController()
291
+ const f = startupFixture(t, { signal: controller.signal })
292
+ await advance(t, 299)
293
+ f.session.feedBytes(new TextEncoder().encode('\x1eEVMUX/1 h='))
294
+ await advance(t, 1)
295
+ assert.notEqual(f.session.startupDrainWait, null)
296
+ const reason = new Error(`test ${action}`)
297
+ if (action === 'cancel') controller.abort(reason)
298
+ else f.session.close(reason)
299
+ assert.equal(await f.result, reason)
300
+ assert.equal(f.session.startupDrainWait, null)
301
+ assert.equal(f.session.draining, false)
302
+ assert.equal(f.session.negotiateController, null)
303
+ await advance(t, 13000)
304
+ assert.equal(f.stats.requests, 0)
305
+ assert.equal(f.stats.writes, 0)
306
+ assert.equal(f.diagnostics.some(event => event.event === 'negotiate.startup_incomplete'), false)
307
+ })
308
+ }
309
+
310
+ test('injected legacy parsers keep the conservative incomplete-frame failure without private budget methods', async () => {
311
+ class LegacyParser {
312
+ constructor(handlers) { this.parser = new EvMuxParser(handlers) }
313
+ feedBytes(bytes) { this.parser.feedBytes(bytes) }
314
+ hasPartialFrame() { return this.parser.hasPartialFrame() }
315
+ reset() { this.parser.reset() }
316
+ }
317
+ let session, disconnects = 0, requests = 0
78
318
  session = fixture({
79
- transport: { async writeBytes() { writes += 1 }, async disconnect() { disconnects += 1 } },
80
- sleep: async () => { session.feedBytes(new TextEncoder().encode('\x1eEVMUX/1 h=100 p=0 c=00000000\r\n{')) },
319
+ EvMuxParser: LegacyParser,
320
+ transport: { async writeBytes() {}, async disconnect() { disconnects += 1 } },
321
+ sleep: async () => session.feedBytes(new TextEncoder().encode('\x1eEVMUX/1 h=')),
81
322
  })
82
- await assert.rejects(session.negotiate({ drainMs: 300 }), error => error.code === 'SERIAL_STARTUP_INCOMPLETE_FRAME')
83
- assert.equal(writes, 0)
323
+ session.rpc = async () => { requests += 1; throw new Error('unexpected request') }
324
+ await assert.rejects(session.negotiate(), error => error.code === 'SERIAL_STARTUP_INCOMPLETE_FRAME')
325
+ assert.equal(requests, 0)
84
326
  assert.equal(disconnects, 1)
85
- assert.equal(session.state, 'disconnected')
327
+ assert.equal(session.startupDrainWait, null)
328
+ session.close()
329
+ })
330
+
331
+ test('injected parsers without optional startup diagnostics or boundary methods remain supported', async () => {
332
+ class MinimalParser extends EvMuxParser {
333
+ constructor(handlers) { super(handlers); this.hasPartialFrame = undefined; this.getDiagnostics = undefined }
334
+ }
335
+ const session = fixture({ EvMuxParser: MinimalParser, sleep: async () => {} })
336
+ session.rpc = async () => { throw new Error('first request reached') }
337
+ await assert.rejects(session.negotiate(), /first request reached/)
338
+ session.close()
339
+ session.parser.reset()
86
340
  })
87
341
 
88
342
  test('plain console tails do not fail drain and TCP does not wait for drain', async () => {
@@ -1,9 +1,9 @@
1
1
  import assert from 'node:assert/strict'
2
2
  import { test } from 'node:test'
3
3
  import { FirmwareFlashSession, firmwareSha256, validateFirmwareImage } from '../src/firmware_flash.js'
4
- async function fixture() {
4
+ async function fixture(chipFamily = 0x3280) {
5
5
  const bytes = new Uint8Array([0xe9, 1, 2, 3]), table = new Uint8Array([4, 5, 6]), calls = []
6
- const image = { evMuxVersion: 4, chipFamily: 0x3280, chipRevision: 302, offset: 0x10000, bytes: 4, partitionSize: 0x700000, sha256: await firmwareSha256(bytes), partitionTable: { offset: 0x8000, bytes: 3, sha256: await firmwareSha256(table) } }
6
+ const image = { evMuxVersion: 4, chipFamily, chipRevision: chipFamily === 0x3231 ? 0 : 302, offset: 0x10000, bytes: 4, partitionSize: 0x700000, sha256: await firmwareSha256(bytes), partitionTable: { offset: 0x8000, bytes: 3, sha256: await firmwareSha256(table) } }
7
7
  const stub = { async readFlash(offset) { calls.push(['read', offset]); return offset === 0x8000 ? table : bytes }, async readRegister() { return 0x1dfbc7f8 }, async writeRegister() { throw new Error('unexpected register write') }, async flashData(_data, progress, offset, compress) { calls.push(['write', offset, compress]); progress(4, 4) } }
8
8
  const loader = { chipFamily: image.chipFamily, chipRevision: image.chipRevision, async initialize() { calls.push(['initialize']) }, async getSecurityInfo() { return { flags: 0, flashCryptCnt: 0 } }, async runStub(skip) { calls.push(['stub', skip]); return stub }, async readRegister() { return 0x1dfbc7f8 }, async writeRegister() { throw new Error('unexpected register write') }, async hardReset() { calls.push(['reset']) }, async disconnect() { calls.push(['close']) } }
9
9
  return { bytes, table, image, calls, loader, stub, session: new FirmwareFlashSession(loader, image) }
@@ -69,21 +69,23 @@ test('unsigned application updates refuse secure boot and flash encryption befor
69
69
  })
70
70
 
71
71
 
72
- test('P4 software download latch is cleared with a mask and verified before reset', async () => {
73
- const f = await fixture(), registers = []; let register = 0x1dfbc7fc
74
- f.stub.readRegister = async address => { registers.push(['read', address]); return register }
75
- f.stub.writeRegister = async (address, value, mask) => { registers.push(['write', address, value, mask]); register &= ~mask }
76
- f.loader.hardReset = async () => { assert.equal(register, 0x1dfbc7f8); f.calls.push(['reset']) }
77
- await f.session.connect(f.bytes); await f.session.flash(f.bytes)
78
- assert.equal(register, 0x1dfbc7f8)
79
- assert.deepEqual(registers, [['read', 0x50110008], ['write', 0x50110008, 0, 4], ['read', 0x50110008]])
80
- assert.ok(f.calls.some(([operation]) => operation === 'reset'))
81
- })
82
- test('P4 latch clear failure does not reset and retains manual recovery guidance', async () => {
83
- const f = await fixture(), messages = []
84
- f.stub.readRegister = async () => 0x1dfbc7fc
85
- f.stub.writeRegister = async () => {}
86
- await f.session.connect(f.bytes); await f.session.flash(f.bytes, { onPhase: message => messages.push(message) })
87
- assert.ok(!f.calls.some(([operation]) => operation === 'reset'))
88
- assert.match(messages.at(-1), /verified.*RESET/)
89
- })
72
+ for (const [name, family, address] of [['P4', 0x3280, 0x50110008], ['S31', 0x3231, 0x20700008]]) {
73
+ test(`${name} software download latch is cleared with a mask and verified before reset`, async () => {
74
+ const f = await fixture(family), registers = []; let register = 0x1dfbc7fc
75
+ f.stub.readRegister = async address => { registers.push(['read', address]); return register }
76
+ f.stub.writeRegister = async (address, value, mask) => { registers.push(['write', address, value, mask]); register &= ~mask }
77
+ f.loader.hardReset = async () => { assert.equal(register, 0x1dfbc7f8); f.calls.push(['reset']) }
78
+ await f.session.connect(f.bytes); await f.session.flash(f.bytes)
79
+ assert.equal(register, 0x1dfbc7f8)
80
+ assert.deepEqual(registers, [['read', address], ['write', address, 0, 4], ['read', address]])
81
+ assert.ok(f.calls.some(([operation]) => operation === 'reset'))
82
+ })
83
+ test(`${name} latch clear failure does not reset and retains manual recovery guidance`, async () => {
84
+ const f = await fixture(family), messages = []
85
+ f.stub.readRegister = async () => 0x1dfbc7fc
86
+ f.stub.writeRegister = async () => {}
87
+ await f.session.connect(f.bytes); await f.session.flash(f.bytes, { onPhase: message => messages.push(message) })
88
+ assert.ok(!f.calls.some(([operation]) => operation === 'reset'))
89
+ assert.match(messages.at(-1), /verified.*RESET/)
90
+ })
91
+ }