@esp-vision/device-core 0.6.0 → 0.6.1

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.1
4
+
5
+ ### Fixed
6
+
7
+ - Accept native Windows CRLF startup lines in the direct Python serial-failure regression so its diagnostic and payload-redaction assertions run on Windows.
8
+
9
+ - Preserve bounded serial read/write OS diagnostics and unexpected Python bridge exit codes/signals instead of reducing them to CONNECTION_CLOSED; omit arbitrary exception text and all transferred bytes, and keep intentional disconnect/cancellation quiet.
10
+
11
+ - Run package and release checks through npm's JavaScript entry point on Windows, preserving paths and arguments without a command shell; accept checkout CRLF in the generated action-schema check while still rejecting changed schema content.
12
+ - Observe native USB re-enumeration for up to five seconds after read-only ROM initialization failure, requiring the same MAC and physical location before one verified runtime handoff; cancellation, ambiguous descriptors and failed reads/writes cannot replay operations.
13
+ - Forward Node readback packet budgets to the external esptool 5.6.0 reader, using one outstanding 1 KiB packet by default and preserving its ACK/MD5 verification. Reject incompatible versions before initialization or writing and restore temporary instance settings after success or failure.
14
+
3
15
  ## 0.6.0
4
16
 
5
17
  ### Added
package/README.md CHANGED
@@ -52,7 +52,7 @@ esp-vision-device inspect --port COM3 --fields identity,capabilities
52
52
  esp-vision-device inspect --tcp 192.168.1.100:3232 --fields network,storage --json
53
53
  ```
54
54
 
55
- Wired access/enumeration requires Python with pyserial (`--python` overrides the interpreter); LAN discovery uses optional `multicast-dns`, normally installed by npm. Windows/Linux/macOS paths are accepted. Passive enumeration never opens a serial port; `--probe` and targeted queries acquire a session and may interrupt Python. Unidentified ports, ROM candidates and USB-to-UART adapters remain numbered and visible. Board names are unknown until firmware identification; USB descriptors are hints only. Firmware versions are fetched automatically from dl.espressif.com. Firmware flashing uses the built-in serial adapter with separately installed Python esptool>=5.3,<6; no user loader module is required. Use `firmware update --mode install --board BOARD --port PORT --version VERSION` for blank-device installation over a ROM-capable USJ/UART port. Install replaces data within the complete image range; default updates preserve the existing layout. Historical releases without qualified metadata remain listed but cannot be installed by this flow. Check the registry version before installing a newly prepared release.
55
+ Wired access/enumeration requires Python with pyserial (`--python` overrides the interpreter); LAN discovery uses optional `multicast-dns`, normally installed by npm. Windows/Linux/macOS paths are accepted. Passive enumeration never opens a serial port; `--probe` and targeted queries acquire a session and may interrupt Python. Unidentified ports, ROM candidates and USB-to-UART adapters remain numbered and visible. Board names are unknown until firmware identification; USB descriptors are hints only. Firmware versions are fetched automatically from dl.espressif.com. Firmware flashing uses the built-in serial adapter with separately installed Python esptool==5.6.0; no user loader module is required. Use `firmware update --mode install --board BOARD --port PORT --version VERSION` for blank-device installation over a ROM-capable USJ/UART port. Install replaces data within the complete image range; default updates preserve the existing layout. Historical releases without qualified metadata remain listed but cannot be installed by this flow. Check the registry version before installing a newly prepared release.
56
56
 
57
57
  ## Public device API
58
58
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@esp-vision/device-core",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Shared EV-MUX, framed REPL, device session, and action contracts for ESP Vision clients.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -78,5 +78,5 @@
78
78
  "devDependencies": {
79
79
  "esbuild": "0.25.12"
80
80
  },
81
- "espVisionReleaseType": "minor"
81
+ "espVisionReleaseType": "patch"
82
82
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "title": "ESP Vision Device Actions",
4
- "deviceCoreVersion": "0.6.0",
4
+ "deviceCoreVersion": "0.6.1",
5
5
  "schemaVersion": 1,
6
6
  "actions": [
7
7
  {
@@ -27,7 +27,8 @@ const document = {
27
27
  const serialized = `${JSON.stringify(document, null, 2)}\n`
28
28
 
29
29
  if (process.argv.includes('--check')) {
30
- assert.equal(await readFile(outputPath, 'utf8'), serialized, 'Device Action schema artifact is stale; regenerate it')
30
+ const checkedIn = (await readFile(outputPath, 'utf8')).replaceAll('\r\n', '\n')
31
+ assert.equal(checkedIn, serialized, 'Device Action schema artifact is stale; regenerate it')
31
32
  console.log(`Device Action Schema ${DEVICE_ACTION_SCHEMA_VERSION} is current`)
32
33
  } else {
33
34
  await mkdir(dirname(outputPath), { recursive: true })
@@ -0,0 +1,17 @@
1
+ // SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { existsSync } from 'node:fs'
4
+ import { dirname, join } from 'node:path'
5
+ import process from 'node:process'
6
+
7
+ // npm.cmd cannot be passed to execFile/spawn without a Windows shell. Invoke
8
+ // npm's JavaScript entry point instead, preserving paths and arguments verbatim.
9
+ export function npmCommand(args, env = process.env) {
10
+ if (env.npm_execpath) return [process.execPath, [env.npm_execpath, ...args]]
11
+ if (process.platform === 'win32') {
12
+ const bundled = join(dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js')
13
+ if (!existsSync(bundled)) throw new Error('Run this check through npm run so npm_execpath identifies npm')
14
+ return [process.execPath, [bundled, ...args]]
15
+ }
16
+ return ['npm', args]
17
+ }
@@ -3,6 +3,7 @@
3
3
  import assert from 'node:assert/strict'
4
4
  import { highestStableVersion, validateVersionStep } from './version-policy.mjs'
5
5
  import { verifyPublishedIntegrity } from './verify-registry.mjs'
6
+ import { npmCommand } from './npm-command.mjs'
6
7
  import { spawnSync } from 'node:child_process'
7
8
  import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises'
8
9
  import { tmpdir } from 'node:os'
@@ -48,7 +49,7 @@ try {
48
49
  await writeFile(config, `registry=${registry}\n//registry.npmjs.org/:_authToken=\${NPM_PUBLISH_TOKEN}\n`, { mode: 0o600 })
49
50
  const env = dryRun ? { ...process.env } : { ...process.env, NPM_CONFIG_USERCONFIG: config }
50
51
  function npm(args) {
51
- const result = spawnSync('npm', args, { cwd: root, env, encoding: 'utf8' })
52
+ const result = spawnSync(...npmCommand(args, env), { cwd: root, env, encoding: 'utf8' })
52
53
  if (result.status !== 0) throw new Error(`npm ${args[0]} failed: ${result.stderr || result.error?.message}`)
53
54
  return result.stdout
54
55
  }
package/src/node_rom.js CHANGED
@@ -11,7 +11,7 @@ export function openNodeRom({ port, python = defaultPython, signal } = {}, depen
11
11
  const lifecycle = new AbortController()
12
12
  signal = signal ? AbortSignal.any([signal, lifecycle.signal]) : lifecycle.signal
13
13
  let child, input, pending, sequence = 0, closed = false, initializing = false, romReady = false
14
- const fail = code => Object.assign(new Error(code === 'ESPTOOL_UNAVAILABLE' ? 'Install Python esptool>=5.3,<6 for the built-in flasher' : 'ROM operation failed'), { code })
14
+ const fail = code => Object.assign(new Error(code === 'ESPTOOL_UNAVAILABLE' ? 'Install Python esptool==5.6.0 for the built-in flasher' : 'ROM operation failed'), { code })
15
15
  const reject = code => { if (pending) { clearTimeout(pending.timer); pending.reject(fail(code)); pending = null } }
16
16
  function rpc(method, args = []) {
17
17
  if (closed) return Promise.reject(fail('ROM_CLOSED'))
@@ -98,7 +98,21 @@ export function openNodeRom({ port, python = defaultPython, signal } = {}, depen
98
98
  flashId: () => rpc('flashId'),
99
99
  getSecurityInfo: () => rpc('security'),
100
100
  async runStub() { await rpc('stub'); return loader },
101
- async readFlash(offset, bytes) { return new Uint8Array(Buffer.from(await rpc('read', [offset, bytes]), 'base64')) },
101
+ async readFlash(offset, bytes, progress, options) {
102
+ if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isSafeInteger(bytes) || bytes <= 0 || bytes > 0xffffffff
103
+ || offset + bytes > 0x100000000) throw new RangeError('ROM read requires a positive 32-bit address range')
104
+ if (progress !== undefined && typeof progress !== 'function') throw new TypeError('ROM read progress must be a function')
105
+ if (options !== undefined && (!options || typeof options !== 'object' || Array.isArray(options))) throw new TypeError('ROM read options must be an object')
106
+ if (options && Object.keys(options).some(key => !['blockSize', 'maxInFlight', 'chunkSize'].includes(key))) throw new TypeError('Unsupported ROM read option')
107
+ const blockSize = options?.blockSize === undefined ? 1024 : options.blockSize
108
+ const maxInFlight = options?.maxInFlight === undefined ? 1 : options.maxInFlight
109
+ if (![1024, 4096].includes(blockSize) || !Number.isSafeInteger(maxInFlight) || maxInFlight < 1 || maxInFlight > 64) throw new RangeError('ROM read requires 1024 or 4096 byte blocks and 1..64 outstanding packets')
110
+ if (options?.chunkSize !== undefined && (!Number.isSafeInteger(options.chunkSize) || options.chunkSize <= 0 || bytes > options.chunkSize)) throw new RangeError('ROM read exceeds the requested chunk size')
111
+ const data = new Uint8Array(Buffer.from(await rpc('read', [offset, bytes, { blockSize, maxInFlight }]), 'base64'))
112
+ if (data.length !== bytes) throw Object.assign(fail('ROM_READ_LENGTH'), { operation: 'read' })
113
+ progress?.(data, data.length, bytes)
114
+ return data
115
+ },
102
116
  async flashData(bytes, progress, offset) { await rpc('write', [offset, Buffer.from(bytes).toString('base64')]); progress(bytes.byteLength, bytes.byteLength) },
103
117
  readRegister: address => rpc('readRegister', [address]),
104
118
  writeRegister: (address, value, mask) => rpc('writeRegister', [address, value, mask]),
package/src/node_rom.py CHANGED
@@ -5,6 +5,7 @@ import base64
5
5
  import contextlib
6
6
  import json
7
7
  import os
8
+ import struct
8
9
  import sys
9
10
 
10
11
 
@@ -59,12 +60,73 @@ def _open_rom(esptool, port):
59
60
  first_error = error
60
61
 
61
62
 
63
+
64
+ def _read_flash(esp, offset, length, options=None):
65
+ """Bound the upstream stub reader without replacing its ACK/MD5 parser."""
66
+ if type(offset) is not int or offset < 0 or type(length) is not int or length <= 0 or length > 0xffffffff or offset + length > 0x100000000:
67
+ raise ValueError('ROM read requires a positive 32-bit address range')
68
+ if options is None:
69
+ options = {}
70
+ if not isinstance(options, dict) or set(options) - {'blockSize', 'maxInFlight'}:
71
+ raise ValueError('Unsupported ROM read options')
72
+ block_size, max_in_flight = options.get('blockSize', 1024), options.get('maxInFlight', 1)
73
+ if type(block_size) is not int or block_size not in (1024, 4096) or type(max_in_flight) is not int or not 1 <= max_in_flight <= 64:
74
+ raise ValueError('ROM read requires 1024 or 4096 byte blocks and 1..64 outstanding packets')
75
+ if not getattr(esp, 'IS_STUB', False):
76
+ raise RuntimeError('Bounded Flash read requires an initialized flasher stub')
77
+ if getattr(esp, 'FLASH_SECTOR_SIZE', None) != 4096 or not callable(getattr(esp, 'check_command', None)):
78
+ raise RuntimeError('Unsupported esptool stub read layout; use esptool==5.6.0')
79
+ command = esp.ESP_CMDS['READ_FLASH']
80
+ original_check = esp.check_command
81
+ missing = object()
82
+ old_check = vars(esp).get('check_command', missing)
83
+ old_block = vars(esp).get('FLASH_SECTOR_SIZE', missing)
84
+ commands = 0
85
+ block_changed = check_changed = False
86
+
87
+ def bounded_check(op_description, op=None, data=b'', *args, **kwargs):
88
+ nonlocal commands
89
+ if op == command:
90
+ commands += 1
91
+ if commands != 1 or not isinstance(data, bytes) or len(data) != 16:
92
+ raise RuntimeError('Unsupported esptool READ_FLASH command layout')
93
+ wire_offset, wire_length, wire_block, wire_window = struct.unpack('<IIII', data)
94
+ if (wire_offset, wire_length, wire_block, wire_window) != (offset, length, block_size, 64):
95
+ raise RuntimeError('Unexpected esptool READ_FLASH range or packet layout')
96
+ data = struct.pack('<IIII', offset, length, block_size, max_in_flight)
97
+ return original_check(op_description, op, data, *args, **kwargs)
98
+
99
+ try:
100
+ # Upstream 5.6.0 uses this instance field for both its request and its
101
+ # short-packet validation. Changing only the wire request is invalid.
102
+ esp.FLASH_SECTOR_SIZE = block_size
103
+ block_changed = True
104
+ esp.check_command = bounded_check
105
+ check_changed = True
106
+ result = esp.read_flash(offset, length)
107
+ if commands != 1 or not isinstance(result, bytes) or len(result) != length:
108
+ raise RuntimeError('Upstream Flash read did not complete the bounded request')
109
+ return result
110
+ finally:
111
+ try:
112
+ if check_changed:
113
+ if old_check is missing:
114
+ del esp.check_command
115
+ else:
116
+ esp.check_command = old_check
117
+ finally:
118
+ if block_changed:
119
+ if old_block is missing:
120
+ del esp.FLASH_SECTOR_SIZE
121
+ else:
122
+ esp.FLASH_SECTOR_SIZE = old_block
123
+
124
+
62
125
  def main():
63
126
  with contextlib.redirect_stdout(sys.stderr):
64
127
  import esptool
65
- major, minor = map(int, esptool.__version__.split('.')[:2])
66
- if major != 5 or minor < 3:
67
- raise RuntimeError('Install esptool>=5.3,<6')
128
+ if esptool.__version__ != '5.6.0':
129
+ raise RuntimeError('Install esptool==5.6.0 for the verified bounded Flash reader')
68
130
  esp = None
69
131
  families = {'ESP32-P4': 0x3280, 'ESP32-S3': 0x3253, 'ESP32-S31': 0x3231}
70
132
  try:
@@ -101,7 +163,7 @@ def main():
101
163
  elif method == 'flashId':
102
164
  result = esp.flash_id()
103
165
  elif method == 'read':
104
- result = base64.b64encode(esp.read_flash(*args)).decode('ascii')
166
+ result = base64.b64encode(_read_flash(esp, *args)).decode('ascii')
105
167
  elif method == 'write':
106
168
  offset, encoded = args
107
169
  data = base64.b64decode(encoded, validate=True)
@@ -179,33 +179,41 @@ export async function recoverNodeRomPort(previous, { python = defaultPython, sig
179
179
  if (mac(previous.transition.serial) !== expectedMac) return null
180
180
  const enumerate = dependencies.listSerial ?? listSerial
181
181
  const now = dependencies.now ?? (() => performance.now())
182
+ const sleep = dependencies.sleep ?? ((ms, abortSignal) => delay(ms, undefined, { signal: abortSignal }))
182
183
  const deadline = now() + timeout
183
- let ports
184
- try { ports = await enumerate({ python, signal, timeout }) }
185
- catch (error) {
184
+ const sameDevice = item => mac(item.serialNumber) === expectedMac && physicalLocation(item.location) === location
185
+ let runtime
186
+ // A closed native stub may still enumerate as ROM, then disappear before
187
+ // the same device's runtime reappears. Only observe during this window;
188
+ // never re-SYNC the stale ROM or issue a second handoff while it settles.
189
+ while (now() < deadline) {
190
+ checkAbort(signal)
191
+ let ports
192
+ try { ports = await enumerate({ python, signal, timeout: Math.max(1, Math.ceil(deadline - now())) }) }
193
+ catch (error) {
194
+ checkAbort(signal)
195
+ throw fail('ROM_PORT_ENUMERATION_FAILED', 'Cannot verify the selected device for read-only initialization recovery', error)
196
+ }
186
197
  checkAbort(signal)
187
- throw fail('ROM_PORT_ENUMERATION_FAILED', 'Cannot verify the selected device for read-only initialization recovery', error)
198
+ if (now() >= deadline) throw fail('ROM_PORT_REBIND_TIMEOUT', 'Read-only initialization recovery enumeration exceeded its deadline')
199
+ if (!Array.isArray(ports)) throw fail('ROM_PORT_ENUMERATION_FAILED', 'Serial enumeration returned invalid descriptors')
200
+ const candidates = ports.filter(item => nativeRom(item) || nativeRuntime(item))
201
+ const conflicting = candidates.filter(item => (mac(item.serialNumber) === expectedMac || physicalLocation(item.location) === location) && !sameDevice(item))
202
+ if (conflicting.length) throw fail('ROM_RUNTIME_IDENTITY_MISMATCH', 'Runtime descriptors do not match the previously selected ROM device')
203
+ const matches = candidates.filter(sameDevice)
204
+ if (matches.length > 1) throw fail('ROM_PORT_AMBIGUOUS', 'Multiple native USB ports match the selected device')
205
+ if (matches.length && nativeRuntime(matches[0])) { runtime = matches[0]; break }
206
+ await sleep(Math.min(50, Math.max(1, deadline - now())), signal)
188
207
  }
189
- checkAbort(signal)
190
- if (now() >= deadline) throw fail('ROM_PORT_REBIND_TIMEOUT', 'Read-only initialization recovery enumeration exceeded its deadline')
191
- if (!Array.isArray(ports)) throw fail('ROM_PORT_ENUMERATION_FAILED', 'Serial enumeration returned invalid descriptors')
192
- const sameDevice = item => mac(item.serialNumber) === expectedMac && physicalLocation(item.location) === location
193
- const existingRom = ports.filter(item => nativeRom(item) && sameDevice(item))
194
- if (existingRom.length > 1) throw fail('ROM_PORT_AMBIGUOUS', 'Multiple ROM ports match the selected device')
195
- if (existingRom.length) return null
196
- const runtimes = ports.filter(nativeRuntime)
197
- const conflicting = runtimes.filter(item => (mac(item.serialNumber) === expectedMac || physicalLocation(item.location) === location) && !sameDevice(item))
198
- if (conflicting.length) throw fail('ROM_RUNTIME_IDENTITY_MISMATCH', 'Runtime descriptors do not match the previously selected ROM device')
199
- const matches = runtimes.filter(sameDevice)
200
- if (matches.length > 1) throw fail('ROM_PORT_AMBIGUOUS', 'Multiple runtime ports match the selected device')
201
- if (!matches.length) return null
208
+ // Returning null preserves the caller's original initialize failure.
209
+ if (!runtime) return null
202
210
  // Revalidate at prepare's fresh enumeration, before opening EV-MUX. An OS port
203
211
  // may have been reused for a different board since the recovery snapshot.
204
- const result = await prepareNodeRomPort({ port: matches[0].path, python, signal, timeout,
212
+ const result = await prepareNodeRomPort({ port: runtime.path, python, signal, timeout,
205
213
  expectedIdentity: { mac: expectedMac, location } }, dependencies)
206
214
  if (result.expectedMac !== expectedMac || result.transition?.observed !== 'rom' || physicalLocation(result.transition.location) !== location) {
207
215
  throw fail('ROM_RUNTIME_IDENTITY_MISMATCH', 'Recovery did not preserve the selected physical device identity')
208
216
  }
209
- result.transition.recovery = { previousPort: previous.port, runtimePid: matches[0].productId }
217
+ result.transition.recovery = { previousPort: previous.port, runtimePid: runtime.productId }
210
218
  return result
211
219
  }
@@ -11,6 +11,57 @@ import threading
11
11
  import serial
12
12
 
13
13
 
14
+ def _report_serial_error(stage, error):
15
+ """Emit bounded OS diagnostics, never exception text or transferred bytes."""
16
+ number = getattr(error, 'errno', None)
17
+ winerror = getattr(error, 'winerror', None)
18
+ cause = error.__cause__ or error.__context__
19
+ if not isinstance(number, int) and cause is not None:
20
+ number = getattr(cause, 'errno', None)
21
+ if not isinstance(winerror, int) and cause is not None:
22
+ winerror = getattr(cause, 'winerror', None)
23
+ def bounded_number(value):
24
+ if isinstance(value, int) and not isinstance(value, bool) and -0x80000000 <= value <= 0xffffffff:
25
+ return value
26
+ return None
27
+ number = bounded_number(number)
28
+ winerror = bounded_number(winerror)
29
+ # Pyserial sometimes wraps a Windows API failure without preserving errno.
30
+ # Only recognize fixed API names/messages; arbitrary exception text may
31
+ # contain application bytes and must not cross this diagnostic channel.
32
+ text = str(error)[:512]
33
+ message = 'Serial ' + stage + ' failed'
34
+ if number is not None and 0 < number < 4096:
35
+ message = os.strerror(number)
36
+ else:
37
+ for needle, safe in (
38
+ ('ClearCommError', 'ClearCommError failed'),
39
+ ('GetOverlappedResult', 'GetOverlappedResult failed'),
40
+ ('ReadFile', 'ReadFile failed'),
41
+ ('WriteFile', 'WriteFile failed'),
42
+ ('readiness to read but returned no data', 'Device reports readiness to read but returned no data'),
43
+ ('write timeout', 'Serial write timed out'),
44
+ ('Zero-byte serial write', 'Serial write returned zero bytes'),
45
+ ):
46
+ if needle.lower() in text.lower():
47
+ message = safe
48
+ break
49
+ record = {'kind': 'serial.error', 'stage': stage, 'type': type(error).__name__[:64],
50
+ 'errno': number, 'winerror': winerror, 'message': message[:160]}
51
+ # One small line keeps READY/control records intact when the reader thread
52
+ # fails concurrently with the main thread. stderr failure is terminal too.
53
+ line = (json.dumps(record, ensure_ascii=True) + '\n').encode()
54
+ try:
55
+ offset = 0
56
+ while offset < len(line):
57
+ count = os.write(2, line[offset:])
58
+ if count <= 0:
59
+ break
60
+ offset += count
61
+ except OSError:
62
+ pass
63
+
64
+
14
65
  def _configure_serial_exclusive(port, platform=os.name):
15
66
  # pyserial implements this as a non-blocking advisory lock on POSIX. The
16
67
  # Windows backend does not support this option, so leave its behavior alone.
@@ -50,16 +101,24 @@ def main():
50
101
  raise SystemExit(0)
51
102
 
52
103
  def receive():
53
- try:
54
- while not stopped.is_set():
104
+ while not stopped.is_set():
105
+ try:
55
106
  data = port.read(8192)
56
- if data:
107
+ except (OSError, serial.SerialException) as error:
108
+ if not stopped.is_set():
109
+ _report_serial_error('read', error)
110
+ os._exit(2)
111
+ return
112
+ if data:
113
+ try:
57
114
  offset = 0
58
115
  while offset < len(data):
59
116
  offset += os.write(1, data[offset:])
60
- except (OSError, serial.SerialException):
61
- if not stopped.is_set():
62
- os._exit(2)
117
+ except OSError as error:
118
+ if not stopped.is_set():
119
+ _report_serial_error('stdout', error)
120
+ os._exit(2)
121
+ return
63
122
 
64
123
  signal.signal(signal.SIGTERM, terminate)
65
124
  signal.signal(signal.SIGINT, terminate)
@@ -102,12 +161,16 @@ def main():
102
161
  response = {'id': request.get('id'), 'ok': False}
103
162
  print(json.dumps(response), file=sys.stderr, flush=True)
104
163
  continue
105
- offset = 0
106
- while offset < len(data):
107
- written = port.write(data[offset:])
108
- if not written:
109
- raise OSError('Zero-byte serial write')
110
- offset += written
164
+ try:
165
+ offset = 0
166
+ while offset < len(data):
167
+ written = port.write(data[offset:])
168
+ if not written:
169
+ raise OSError('Zero-byte serial write')
170
+ offset += written
171
+ except (OSError, serial.SerialException) as error:
172
+ _report_serial_error('write', error)
173
+ raise SystemExit(2) from None
111
174
  finally:
112
175
  stopped.set()
113
176
  port.close()
@@ -39,7 +39,7 @@ export function tcpTransport(options) {
39
39
 
40
40
  export function serialTransport(options) {
41
41
  const events = subscribers()
42
- let child, controlId = 0, baudRate = 115200
42
+ let child, connection, lastError = null, controlId = 0, baudRate = 115200
43
43
  const pending = new Map()
44
44
  const rejectControls = error => {
45
45
  for (const request of pending.values()) request.reject(error)
@@ -58,35 +58,65 @@ export function serialTransport(options) {
58
58
  connect({ signal }) {
59
59
  return new Promise((resolveConnect, reject) => {
60
60
  const adapter = fileURLToPath(new URL('./node_serial.py', import.meta.url))
61
- child = spawn(options.python, ['-u', adapter, options.serial], { stdio: ['pipe', 'pipe', 'pipe'] })
61
+ const bridge = child = spawn(options.python, ['-u', adapter, options.serial], { stdio: ['pipe', 'pipe', 'pipe'] })
62
+ const current = connection = { intentional: false }
63
+ lastError = null
64
+ let adapterCause = null, processCause = null
65
+ const safeCause = value => {
66
+ if (value?.kind !== 'serial.error' || !['read', 'write', 'stdout'].includes(value.stage)
67
+ || typeof value.type !== 'string' || typeof value.message !== 'string') return null
68
+ const integer = n => Number.isSafeInteger(n) && n >= -0x80000000 && n <= 0xffffffff ? n : null
69
+ return { stage: value.stage, type: value.type.slice(0, 64), errno: integer(value.errno),
70
+ winerror: integer(value.winerror), message: value.message.replace(/[\r\n\x00-\x1f\x7f]/g, ' ').slice(0, 160) }
71
+ }
72
+ const errorDetails = (exitCode = null, signal = null) => ({
73
+ ...(adapterCause ?? processCause ?? { stage: 'process', type: 'ProcessExit', errno: null, winerror: null,
74
+ message: 'Serial adapter exited unexpectedly' }),
75
+ exitCode: Number.isInteger(exitCode) ? exitCode : null,
76
+ signal: typeof signal === 'string' ? signal.slice(0, 32) : null,
77
+ })
62
78
  let ready = false, settled = false, runtimeErrorReported = false, stderr = '', lines = ''
63
79
  const abort = () => {
64
- child.kill('SIGTERM')
80
+ current.intentional = true
81
+ bridge.kill('SIGTERM')
65
82
  if (!settled) {
66
83
  settled = true
67
84
  reject(fail('CANCELLED', 'Serial connection cancelled'))
68
85
  }
69
86
  }
70
87
  const cleanup = () => signal.removeEventListener('abort', abort)
71
- const serialError = () => {
72
- const code = ['SERIAL_ACCESS_DENIED', 'SERIAL_BUSY'].find(code => stderr.includes(code)) ?? 'SERIAL_ERROR'
73
- return fail(code, 'Serial adapter failed; ensure Python, pyserial and port access are available')
88
+ const serialError = details => {
89
+ const code = ready ? 'SERIAL_ERROR' : ['SERIAL_ACCESS_DENIED', 'SERIAL_BUSY'].find(code => stderr.includes(code)) ?? 'SERIAL_ERROR'
90
+ const message = ready && details?.stage !== 'process'
91
+ ? `Serial adapter ${details.stage} failed: ${details.message}`
92
+ : 'Serial adapter failed; ensure Python, pyserial and port access are available'
93
+ const error = fail(code, message)
94
+ error.details = details ?? errorDetails()
95
+ error.cause = Object.assign(new Error(error.details.message), { ...error.details, name: error.details.type })
96
+ return error
74
97
  }
75
- const failBeforeReady = () => {
98
+ const failBeforeReady = details => {
76
99
  if (settled) return
77
100
  settled = true
78
101
  cleanup()
79
- const error = serialError()
102
+ const error = serialError(details)
80
103
  reject(error)
81
104
  events.emit('error', error)
82
105
  }
83
- const reportRuntimeError = () => {
106
+ const reportRuntimeError = details => {
84
107
  if (runtimeErrorReported) return
85
108
  runtimeErrorReported = true
86
- events.emit('error', serialError())
109
+ const error = serialError(details)
110
+ lastError = { ...error.details }
111
+ events.emit('error', error)
112
+ }
113
+ const recordProcessError = (stage, error) => {
114
+ processCause = { stage, type: 'ProcessError', errno: null, winerror: null,
115
+ message: typeof error?.code === 'string' && /^[A-Z0-9_]{1,32}$/.test(error.code)
116
+ ? `Serial adapter ${stage} failed (${error.code})` : `Serial adapter ${stage} failed` }
87
117
  }
88
- child.on('error', () => { if (ready) reportRuntimeError() })
89
- child.stdin.on('error', () => { if (ready) reportRuntimeError() })
118
+ child.on('error', error => recordProcessError('process', error))
119
+ child.stdin.on('error', error => recordProcessError('stdin', error))
90
120
  child.stdout.on('data', bytes => events.emit('data', bytes))
91
121
  child.stderr.on('data', bytes => {
92
122
  // Do not print Python errors: they can contain host paths or device data.
@@ -105,6 +135,8 @@ export function serialTransport(options) {
105
135
  }
106
136
  let response
107
137
  try { response = JSON.parse(line) } catch { continue }
138
+ const diagnostic = safeCause(response)
139
+ if (diagnostic) { adapterCause = diagnostic; continue }
108
140
  const request = pending.get(response.id)
109
141
  if (!request) continue
110
142
  pending.delete(response.id)
@@ -119,7 +151,23 @@ export function serialTransport(options) {
119
151
  })
120
152
  // Wait for close rather than exit: stdout/stderr may still have buffered
121
153
  // data after exit, including the adapter's stable SERIAL_BUSY marker.
122
- child.on('close', () => { cleanup(); rejectControls(fail('SERIAL_CLOSED', 'Serial adapter closed')); if (!ready) failBeforeReady(); events.emit('close') })
154
+ child.on('close', (code, signal) => {
155
+ cleanup()
156
+ if (connection !== current) return
157
+ const details = errorDetails(code, signal)
158
+ if (current.intentional) {
159
+ const error = fail('SERIAL_CLOSED', 'Serial adapter closed')
160
+ rejectControls(error)
161
+ if (!settled) { settled = true; reject(error) }
162
+ } else {
163
+ lastError = { ...details }
164
+ const error = serialError(details)
165
+ rejectControls(error)
166
+ if (!ready) failBeforeReady(details)
167
+ else reportRuntimeError(details)
168
+ }
169
+ events.emit('close')
170
+ })
123
171
  signal.addEventListener('abort', abort, { once: true })
124
172
  if (signal.aborted) abort()
125
173
  })
@@ -158,8 +206,9 @@ export function serialTransport(options) {
158
206
  send(1, Buffer.from(JSON.stringify({ id, baudRate: speed }))).catch(error => pending.get(id)?.reject(error))
159
207
  })
160
208
  },
161
- getDiagnostics() { return { baudRate } },
209
+ getDiagnostics() { return { baudRate, ...(lastError ? { lastError: { ...lastError } } : {}) } },
162
210
  async disconnect() {
211
+ if (connection) connection.intentional = true
163
212
  rejectControls(fail('SERIAL_CLOSED', 'Serial adapter closed'))
164
213
  if (!child?.pid || child.exitCode !== null || child.signalCode !== null) return
165
214
  await new Promise(resolveExit => {
@@ -7,6 +7,7 @@ import { tmpdir } from 'node:os'
7
7
  import { dirname, join, resolve } from 'node:path'
8
8
  import { fileURLToPath } from 'node:url'
9
9
  import process from 'node:process'
10
+ import { npmCommand } from '../scripts/npm-command.mjs'
10
11
 
11
12
  const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
12
13
  const manifest = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'))
@@ -14,7 +15,7 @@ assert.equal(manifest.license, 'Apache-2.0')
14
15
  assert.match(await readFile(join(root, 'LICENSE'), 'utf8'), /Apache License[\s\S]*Version 2\.0/)
15
16
  const temporary = await mkdtemp(join(tmpdir(), 'esp-vision-device-core-package-'))
16
17
  try {
17
- const [packed] = JSON.parse(execFileSync('npm', ['pack', '--json', '--ignore-scripts', '--pack-destination', temporary], { cwd: root, encoding: 'utf8' }))
18
+ const [packed] = JSON.parse(execFileSync(...npmCommand(['pack', '--json', '--ignore-scripts', '--pack-destination', temporary]), { cwd: root, encoding: 'utf8' }))
18
19
  assert.ok(packed.files.some(({ path }) => path === 'LICENSE'))
19
20
  assert.ok(packed.files.some(({ path }) => path === 'NOTICE'))
20
21
  assert.ok(packed.files.some(({ path }) => path === 'schemas/device-actions-v1.json'))
@@ -25,7 +26,7 @@ try {
25
26
  assert.ok(!packed.files.some(({ path }) => path.includes('__pycache__') || path.endsWith('.pyc')), 'Never ship host Python bytecode')
26
27
  const consumer = join(temporary, 'consumer')
27
28
  await mkdir(consumer)
28
- execFileSync('npm', ['install', '--offline', '--ignore-scripts', '--no-audit', '--no-fund', '--package-lock=false', join(temporary, packed.filename)], { cwd: consumer, stdio: 'pipe' })
29
+ execFileSync(...npmCommand(['install', '--offline', '--ignore-scripts', '--no-audit', '--no-fund', '--package-lock=false', join(temporary, packed.filename)]), { cwd: consumer, stdio: 'pipe' })
29
30
  const installed = join(consumer, 'node_modules/@esp-vision/device-core')
30
31
  assert.equal(await readFile(join(installed, 'LICENSE'), 'utf8'), await readFile(join(root, 'LICENSE'), 'utf8'))
31
32
  const notice = await readFile(join(installed, 'NOTICE'), 'utf8')
@@ -65,19 +66,19 @@ try {
65
66
  for (const required of ['src/node_serial.py', 'src/jpeg_validate.js', 'scripts/run-tests.mjs', 'tests/hardware-check.mjs', 'tests/device-checks.mjs', 'tests/jpeg-fixtures.mjs', 'tests/test-jpeg-validation.mjs', 'tests/fixtures/device-checks.py']) {
66
67
  assert.ok(packed.files.some(({ path }) => path === required), `Missing installed check asset: ${required}`)
67
68
  }
68
- const unitOutput = execFileSync('npm', ['test'], { cwd: installed, encoding: 'utf8', maxBuffer: 4 * 1024 * 1024 })
69
+ const unitOutput = execFileSync(...npmCommand(['test']), { cwd: installed, encoding: 'utf8', maxBuffer: 4 * 1024 * 1024 })
69
70
  const unitCount = Number(/^(?:# tests|ℹ tests) (\d+)$/m.exec(unitOutput)?.[1] ?? 0)
70
71
  assert.ok(unitCount > 0, 'Installed npm test must execute a nonempty suite')
71
- const hardwareHelp = execFileSync('npm', ['run', 'test:hardware', '--', '--help'], { cwd: installed, encoding: 'utf8' })
72
+ const hardwareHelp = execFileSync(...npmCommand(['run', 'test:hardware', '--', '--help']), { cwd: installed, encoding: 'utf8' })
72
73
  assert.ok(hardwareHelp.includes('--serial PATH'), 'Installed hardware runner must load and show its help')
73
74
  // A damaged tarball must fail instead of reporting a successful zero-test run.
74
75
  await rm(join(installed, 'tests'), { recursive: true })
75
76
  await mkdir(join(installed, 'tests'))
76
- const empty = spawnSync('npm', ['test'], { cwd: installed, encoding: 'utf8' })
77
+ const empty = spawnSync(...npmCommand(['test']), { cwd: installed, encoding: 'utf8' })
77
78
  assert.equal(empty.status, 1, 'An empty suite must fail closed')
78
79
  assert.match(empty.stderr, /No Device Core unit tests found/)
79
80
  await rm(join(installed, 'tests'), { recursive: true })
80
- const missing = spawnSync('npm', ['test'], { cwd: installed, encoding: 'utf8' })
81
+ const missing = spawnSync(...npmCommand(['test']), { cwd: installed, encoding: 'utf8' })
81
82
  assert.equal(missing.status, 1, 'Missing tests must fail closed')
82
83
  console.log(JSON.stringify({ package: packed.name, version: packed.version, entryPoints, unitCount, standalone: true }))
83
84
  } finally {
@@ -0,0 +1,47 @@
1
+ // SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import assert from 'node:assert/strict'
4
+ import { spawnSync } from 'node:child_process'
5
+ import { copyFile, cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
6
+ import { tmpdir } from 'node:os'
7
+ import { join } from 'node:path'
8
+ import process from 'node:process'
9
+ import { fileURLToPath } from 'node:url'
10
+ import { test } from 'node:test'
11
+ import { npmCommand } from '../scripts/npm-command.mjs'
12
+
13
+ const root = fileURLToPath(new URL('../', import.meta.url))
14
+
15
+ test('npm execution preserves Unicode, spaces and shell metacharacters as literal arguments', async t => {
16
+ const directory = await mkdtemp(join(tmpdir(), 'core npm 中文 '))
17
+ t.after(() => rm(directory, { recursive: true, force: true }))
18
+ const cli = join(directory, 'npm entry.cjs')
19
+ await writeFile(cli, 'process.stdout.write(JSON.stringify(process.argv.slice(2))); process.exitCode = 7\n')
20
+ const args = ['pack', '中文 path', '& echo forbidden', '$(forbidden)', '%PATH%']
21
+ const env = { ...process.env, npm_execpath: cli }
22
+ const result = spawnSync(...npmCommand(args, env), { env, encoding: 'utf8' })
23
+ assert.equal(result.status, 7, result.stderr)
24
+ assert.deepEqual(JSON.parse(result.stdout), args)
25
+ })
26
+
27
+ test('schema check accepts checkout CRLF but rejects a real artifact change', async t => {
28
+ const directory = await mkdtemp(join(tmpdir(), 'core schema '))
29
+ t.after(() => rm(directory, { recursive: true, force: true }))
30
+ await mkdir(join(directory, 'scripts'))
31
+ await mkdir(join(directory, 'schemas'))
32
+ await cp(join(root, 'src'), join(directory, 'src'), { recursive: true })
33
+ await copyFile(join(root, 'package.json'), join(directory, 'package.json'))
34
+ const script = join(directory, 'scripts/generate-action-schema.mjs')
35
+ await copyFile(join(root, 'scripts/generate-action-schema.mjs'), script)
36
+ const schema = join(directory, 'schemas/device-actions-v1.json')
37
+ const original = (await readFile(join(root, 'schemas/device-actions-v1.json'), 'utf8')).replaceAll('\r\n', '\n')
38
+ await writeFile(schema, original.replaceAll('\n', '\r\n'))
39
+ const current = spawnSync(process.execPath, [script, '--check'], { encoding: 'utf8' })
40
+ assert.equal(current.status, 0, current.stderr)
41
+ const changed = JSON.parse(original)
42
+ changed.deviceCoreVersion = '0.0.0'
43
+ await writeFile(schema, JSON.stringify(changed, null, 2).replaceAll('\n', '\r\n') + '\r\n')
44
+ const stale = spawnSync(process.execPath, [script, '--check'], { encoding: 'utf8' })
45
+ assert.notEqual(stale.status, 0)
46
+ assert.match(stale.stderr, /Device Action schema artifact is stale/)
47
+ })
@@ -265,10 +265,12 @@ test('recovery never repeats a previous active handoff or uses an incomplete pre
265
265
  }
266
266
  })
267
267
 
268
- test('same ROM or no runtime returns null without issuing a bootloader command', async () => {
268
+ test('persistent ROM or absence exhausts only passive observation before returning null', async () => {
269
269
  for (const snapshot of [[rom('/dev/ttyACM1')], [], [native('/dev/ttyACM8', { serialNumber: 'e8f60ae75653', location: '2-3' })]]) {
270
270
  const f = fixture({ snapshots: [snapshot] })
271
271
  assert.equal(await f.recover(previousRom()), null)
272
+ assert.equal(f.now(), 200)
273
+ assert.equal(f.calls.enumerate.length, 4)
272
274
  assert.equal(f.calls.creates, 0)
273
275
  assert.equal(f.calls.control.length, 0)
274
276
  }
@@ -303,3 +305,81 @@ test('read-only recovery honors cancel and fresh-enumeration deadline', async ()
303
305
  await assert.rejects(late.recover(previousRom()), { code: 'ROM_PORT_REBIND_TIMEOUT' })
304
306
  assert.equal(late.calls.creates, 0)
305
307
  })
308
+
309
+
310
+ test('read-only recovery observes a real 2.4-second missing-device window before one handoff', async () => {
311
+ const runtime = native('/dev/ttyACM2', { productId: 0x4023 })
312
+ const f = fixture({ snapshots: [...Array.from({ length: 48 }, () => []), [runtime], [runtime], [rom('/dev/ttyACM3')]] })
313
+ const result = await f.recover(previousRom(), { timeout: 5000 })
314
+ assert.equal(f.now(), 2400)
315
+ assert.equal(result.port, '/dev/ttyACM3')
316
+ assert.equal(result.transition.recovery.runtimePid, 0x4023)
317
+ assert.equal(f.calls.control.length, 1)
318
+ assert.equal(f.calls.connect.length, 1)
319
+ assert.equal(f.calls.disconnect, 1)
320
+ assert.equal(f.calls.enumerate[48].timeout, 2600)
321
+ })
322
+
323
+ test('a stale ROM followed by disappearance is observed passively until the same runtime returns', async () => {
324
+ const runtime = native('/dev/ttyACM2')
325
+ const snapshots = [...Array.from({ length: 10 }, () => [rom('/dev/ttyACM0')]),
326
+ ...Array.from({ length: 38 }, () => []), [runtime], [runtime], [rom('/dev/ttyACM3')]]
327
+ const f = fixture({ snapshots })
328
+ const result = await f.recover(previousRom(), { timeout: 5000 })
329
+ assert.equal(f.now(), 2400)
330
+ assert.equal(result.port, '/dev/ttyACM3')
331
+ assert.equal(f.calls.creates, 1)
332
+ assert.equal(f.calls.control.length, 1)
333
+ assert.equal(f.calls.disconnect, 1)
334
+ })
335
+
336
+ test('a conflicting MAC or location seen after waiting fails before opening any runtime', async () => {
337
+ for (const conflicting of [native('/dev/ttyACM2', { serialNumber: 'e8f60ae75653' }),
338
+ native('/dev/ttyACM2', { location: '2-3' }), rom('/dev/ttyACM2', { serialNumber: 'e8f60ae75653' }),
339
+ rom('/dev/ttyACM2', { location: '2-3' })]) {
340
+ const f = fixture({ snapshots: [[], [], [conflicting]] })
341
+ await assert.rejects(f.recover(previousRom()), { code: 'ROM_RUNTIME_IDENTITY_MISMATCH' })
342
+ assert.equal(f.now(), 100)
343
+ assert.equal(f.calls.creates, 0)
344
+ assert.equal(f.calls.control.length, 0)
345
+ }
346
+ })
347
+
348
+ test('late duplicate runtime or simultaneous ROM/runtime descriptors remain ambiguous', async () => {
349
+ for (const second of [native('/dev/ttyACM3'), rom('/dev/ttyACM3')]) {
350
+ const f = fixture({ snapshots: [[], [native('/dev/ttyACM2'), second]] })
351
+ await assert.rejects(f.recover(previousRom()), { code: 'ROM_PORT_AMBIGUOUS' })
352
+ assert.equal(f.now(), 50)
353
+ assert.equal(f.calls.creates, 0)
354
+ assert.equal(f.calls.control.length, 0)
355
+ }
356
+ })
357
+
358
+ test('cancel during passive recovery sleep never creates a session or sends bootloader', async () => {
359
+ const reason = Object.assign(new Error('cancelled during native USB observation'), { name: 'AbortError' })
360
+ const f = fixture({ snapshots: [[]], onSleep(controller, clock) { if (clock >= 150) controller.abort(reason) } })
361
+ await assert.rejects(f.recover(previousRom(), { timeout: 5000 }), value => value === reason)
362
+ assert.equal(f.now(), 150)
363
+ assert.equal(f.calls.creates, 0)
364
+ assert.equal(f.calls.control.length, 0)
365
+ })
366
+
367
+ test('late runtime enumeration does not start handoff after the passive deadline', async () => {
368
+ const runtime = native('/dev/ttyACM2')
369
+ const f = fixture({ snapshots: [[runtime]], enumerationDelay: 201 })
370
+ await assert.rejects(f.recover(previousRom()), { code: 'ROM_PORT_REBIND_TIMEOUT' })
371
+ assert.equal(f.calls.creates, 0)
372
+ assert.equal(f.calls.control.length, 0)
373
+ })
374
+
375
+ test('a runtime handshake error after settling is propagated without another observation or handoff', async () => {
376
+ const runtime = native('/dev/ttyACM2')
377
+ const reason = failure('SESSION_BUSY')
378
+ const f = fixture({ snapshots: [[], [runtime], [runtime]], connectError: reason })
379
+ await assert.rejects(f.recover(previousRom()), value => value === reason)
380
+ assert.equal(f.now(), 50)
381
+ assert.equal(f.calls.connect.length, 1)
382
+ assert.equal(f.calls.creates, 1)
383
+ assert.equal(f.calls.control.length, 0)
384
+ assert.equal(f.calls.disconnect, 1)
385
+ })
@@ -6,7 +6,7 @@ import { EventEmitter } from 'node:events'
6
6
  import { PassThrough, Writable } from 'node:stream'
7
7
  import { openNodeRom } from '../src/node_rom.js'
8
8
 
9
- function fixture({ mac = 'e8f60ae75642', expected = mac, prepare, initializeError } = {}) {
9
+ function fixture({ mac = 'e8f60ae75642', expected = mac, prepare, initializeError, operationError, readResult = 'QUI=' } = {}) {
10
10
  const calls = [], requests = []
11
11
  const child = new EventEmitter()
12
12
  child.exitCode = null
@@ -15,10 +15,11 @@ function fixture({ mac = 'e8f60ae75642', expected = mac, prepare, initializeErro
15
15
  write(data, encoding, done) {
16
16
  const request = JSON.parse(data.toString())
17
17
  requests.push(request)
18
- queueMicrotask(() => child.stdout.write(JSON.stringify(request.method === 'initialize' && initializeError
19
- ? { id: request.id, error: 'ROM_OPERATION_FAILED', method: 'initialize', detail: initializeError }
18
+ const detail = request.method === 'initialize' ? initializeError : operationError
19
+ queueMicrotask(() => child.stdout.write(JSON.stringify(detail
20
+ ? { id: request.id, error: 'ROM_OPERATION_FAILED', method: request.method, detail }
20
21
  : { id: request.id, result: request.method === 'initialize'
21
- ? { chipFamily: 0x3280, chipRevision: 302, chipMac: mac, connectionMode: 'no-reset' } : null }) + '\n'))
22
+ ? { chipFamily: 0x3280, chipRevision: 302, chipMac: mac, connectionMode: 'no-reset' } : request.method === 'read' ? readResult : null }) + '\n'))
22
23
  done()
23
24
  },
24
25
  final(done) { child.exitCode = 0; queueMicrotask(() => child.emit('exit', 0)); done() },
@@ -158,3 +159,73 @@ test('a second initialization sync failure cannot trigger a third process or han
158
159
  assert.equal(recoveries, 1)
159
160
  assert.equal(first.requests.length + second.requests.length, 2)
160
161
  })
162
+
163
+
164
+ test('post-initialize read or write failures never start recovery, a second process, or replay data', async () => {
165
+ for (const method of ['read', 'write']) {
166
+ const f = fixture({ operationError: 'Original operation failed' })
167
+ f.selected.transition = { observed: 'rom', requested: false }
168
+ let recoveries = 0, progresses = 0
169
+ const loader = openNodeRom({ port: '/dev/rom' }, { ...f.dependencies,
170
+ recoverPort: async () => { recoveries++; throw Error('Read/write must not recover') },
171
+ })
172
+ try {
173
+ await loader.initialize()
174
+ const operation = method === 'read' ? loader.readFlash(0x8000, 1024)
175
+ : loader.flashData(new Uint8Array([1, 2, 3]), () => { progresses++ }, 0x10000)
176
+ await assert.rejects(operation, error => error.code === 'ROM_OPERATION_FAILED'
177
+ && error.operation === method && error.cause === 'Original operation failed')
178
+ assert.equal(recoveries, 0)
179
+ assert.equal(progresses, 0)
180
+ assert.equal(f.calls.filter(c => c[0] === 'spawn').length, 1)
181
+ assert.deepEqual(f.requests.map(r => r.method), ['initialize', method])
182
+ } finally { await loader.disconnect() }
183
+ }
184
+ })
185
+
186
+
187
+ test('Node ROM reads forward default and explicit budgets and report progress only after complete data', async () => {
188
+ const f = fixture(), loader = openNodeRom({ port: '/dev/runtime' }, f.dependencies)
189
+ const progress = []
190
+ try {
191
+ await loader.initialize()
192
+ assert.deepEqual(await loader.readFlash(0x8000, 2, (...args) => progress.push(args)), new Uint8Array([65, 66]))
193
+ assert.deepEqual(await loader.readFlash(0x10000, 2, undefined,
194
+ { chunkSize: 65536, blockSize: 4096, maxInFlight: 2 }), new Uint8Array([65, 66]))
195
+ assert.deepEqual(f.requests.map(r => [r.method, r.args]), [['initialize', []],
196
+ ['read', [0x8000, 2, { blockSize: 1024, maxInFlight: 1 }]],
197
+ ['read', [0x10000, 2, { blockSize: 4096, maxInFlight: 2 }]]])
198
+ assert.equal(progress.length, 1)
199
+ assert.deepEqual(progress[0], [new Uint8Array([65, 66]), 2, 2])
200
+ } finally { await loader.disconnect() }
201
+ })
202
+
203
+ test('invalid read ranges, progress, or packet budgets never issue a child process RPC', async () => {
204
+ const f = fixture(), loader = openNodeRom({ port: '/dev/runtime' }, f.dependencies)
205
+ try {
206
+ await loader.initialize()
207
+ for (const [offset, count, progress, options] of [[-1, 2], [0, 0], [0, 0x100000000], [0xffffffff, 2], [0, 2, null],
208
+ [0, 2, undefined, null], [0, 2, undefined, []], [0, 2, undefined, { blockSize: 512 }],
209
+ [0, 2, undefined, { blockSize: true }], [0, 2, undefined, { blockSize: null }], [0, 2, undefined, { maxInFlight: 0 }],
210
+ [0, 2, undefined, { maxInFlight: 65 }], [0, 2, undefined, { maxInFlight: null }], [0, 2, undefined, { maxInFlight: 1.5 }],
211
+ [0, 2, undefined, { unknown: 1 }], [0, 2, undefined, { chunkSize: 1 }]]) {
212
+ await assert.rejects(loader.readFlash(offset, count, progress, options), error => error instanceof TypeError || error instanceof RangeError)
213
+ }
214
+ assert.deepEqual(f.requests.map(r => r.method), ['initialize'])
215
+ } finally { await loader.disconnect() }
216
+ })
217
+
218
+ test('incomplete read results are rejected without progress, recovery, or duplicate read', async () => {
219
+ const f = fixture({ readResult: 'QQ==' })
220
+ let recovered = 0, progressed = 0
221
+ const loader = openNodeRom({ port: '/dev/runtime' }, { ...f.dependencies,
222
+ recoverPort: async () => { recovered++; throw Error('Read must not recover') },
223
+ })
224
+ try {
225
+ await loader.initialize()
226
+ await assert.rejects(loader.readFlash(0, 2, () => { progressed++ }), { code: 'ROM_READ_LENGTH', operation: 'read' })
227
+ assert.equal(recovered, 0)
228
+ assert.equal(progressed, 0)
229
+ assert.deepEqual(f.requests.map(r => r.method), ['initialize', 'read'])
230
+ } finally { await loader.disconnect() }
231
+ })
@@ -1,6 +1,6 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  import assert from 'node:assert/strict'
3
- import { spawnSync } from 'node:child_process'
3
+ import { spawn, spawnSync } from 'node:child_process'
4
4
  import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
5
5
  import { delimiter, dirname, join, resolve } from 'node:path'
6
6
  import { fileURLToPath } from 'node:url'
@@ -306,5 +306,234 @@ test('Node drain rejects promptly on explicit disconnect or Python bridge exit',
306
306
  await f.transport.disconnect()
307
307
  await rejected
308
308
  const exited = await drainFixture(t, ['exit'])
309
- await assert.rejects(exited.transport.drain(), { code: 'SERIAL_CLOSED' })
309
+ await assert.rejects(exited.transport.drain(), error => error.code === 'SERIAL_ERROR' && error.details.exitCode === 2)
310
+ })
311
+
312
+ async function failureFixture(t, mode) {
313
+ await mkdir(tempBase, { recursive: true })
314
+ const root = await mkdtemp(join(tempBase, 'serial-failure-')), gate = join(root, 'fail')
315
+ t.after(() => rm(root, { recursive: true, force: true }))
316
+ await writeFile(join(root, 'serial.py'), `
317
+ import os, signal, time
318
+ MODE = ${JSON.stringify(mode)}
319
+ GATE = ${JSON.stringify(gate)}
320
+ SECRET = 'TRANSFERRED_BYTES_MUST_NOT_ESCAPE_' * 100
321
+ class SerialException(Exception):
322
+ pass
323
+ class Serial:
324
+ def __init__(self, *args, **kwargs):
325
+ self.baudrate = kwargs['baudrate']
326
+ def open(self):
327
+ if MODE == 'denied':
328
+ raise SerialException('Could not open COM7: Access is denied ' + SECRET)
329
+ if MODE == 'opening':
330
+ time.sleep(1)
331
+ def read(self, size):
332
+ if os.path.exists(GATE):
333
+ if MODE == 'read':
334
+ raise OSError(5, SECRET)
335
+ if MODE == 'read-private':
336
+ raise SerialException(SECRET)
337
+ if MODE == 'exit':
338
+ os._exit(7)
339
+ if MODE == 'eof':
340
+ os._exit(0)
341
+ if MODE == 'signal':
342
+ os.kill(os.getpid(), signal.SIGKILL)
343
+ time.sleep(.005)
344
+ return b''
345
+ def write(self, data):
346
+ if MODE == 'write':
347
+ raise SerialException('GetOverlappedResult failed ' + SECRET + repr(data))
348
+ return len(data)
349
+ def flush(self):
350
+ pass
351
+ def close(self):
352
+ pass
353
+ `)
354
+ return { root, gate, fail: () => writeFile(gate, '') }
355
+ }
356
+
357
+ function deadline(promise, message) {
358
+ let timer
359
+ return Promise.race([promise, new Promise((_, reject) => {
360
+ timer = setTimeout(() => reject(new Error(message)), 5000)
361
+ })]).finally(() => clearTimeout(timer))
362
+ }
363
+
364
+ async function transportFailure(t, mode) {
365
+ const f = await failureFixture(t, mode), previous = process.env.PYTHONPATH
366
+ process.env.PYTHONPATH = [f.root, previous].filter(Boolean).join(delimiter)
367
+ t.after(() => { if (previous === undefined) delete process.env.PYTHONPATH; else process.env.PYTHONPATH = previous })
368
+ const transport = serialTransport({ python, serial: '/dev/fixture' }), errors = []
369
+ let closed
370
+ const close = new Promise(resolveClose => { closed = resolveClose })
371
+ transport.subscribe({ error: error => errors.push(error), close: closed })
372
+ t.after(() => transport.disconnect())
373
+ await transport.connect({ signal: new AbortController().signal })
374
+ return { ...f, transport, errors, close: () => deadline(close, 'Serial child did not close') }
375
+ }
376
+
377
+ test('actual Python reader failure reaches Node with safe OS cause and child status', { skip: !python }, async t => {
378
+ const f = await transportFailure(t, 'read')
379
+ await f.fail()
380
+ await f.close()
381
+ assert.equal(f.errors.length, 1)
382
+ const error = f.errors[0]
383
+ assert.equal(error.code, 'SERIAL_ERROR')
384
+ assert.deepEqual(error.details, { stage: 'read', type: 'OSError', errno: 5, winerror: null,
385
+ message: 'Input/output error', exitCode: 2, signal: null })
386
+ assert.equal(error.cause.name, 'OSError')
387
+ assert.equal(error.cause.errno, 5)
388
+ assert.deepEqual(f.transport.getDiagnostics().lastError, error.details)
389
+ assert.equal(JSON.stringify(error.details).includes('TRANSFERRED_BYTES'), false)
390
+ const snapshot = f.transport.getDiagnostics()
391
+ snapshot.lastError.message = 'changed by caller'
392
+ assert.notEqual(f.transport.getDiagnostics().lastError.message, snapshot.lastError.message)
393
+ })
394
+
395
+ test('actual Python writer failure keeps its API category without exposing payload or arbitrary exception text', { skip: !python }, async t => {
396
+ const f = await transportFailure(t, 'write')
397
+ const payload = new TextEncoder().encode('private transmitted bytes, never a diagnostic')
398
+ await f.transport.writeBytes(payload)
399
+ await f.close()
400
+ assert.equal(f.errors.length, 1)
401
+ assert.equal(f.errors[0].code, 'SERIAL_ERROR')
402
+ assert.deepEqual(f.errors[0].details, { stage: 'write', type: 'SerialException', errno: null,
403
+ winerror: null, message: 'GetOverlappedResult failed', exitCode: 2, signal: null })
404
+ const diagnostic = JSON.stringify(f.transport.getDiagnostics())
405
+ assert.equal(diagnostic.includes('TRANSFERRED_BYTES'), false)
406
+ assert.equal(diagnostic.includes('private transmitted bytes'), false)
407
+ })
408
+
409
+ test('unrecognized Python exception text is omitted and diagnostics remain bounded', { skip: !python }, async t => {
410
+ const f = await transportFailure(t, 'read-private')
411
+ await f.fail()
412
+ await f.close()
413
+ assert.equal(f.errors.length, 1)
414
+ assert.equal(f.errors[0].details.message, 'Serial read failed')
415
+ assert(JSON.stringify(f.errors[0].details).length < 512)
416
+ assert.equal(JSON.stringify(f.errors[0].details).includes('TRANSFERRED_BYTES'), false)
417
+ })
418
+
419
+ test('unexpected bridge exit zero, nonzero or signal reports one serial error with the actual close status', {
420
+ skip: !python,
421
+ }, async t => {
422
+ for (const mode of ['exit', 'eof', ...(process.platform === 'win32' ? [] : ['signal'])]) {
423
+ const f = await transportFailure(t, mode)
424
+ await f.fail()
425
+ await f.close()
426
+ assert.equal(f.errors.length, 1, mode)
427
+ assert.equal(f.errors[0].code, 'SERIAL_ERROR')
428
+ assert.equal(f.errors[0].details.stage, 'process')
429
+ assert.equal(f.errors[0].details.exitCode, mode === 'exit' ? 7 : mode === 'eof' ? 0 : null)
430
+ assert.equal(f.errors[0].details.signal, mode === 'signal' ? 'SIGKILL' : null)
431
+ }
432
+ })
433
+
434
+ test('intentional disconnect and connection cancellation do not become serial failures', { skip: !python }, async t => {
435
+ const f = await transportFailure(t, 'idle')
436
+ await f.transport.disconnect()
437
+ await f.close()
438
+ assert.deepEqual(f.errors, [])
439
+ assert.equal(f.transport.getDiagnostics().lastError, undefined)
440
+ const pending = await failureFixture(t, 'opening'), previous = process.env.PYTHONPATH
441
+ process.env.PYTHONPATH = [pending.root, previous].filter(Boolean).join(delimiter)
442
+ t.after(() => { if (previous === undefined) delete process.env.PYTHONPATH; else process.env.PYTHONPATH = previous })
443
+ const transport = serialTransport({ python, serial: '/dev/fixture' }), errors = []
444
+ let closed
445
+ const close = new Promise(resolveClose => { closed = resolveClose })
446
+ transport.subscribe({ error: error => errors.push(error), close: closed })
447
+ t.after(() => transport.disconnect())
448
+ const controller = new AbortController(), connecting = transport.connect({ signal: controller.signal })
449
+ const cancelled = assert.rejects(connecting, { code: 'CANCELLED' })
450
+ controller.abort()
451
+ await cancelled
452
+ await deadline(close, 'Cancelled child did not close')
453
+ assert.deepEqual(errors, [])
454
+ assert.equal(transport.getDiagnostics().lastError, undefined)
455
+ })
456
+
457
+
458
+ test('Python failure stderr is one bounded structured record and never includes transfer data', { skip: !python }, async t => {
459
+ for (const mode of ['read-private', 'write']) {
460
+ const f = await failureFixture(t, mode), stderr = [], stdout = []
461
+ const child = spawn(python, ['-u', adapter, '/dev/fixture'], {
462
+ env: fixturePythonEnv(f.root), stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true,
463
+ })
464
+ t.after(() => { if (child.exitCode === null && child.signalCode === null) child.kill('SIGTERM') })
465
+ let ready
466
+ const started = new Promise(resolveReady => { ready = resolveReady })
467
+ child.stdout.on('data', bytes => stdout.push(bytes))
468
+ child.stderr.on('data', bytes => { stderr.push(bytes); if (/(?:^|\n)READY\r?\n/.test(Buffer.concat(stderr).toString())) ready() })
469
+ const closed = new Promise((resolveClose, reject) => { child.on('error', reject); child.on('close', (code, signal) => resolveClose({ code, signal })) })
470
+ await deadline(started, 'Python fault fixture did not become ready')
471
+ if (mode === 'write') {
472
+ const payload = Buffer.from('private serial bytes')
473
+ const record = Buffer.alloc(5 + payload.length)
474
+ record.writeUInt8(0, 0); record.writeUInt32BE(payload.length, 1); record.set(payload, 5)
475
+ child.stdin.write(record)
476
+ } else await f.fail()
477
+ assert.deepEqual(await deadline(closed, 'Python fault did not terminate'), { code: 2, signal: null })
478
+ const text = Buffer.concat(stderr).toString(), lines = text.trim().split(/\r?\n/)
479
+ assert.equal(lines.filter(line => line === 'READY').length, 1)
480
+ const records = lines.filter(line => line !== 'READY').map(line => JSON.parse(line))
481
+ assert.equal(records.length, 1)
482
+ assert.equal(records[0].kind, 'serial.error')
483
+ assert.equal(records[0].stage, mode === 'write' ? 'write' : 'read')
484
+ assert(Buffer.byteLength(lines.find(line => line !== 'READY')) < 512)
485
+ assert.equal(text.includes('TRANSFERRED_BYTES'), false)
486
+ assert.equal(text.includes('private serial bytes'), false)
487
+ assert.equal(Buffer.concat(stdout).length, 0)
488
+ }
489
+ })
490
+
491
+
492
+ test('disconnect before READY settles the pending connection without reporting an unexpected failure', { skip: !python }, async t => {
493
+ const f = await failureFixture(t, 'opening'), previous = process.env.PYTHONPATH
494
+ process.env.PYTHONPATH = [f.root, previous].filter(Boolean).join(delimiter)
495
+ t.after(() => { if (previous === undefined) delete process.env.PYTHONPATH; else process.env.PYTHONPATH = previous })
496
+ const transport = serialTransport({ python, serial: '/dev/fixture' }), errors = []
497
+ transport.subscribe({ error: error => errors.push(error) })
498
+ t.after(() => transport.disconnect())
499
+ const connecting = transport.connect({ signal: new AbortController().signal })
500
+ const closed = assert.rejects(connecting, { code: 'SERIAL_CLOSED' })
501
+ await transport.disconnect()
502
+ await deadline(closed, 'Disconnect did not settle the pending connection')
503
+ assert.deepEqual(errors, [])
504
+ assert.equal(transport.getDiagnostics().lastError, undefined)
505
+ })
506
+
507
+
508
+ test('spawn failure before READY preserves its bounded process cause and settles once', async t => {
509
+ const transport = serialTransport({ python: join(tempBase, 'missing-python-executable'), serial: '/dev/fixture' }), errors = []
510
+ transport.subscribe({ error: error => errors.push(error) })
511
+ t.after(() => transport.disconnect())
512
+ await assert.rejects(transport.connect({ signal: new AbortController().signal }), error => {
513
+ assert.equal(error.code, 'SERIAL_ERROR')
514
+ assert.equal(error.details.stage, 'process')
515
+ assert.equal(error.details.type, 'ProcessError')
516
+ assert.equal(error.details.exitCode, process.platform === 'win32' ? -4058 : -2)
517
+ assert.equal(error.details.signal, null)
518
+ assert.match(error.details.message, /ENOENT/)
519
+ return true
520
+ })
521
+ assert.equal(errors.length, 1)
522
+ })
523
+
524
+ test('Windows-style access denial before READY retains its existing error code without port or exception text', { skip: !python }, async t => {
525
+ const f = await failureFixture(t, 'denied'), previous = process.env.PYTHONPATH
526
+ process.env.PYTHONPATH = [f.root, previous].filter(Boolean).join(delimiter)
527
+ t.after(() => { if (previous === undefined) delete process.env.PYTHONPATH; else process.env.PYTHONPATH = previous })
528
+ const transport = serialTransport({ python, serial: 'COM7' }), errors = []
529
+ transport.subscribe({ error: error => errors.push(error) })
530
+ t.after(() => transport.disconnect())
531
+ await assert.rejects(transport.connect({ signal: new AbortController().signal }), error => {
532
+ assert.equal(error.code, 'SERIAL_ACCESS_DENIED')
533
+ assert.equal(error.details.exitCode, 2)
534
+ assert.equal(JSON.stringify(error.details).includes('TRANSFERRED_BYTES'), false)
535
+ assert.equal(error.message.includes('COM7'), false)
536
+ return true
537
+ })
538
+ assert.equal(errors.length, 1)
310
539
  })
@@ -17,23 +17,17 @@ async function fixture(t, published, version = '1.2.3', type = 'patch') {
17
17
  await copyFile(join(source, 'release-npm.mjs'), join(scripts, 'release-npm.mjs'))
18
18
  await copyFile(join(source, 'verify-registry.mjs'), join(scripts, 'verify-registry.mjs'))
19
19
  await copyFile(join(source, 'version-policy.mjs'), join(scripts, 'version-policy.mjs'))
20
+ await copyFile(join(source, 'npm-command.mjs'), join(scripts, 'npm-command.mjs'))
20
21
  await writeFile(join(root, 'package.json'), JSON.stringify({ name: '@esp-vision/device-core', version, espVisionReleaseType: type, license: 'Apache-2.0', publishConfig: { access: 'public', registry: 'https://registry.npmjs.org/' } }))
21
22
  const loader = join(root, 'registry.mjs'), npm = join(root, 'npm.cjs')
22
23
  // Invoke the fixture with Node on every platform, without shell/PATH resolution.
23
- await writeFile(loader, `import childProcess from 'node:child_process';
24
- import { syncBuiltinESMExports } from 'node:module';
25
- const spawnSync = childProcess.spawnSync;
26
- childProcess.spawnSync = (command, ...args) => command === 'npm'
27
- ? spawnSync(process.execPath, [${JSON.stringify(npm)}, ...args[0]], args[1])
28
- : spawnSync(command, ...args);
29
- syncBuiltinESMExports();
30
- let reads = 0; globalThis.fetch = async () => { reads++; const found = ${published} || reads === 2 || reads >= 4; return {ok:found,status:found?200:404,json:async()=>reads===2?({versions:{'1.2.2':{}}}):({dist:{integrity:'sha512-fixture'}})} }`)
24
+ await writeFile(loader, `let reads = 0; globalThis.fetch = async () => { reads++; const found = ${published} || reads === 2 || reads >= 4; return {ok:found,status:found?200:404,json:async()=>reads===2?({versions:{'1.2.2':{}}}):({dist:{integrity:'sha512-fixture'}})} }`)
31
25
  await writeFile(npm, `const fs = require('node:fs'); const args = process.argv.slice(2); fs.appendFileSync(process.env.CALL_LOG, args[0] + '\\n');
32
26
  if (args[0] === 'whoami') process.stdout.write('esp-vision');
33
27
  else if (args[0] === 'pack') process.stdout.write(JSON.stringify([{name:'@esp-vision/device-core',version:'1.2.3',filename:'core.tgz',integrity:'sha512-fixture',files:[{path:'LICENSE'},{path:'NOTICE'}]}]));
34
28
  else if (args[0] !== 'publish') process.exit(2);
35
29
  `)
36
- const env = { ...process.env, TMPDIR: root, TMP: root, TEMP: root, CALL_LOG: log,
30
+ const env = { ...process.env, TMPDIR: root, TMP: root, TEMP: root, CALL_LOG: log, npm_execpath: npm,
37
31
  NODE_OPTIONS: `--import ${pathToFileURL(loader).href}`, NPM_PUBLISH_TOKEN: 'fixture', CI_PIPELINE_SOURCE: 'push',
38
32
  CI_DEFAULT_BRANCH: 'master', CI_COMMIT_BRANCH: 'master', CI_COMMIT_REF_PROTECTED: 'true' }
39
33
  const result = spawnSync(process.execPath, [resolve(scripts, 'release-npm.mjs')], { cwd: root, env, encoding: 'utf8' })