@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 +12 -0
- package/README.md +1 -1
- package/package.json +2 -2
- package/schemas/device-actions-v1.json +1 -1
- package/scripts/generate-action-schema.mjs +2 -1
- package/scripts/npm-command.mjs +17 -0
- package/scripts/release-npm.mjs +2 -1
- package/src/node_rom.js +16 -2
- package/src/node_rom.py +66 -4
- package/src/node_rom_port.js +27 -19
- package/src/node_serial.py +75 -12
- package/src/node_transport.js +63 -14
- package/tests/check-package.mjs +7 -6
- package/tests/test-host-checks.mjs +47 -0
- package/tests/test-node-rom-port.mjs +81 -1
- package/tests/test-node-rom.mjs +75 -4
- package/tests/test-node-serial.mjs +231 -2
- package/tests/test-npm-release-policy.mjs +3 -9
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
|
|
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.
|
|
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": "
|
|
81
|
+
"espVisionReleaseType": "patch"
|
|
82
82
|
}
|
|
@@ -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
|
-
|
|
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
|
+
}
|
package/scripts/release-npm.mjs
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
|
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
|
-
|
|
66
|
-
|
|
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
|
|
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)
|
package/src/node_rom_port.js
CHANGED
|
@@ -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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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('
|
|
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
|
-
|
|
190
|
-
if (
|
|
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:
|
|
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:
|
|
217
|
+
result.transition.recovery = { previousPort: previous.port, runtimePid: runtime.productId }
|
|
210
218
|
return result
|
|
211
219
|
}
|
package/src/node_serial.py
CHANGED
|
@@ -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
|
-
|
|
54
|
-
|
|
104
|
+
while not stopped.is_set():
|
|
105
|
+
try:
|
|
55
106
|
data = port.read(8192)
|
|
56
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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()
|
package/src/node_transport.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
|
89
|
-
child.stdin.on('error',
|
|
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', () => {
|
|
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 => {
|
package/tests/check-package.mjs
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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('
|
|
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
|
+
})
|
package/tests/test-node-rom.mjs
CHANGED
|
@@ -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
|
-
|
|
19
|
-
|
|
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(),
|
|
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, `
|
|
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' })
|