@esp-vision/device-core 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -286,6 +286,205 @@ def display_stream(opts):
286
286
  lcd.deinit()
287
287
 
288
288
 
289
+ def camera_display_lifecycle(opts):
290
+ """Exercise camera/LCD coexistence, camera shutdown recovery, and restart."""
291
+ import gc, esp32, sensor, display, image
292
+
293
+ def dma_heaps():
294
+ return esp32.idf_heap_info(1 << 3) # ESP-IDF MALLOC_CAP_DMA.
295
+
296
+ lcd = None
297
+ frame = None
298
+ canvas = None
299
+ try:
300
+ sensor.shutdown()
301
+ gc.collect()
302
+ lcd = display.Display(backlight=100)
303
+ panel_width, panel_height = lcd.width(), lcd.height()
304
+ lcd.deinit()
305
+ lcd = None
306
+ gc.collect()
307
+
308
+ sensor.reset()
309
+ sensor.set_pixformat(sensor.RGB565)
310
+ sensor.set_framesize(sensor.QVGA)
311
+ sensor.skip_frames(n=3)
312
+ frame = sensor.snapshot()
313
+ dma_before = dma_heaps()
314
+ lcd = display.Display(backlight=100)
315
+ lcd.write(frame, fit=True)
316
+ dma_active = dma_heaps()
317
+ lcd.deinit()
318
+ lcd = None
319
+ frame = None
320
+ gc.collect()
321
+
322
+ sensor.shutdown()
323
+ gc.collect()
324
+ dma_shutdown = dma_heaps()
325
+ lcd = display.Display(backlight=100)
326
+ canvas = image.Image(32, 32, image.RGB565)
327
+ lcd.write(canvas, fit=True)
328
+ lcd.deinit()
329
+ lcd = None
330
+ canvas = None
331
+ gc.collect()
332
+
333
+ sensor.reset()
334
+ sensor.set_pixformat(sensor.RGB565)
335
+ sensor.set_framesize(sensor.QVGA)
336
+ sensor.skip_frames(n=3)
337
+ frame = sensor.snapshot()
338
+ restarted = (frame.width(), frame.height())
339
+ dma_restarted = dma_heaps()
340
+ return dict(width=panel_width, height=panel_height,
341
+ cameraWidth=restarted[0], cameraHeight=restarted[1],
342
+ dmaBefore=dma_before, dmaActive=dma_active,
343
+ dmaAfterShutdown=dma_shutdown, dmaAfterRestart=dma_restarted)
344
+ finally:
345
+ if lcd is not None:
346
+ lcd.deinit()
347
+ frame = None
348
+ canvas = None
349
+ sensor.shutdown()
350
+ gc.collect()
351
+
352
+
353
+ def image_copy_to_fb(opts):
354
+ """The unsupported copy_to_fb keyword must be rejected consistently."""
355
+ import gc, image
356
+
357
+ canvas = None
358
+ try:
359
+ try:
360
+ image.Image(32, 32, image.RGB565, copy_to_fb=True)
361
+ except TypeError:
362
+ pass
363
+ else:
364
+ raise AssertionError('Image accepted unsupported copy_to_fb')
365
+
366
+ canvas = image.Image(32, 32, image.RGB565)
367
+ try:
368
+ canvas.to_rgb565(copy_to_fb=True)
369
+ except TypeError:
370
+ pass
371
+ else:
372
+ raise AssertionError('to_rgb565 accepted unsupported copy_to_fb')
373
+ canvas.draw_rectangle(0, 0, 32, 32, color=(255, 0, 0), fill=True)
374
+ assert canvas.size() == 32 * 32 * 2, 'unexpected RGB565 buffer size'
375
+ return dict(width=canvas.width(), height=canvas.height(), size=canvas.size())
376
+ finally:
377
+ canvas = None
378
+ gc.collect()
379
+
380
+
381
+ def image_transform_argument(opts):
382
+ """Transform is not part of the ESP-VISION Image API."""
383
+ import image
384
+
385
+ canvas = image.Image(32, 32, image.RGB565)
386
+ source = image.Image(8, 8, image.RGB565)
387
+ matrix = ((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0))
388
+ for operation in (
389
+ lambda: canvas.copy(transform=matrix),
390
+ lambda: canvas.draw_image(source, transform=matrix),
391
+ ):
392
+ try:
393
+ operation()
394
+ except TypeError as exc:
395
+ assert 'keyword' in str(exc), 'transform is accepted but unsupported'
396
+ else:
397
+ raise AssertionError('Image accepted unsupported transform keyword')
398
+ return dict(operations=2)
399
+
400
+
401
+ def sensor_snapshot_buffer(opts):
402
+ """Validate the caller-owned writable buffer contract for sensor.snapshot."""
403
+ import gc, sensor
404
+
405
+ buffer = None
406
+ frame = None
407
+ try:
408
+ sensor.shutdown()
409
+ sensor.reset()
410
+ sensor.set_pixformat(sensor.RGB565)
411
+ sensor.set_framesize(sensor.QVGA)
412
+ sensor.skip_frames(n=3)
413
+ try:
414
+ sensor.snapshot(buffer=bytearray(16))
415
+ except TypeError:
416
+ pass
417
+ else:
418
+ raise AssertionError('sensor.snapshot unexpectedly accepted buffer keyword')
419
+ try:
420
+ sensor.snapshot(bytearray(16))
421
+ except ValueError as exc:
422
+ assert 'snapshot buffer too small' in str(exc), 'unexpected undersized buffer error: ' + str(exc)
423
+ else:
424
+ raise AssertionError('sensor.snapshot accepted an undersized buffer')
425
+ frame_bytes = 320 * 240 * 2
426
+ buffer = bytearray(frame_bytes)
427
+ frame = sensor.snapshot(buffer)
428
+ assert (frame.width(), frame.height(), frame.size()) == (320, 240, frame_bytes), 'unexpected caller-buffer frame'
429
+ buffer = None
430
+ gc.collect()
431
+ assert (frame.width(), frame.height()) == (320, 240), 'Image did not retain caller buffer after GC'
432
+ return dict(width=frame.width(), height=frame.height(), size=frame.size())
433
+ finally:
434
+ frame = None
435
+ buffer = None
436
+ sensor.shutdown()
437
+ gc.collect()
438
+
439
+
440
+ def memory_policy(opts):
441
+ """Large Image storage follows MicroPython GC; camera snapshots use PSRAM."""
442
+ import gc, esp32, image, sensor
443
+
444
+ def free_bytes(caps):
445
+ return sum(heap[1] for heap in esp32.idf_heap_info(caps))
446
+
447
+ def measure_image(width, height):
448
+ gc.collect()
449
+ before = gc.mem_alloc()
450
+ canvas = image.Image(width, height, image.RGB565)
451
+ gc.collect()
452
+ allocated = gc.mem_alloc() - before
453
+ canvas = None
454
+ gc.collect()
455
+ return width * height * 2, allocated
456
+
457
+ frame = None
458
+ try:
459
+ small_bytes, small_allocated = measure_image(32, 32)
460
+ assert small_bytes - 512 <= small_allocated <= small_bytes + 8192, 'small Image allocation did not use GC heap'
461
+ large_bytes, large_allocated = measure_image(640, 480)
462
+ assert large_bytes - 4096 <= large_allocated <= large_bytes + 16384, 'large Image allocation did not use GC heap'
463
+
464
+ sensor.shutdown()
465
+ gc.collect()
466
+ sensor.reset()
467
+ sensor.set_pixformat(sensor.RGB565)
468
+ sensor.set_framesize(sensor.QVGA)
469
+ psram_before = free_bytes(1 << 10) # ESP-IDF MALLOC_CAP_SPIRAM.
470
+ if psram_before <= 0:
471
+ raise RuntimeError('ESP_ERR_NOT_SUPPORTED: PSRAM heap unavailable')
472
+ frame = sensor.snapshot()
473
+ gc.collect()
474
+ psram_after = free_bytes(1 << 10)
475
+ frame_bytes = frame.width() * frame.height() * 2
476
+ psram_used = psram_before - psram_after
477
+ assert psram_used >= frame_bytes - 4096, 'camera snapshot buffer was not allocated from PSRAM'
478
+ return dict(smallBytes=small_bytes, smallAllocated=small_allocated,
479
+ largeBytes=large_bytes, largeAllocated=large_allocated,
480
+ width=frame.width(), height=frame.height(), frameBytes=frame_bytes,
481
+ psramUsed=psram_used)
482
+ finally:
483
+ frame = None
484
+ sensor.shutdown()
485
+ gc.collect()
486
+
487
+
289
488
 
290
489
  def memory(opts):
291
490
  import esp32
@@ -314,7 +513,12 @@ def run_check(name, opts):
314
513
  functions = dict(memory=memory, sensor_state=sensor_state, framesizes=framesizes,
315
514
  pixformats=pixformats, orientation=orientation, ipa_stats=ipa_stats,
316
515
  snapshot_rate=snapshot_rate, preview=preview, image_ops=image_ops,
317
- display=display, display_stream=display_stream)
516
+ display=display, display_stream=display_stream,
517
+ camera_display_lifecycle=camera_display_lifecycle,
518
+ image_copy_to_fb=image_copy_to_fb,
519
+ image_transform_argument=image_transform_argument,
520
+ sensor_snapshot_buffer=sensor_snapshot_buffer,
521
+ memory_policy=memory_policy)
318
522
  try:
319
523
  data = functions[name](opts)
320
524
  result = dict(name=name, status='PASS', data=data)
@@ -11,18 +11,36 @@ const capabilities = { ok: true, protocol: { evMuxVersion: 4 },
11
11
  features: [...EV_MUX_REQUIRED_FEATURES, 'debug.capture_frame', 'script.write', 'script.run', 'device.control'],
12
12
  channels: EV_MUX_REQUIRED_CHANNELS, limits: { ...EV_MUX_DEFAULT_LIMITS, txLargePayloadMax: 262144 },
13
13
  session: { policy: 'firstCome', leaseMs: 15000, keepaliveMs: 5000 } }
14
- function peer({ silent = false, screen = false } = {}) {
14
+ function peer({ silent = false, screen = false, vmReady = true, interactiveScripts = false } = {}) {
15
15
  let handlers, subscriptions = 0, disconnects = 0
16
16
  const requests = [], order = [], id = 'a'.repeat(32)
17
+ const requestWaiters = []
17
18
  const stdout = payload => handlers?.data(makeEvMuxFrame({ channel: 'repl.stdout', type: 'data', sessionId: id, vmEpoch: 1, payload }))
19
+ const recordRequest = request => {
20
+ requests.push(request)
21
+ for (let i = requestWaiters.length - 1; i >= 0; i -= 1) {
22
+ if (requestWaiters[i].predicate(request)) {
23
+ requestWaiters.splice(i, 1)[0].resolve(request)
24
+ }
25
+ }
26
+ }
27
+ const waitForRequest = predicate => {
28
+ const existing = requests.find(predicate)
29
+ return existing ? Promise.resolve(existing) : new Promise(resolve => requestWaiters.push({ predicate, resolve }))
30
+ }
18
31
  const parser = new EvMuxParser({ filterFrame(metadata, bytes) {
19
32
  if (silent) return false
20
33
  const payload = metadata.channel.endsWith('.rpc') ? JSON.parse(new TextDecoder().decode(bytes)) : new TextDecoder().decode(bytes)
21
- requests.push({ metadata, payload })
22
- if (metadata.channel === 'repl.stdin') { stdout(`${payload}\r\n${payload.includes('EVIDE_BOOT_OK') ? 'EVIDE_BOOT_OK' : 'result'}\r\n>>> `); return false }
34
+ recordRequest({ metadata, payload })
35
+ if (metadata.channel === 'repl.stdin') {
36
+ const startMarker = interactiveScripts && /~~EVSCRIPT-START:[A-Za-z0-9-]+~~/.exec(payload)?.[0]
37
+ if (startMarker) stdout(`${payload}\r\n${startMarker}\r\n`)
38
+ else stdout(`${payload}\r\n${payload.includes('EVIDE_BOOT_OK') ? 'EVIDE_BOOT_OK' : 'result'}\r\n>>> `)
39
+ return false
40
+ }
23
41
  if (!metadata.channel.endsWith('.rpc')) return false
24
42
  let result = { ok: true }
25
- if (metadata.method === 'hello') result = { ok: true, evMuxVersion: 4, deviceId: 'e8f60ae75642', hostname: 'esp-vision-test', board: 'TEST', firmware: { version: 'test' }, vmReady: true, session: { id, vmEpoch: 1, leaseMs: 15000, keepaliveMs: 5000 } }
43
+ if (metadata.method === 'hello') result = { ok: true, evMuxVersion: 4, deviceId: 'e8f60ae75642', hostname: 'esp-vision-test', board: 'TEST', firmware: { version: 'test' }, vmReady, session: { id, vmEpoch: 1, leaseMs: 15000, keepaliveMs: 5000 } }
26
44
  if (metadata.method === 'capabilities') result = { ...capabilities, features: [...capabilities.features, ...(screen ? ['debug.capture_screen'] : [])] }
27
45
  if (metadata.method === 'session.keepalive') result = { ok: true, sessionId: id, vmEpoch: 1 }
28
46
  if (metadata.method === 'debug.capture_frame' || (screen && metadata.method === 'debug.capture_screen')) {
@@ -37,7 +55,7 @@ function peer({ silent = false, screen = false } = {}) {
37
55
  async disconnect() { disconnects++; order.push('disconnect') },
38
56
  async writeBytes(bytes) { parser.feedBytes(bytes) },
39
57
  }
40
- return { transport, requests, order, stdout, close: () => handlers.close(), get subscriptions() { return subscriptions }, get disconnects() { return disconnects } }
58
+ return { transport, requests, order, stdout, waitForRequest, close: () => handlers.close(), get subscriptions() { return subscriptions }, get disconnects() { return disconnects } }
41
59
  }
42
60
 
43
61
  test('root exports only the application API; protocols remain explicit adapters', () => {
@@ -57,6 +75,8 @@ test('facade subscribes before connection, negotiates, streams input and dispose
57
75
  assert.equal(ready.identity.deviceId, 'e8f60ae75642')
58
76
  assert.deepEqual(p.order.slice(0, 2), ['subscribe','connect'])
59
77
  assert.equal(p.subscriptions, 1)
78
+ assert.ok(p.requests.some(x => x.metadata.channel === 'repl.signal'), 'default connection retains the REPL readiness probe')
79
+ assert.ok(p.requests.some(x => x.metadata.channel === 'repl.stdin' && x.payload.includes('EVIDE_BOOT_OK')))
60
80
  await d.python.input('print(2)\r')
61
81
  assert.ok(output.some(text => text.includes('result')))
62
82
  unsubscribe(); const count = output.length; p.stdout('ignored\r\n'); assert.equal(output.length,count)
@@ -66,6 +86,28 @@ test('facade subscribes before connection, negotiates, streams input and dispose
66
86
  await assert.rejects(d.files.read({path:'/x'}), {code:'DEVICE_NOT_READY'})
67
87
  })
68
88
 
89
+ test('connect can acquire and inspect a session without interrupting the REPL', async () => {
90
+ const p = peer({ vmReady: false }), d = api.createDevice({ transport: p.transport })
91
+ try {
92
+ const ready = await d.connect({ role: 'tcp', probeRepl: false })
93
+ assert.equal(ready.runtime.vmReady, false, 'passive negotiation preserves the device readiness report')
94
+ assert.ok(p.requests.some(x => x.metadata.method === 'hello' && x.payload.acquire === true))
95
+ assert.ok(p.requests.some(x => x.metadata.method === 'capabilities'))
96
+ assert.equal(p.requests.some(x => x.metadata.channel === 'repl.signal' || x.metadata.channel === 'repl.stdin'), false)
97
+ await assert.rejects(d.python.execute({ source: 'print(1)' }), /requires a ready VM/)
98
+ assert.equal(p.requests.some(x => x.metadata.channel === 'repl.signal' || x.metadata.channel === 'repl.stdin'), false)
99
+ } finally { await d.disconnect() }
100
+ })
101
+
102
+ test('connect validates probeRepl before opening the transport', async () => {
103
+ const p = peer(), d = api.createDevice({ transport: p.transport })
104
+ for (const probeRepl of [null, 'false', 0]) {
105
+ await assert.rejects(d.connect({ role: 'tcp', probeRepl }), { name: 'TypeError' })
106
+ }
107
+ assert.deepEqual(p.order, [])
108
+ assert.deepEqual(p.requests, [])
109
+ })
110
+
69
111
  test('capture returns binary bytes and unsupported LCD capture never captures the camera', async () => {
70
112
  const p = peer(), d = api.createDevice({transport:p.transport})
71
113
  try {
@@ -102,6 +144,53 @@ test('transport loss clears the connection and permits reconnection',async()=>{
102
144
  await d.connect({role:'tcp'});await d.disconnect();assert.equal(p.disconnects,2)
103
145
  })
104
146
 
147
+ test('disconnect releases the session without interrupting a publicly started run', async () => {
148
+ const p = peer({ interactiveScripts: true }), d = api.createDevice({ transport: p.transport })
149
+ try {
150
+ await d.connect({ role: 'tcp', probeRepl: false })
151
+ const run = await d.python.execute({ path: '/main.py', completion: 'start' })
152
+ const doneRejected = assert.rejects(run.done)
153
+ const beforeDisconnect = p.requests.length
154
+ await d.disconnect()
155
+ await doneRejected
156
+ const released = p.requests.slice(beforeDisconnect)
157
+ assert.ok(released.some(request => request.metadata.method === 'session.release'))
158
+ assert.equal(released.some(request => request.metadata.channel === 'repl.signal'
159
+ || (request.metadata.method === 'device.control' && request.payload.action === 'stop')), false)
160
+ } finally { await d.disconnect() }
161
+ })
162
+
163
+ test('caller AbortSignal still interrupts a publicly started run', async () => {
164
+ const p = peer({ interactiveScripts: true }), d = api.createDevice({ transport: p.transport })
165
+ const controller = new AbortController()
166
+ try {
167
+ await d.connect({ role: 'tcp', probeRepl: false })
168
+ const run = await d.python.execute({ path: '/main.py', completion: 'start', signal: controller.signal })
169
+ const doneRejected = assert.rejects(run.done, { name: 'AbortError' })
170
+ const beforeAbort = p.requests.length
171
+ controller.abort()
172
+ await doneRejected
173
+ assert.ok(p.requests.slice(beforeAbort).some(request => request.metadata.method === 'device.control'
174
+ && request.payload.action === 'stop'))
175
+ } finally { await d.disconnect() }
176
+ })
177
+
178
+ test('disconnect still interrupts Python execution in completion-wait mode', async () => {
179
+ const p = peer(), d = api.createDevice({ transport: p.transport })
180
+ try {
181
+ await d.connect({ role: 'tcp', probeRepl: false })
182
+ const execution = d.python.execute({ path: '/main.py' })
183
+ await p.waitForRequest(request => request.metadata.channel === 'repl.stdin'
184
+ && String(request.payload).includes('~~EVSCRIPT-END:'))
185
+ const failed = assert.rejects(execution)
186
+ const beforeDisconnect = p.requests.length
187
+ await d.disconnect()
188
+ await failed
189
+ assert.ok(p.requests.slice(beforeDisconnect).some(request => request.metadata.method === 'device.control'
190
+ && request.payload.action === 'stop'))
191
+ } finally { await d.disconnect() }
192
+ })
193
+
105
194
  test('discovery works before any transport is connected',async()=>{
106
195
  const d=api.createDevice({discovery:{async list(){return [{deviceId:'e8f60ae75642',hostname:'esp-vision-test',board:'TEST',firmwareVersion:'1',evMuxVersion:4,port:3232,addresses:['192.168.1.2']}]}}})
107
196
  assert.equal((await d.discover({timeout:50}))[0].compatible,true)
@@ -126,8 +215,9 @@ test('long Unicode paths stay within REPL budget for directory reads and inline
126
215
  repl.execPy=async source=>{commands.push(source);assert.ok(repl.execInputBytes(source)<=511);return source.includes('__EVLIST__')?'__EVLIST__[]\n':''}
127
216
  assert.deepEqual(await repl.listFs(path),[])
128
217
  assert.equal(commands.length,2,'Long escaped paths are assigned separately from directory code')
129
- const command=repl.createScriptRunCommand(path)
218
+ const command=repl.createScriptRunCommand(path,{acknowledgeStart:true})
130
219
  assert.ok(new TextEncoder().encode(command).length<=511)
220
+ assert.match(command,/EVSCRIPT-START:/)
131
221
  if(repl.scriptRunPrelude)assert.ok(repl.execInputBytes(repl.scriptRunPrelude)<=511)
132
222
  repl.cancelScriptEndSentinel()
133
223
  })
@@ -1,29 +1,71 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  import assert from 'node:assert/strict'
3
3
  import test from 'node:test'
4
+ import { createPythonApi } from '../src/device_python.js'
4
5
  import { DeviceSession } from '../src/device_session.js'
5
6
  import { EvMuxParser, makeEvMuxFrame } from '../src/evmux.js'
6
7
  import { EvRepl } from '../src/evrepl.js'
7
8
 
8
- function executionFixture({ short = false, failWrite = false, failStop = false, stalledReset = false } = {}) {
9
+ function executionFixture({ short = false, failWrite = false, failStop = false, stalledReset = false, deferExec = false, deferRun = false, deferStart = false, features = ['file.write.binary', 'script.run', 'device.control'] } = {}) {
9
10
  const requests = []
10
11
  let running = false
11
12
  let sentinel = null
13
+ let startMarker = null
14
+ let startMarkerEmitted = false
15
+ let pendingExec = null
12
16
  let terminal = ''
17
+ let startAndCompletionInSameFrame = false
13
18
  let session
14
19
  let resetRequested = false
15
20
  let resetPolls = 0
16
21
  let uploaded = 0
17
- const stdout = (payload) => session.feedBytes(makeEvMuxFrame({
18
- channel: 'repl.stdout', type: 'data', sessionId: session.sessionId, vmEpoch: session.vmEpoch, payload,
19
- }))
22
+ const stdout = (payload) => {
23
+ if (startMarker && sentinel?.marker && String(payload).includes(startMarker) && String(payload).includes(sentinel.marker)) {
24
+ startAndCompletionInSameFrame = true
25
+ }
26
+ session.feedBytes(makeEvMuxFrame({
27
+ channel: 'repl.stdout', type: 'data', sessionId: session.sessionId, vmEpoch: session.vmEpoch, payload,
28
+ }))
29
+ }
30
+ const finishFast = () => {
31
+ if (startMarker && !startMarkerEmitted && sentinel) {
32
+ startMarkerEmitted = true
33
+ running = true
34
+ const completionMarker = sentinel.marker
35
+ stdout(`${startMarker}\r\n${completionMarker}\r\n>>> `)
36
+ running = false
37
+ sentinel = null
38
+ startMarker = null
39
+ startMarkerEmitted = false
40
+ return
41
+ }
42
+ finish()
43
+ }
44
+ const enter = () => {
45
+ if (!startMarker || startMarkerEmitted) return
46
+ startMarkerEmitted = true
47
+ running = true
48
+ const split = Math.floor(startMarker.length / 2)
49
+ stdout(startMarker.slice(0, split))
50
+ stdout(`${startMarker.slice(split)}\r`)
51
+ stdout('\n')
52
+ }
20
53
  const finish = () => {
54
+ if (startMarker && !startMarkerEmitted) enter()
21
55
  running = false
22
56
  if (sentinel) {
23
- stdout(`${sentinel.line}${sentinel.marker}\r\n>>> `)
57
+ stdout(`${sentinel.startMarker ? '' : sentinel.line}${sentinel.marker}\r\n>>> `)
24
58
  sentinel = null
59
+ startMarker = null
60
+ startMarkerEmitted = false
25
61
  }
26
62
  }
63
+ const finishExec = () => {
64
+ if (!pendingExec) return
65
+ const command = pendingExec
66
+ pendingExec = null
67
+ stdout(`${command}>>> `)
68
+ }
27
69
  const parser = new EvMuxParser({
28
70
  filterFrame: (metadata, bytes) => {
29
71
  const text = new TextDecoder().decode(bytes)
@@ -35,10 +77,27 @@ function executionFixture({ short = false, failWrite = false, failStop = false,
35
77
  resetRequested = true
36
78
  return
37
79
  }
38
- const match = /print\("(~~EVSCRIPT-END:[^"]+)"\)/.exec(text)
39
- if (match) {
40
- sentinel = { line: text, marker: match[1] }
41
- if (short) finish()
80
+ const marker = /~~EVSCRIPT-END:[A-Za-z0-9-]+~~/.exec(text)?.[0]
81
+ if (marker) {
82
+ startMarker = /~~EVSCRIPT-START:[A-Za-z0-9-]+~~/.exec(text)?.[0] ?? null
83
+ startMarkerEmitted = false
84
+ sentinel = { line: text.trimEnd(), marker, startMarker }
85
+ if (startMarker) {
86
+ const echoSplit = Math.floor(text.length / 2)
87
+ stdout(text.slice(0, echoSplit))
88
+ stdout(`${text.slice(echoSplit)}\r\n`)
89
+ }
90
+ if (deferRun) {
91
+ if (startMarker && !deferStart) enter()
92
+ running = true
93
+ stdout('')
94
+ } else if (short && startMarker && !deferStart) finishFast()
95
+ else {
96
+ if (startMarker && !deferStart) enter()
97
+ if (short) finish()
98
+ }
99
+ } else if (deferExec && text.startsWith('exec(')) {
100
+ pendingExec = text
42
101
  } else {
43
102
  stdout(`${text}\r\n>>> `)
44
103
  }
@@ -85,10 +144,79 @@ function executionFixture({ short = false, failWrite = false, failStop = false,
85
144
  session.vmEpoch = 1
86
145
  session.vmReady = true
87
146
  session.limits.fileWriteChunkMax = 16384
88
- session.capabilities = { features: ['file.write.binary', 'script.run', 'device.control'] }
89
- return { session, requests, finish, get terminal() { return terminal }, get running() { return running } }
147
+ session.capabilities = { features }
148
+ return { session, requests, finish, enter, finishExec, get terminal() { return terminal }, get running() { return running },
149
+ get startAndCompletionInSameFrame() { return startAndCompletionInSameFrame } }
90
150
  }
91
151
 
152
+ test('inline source assignment splits at the REPL limit without changing source text', () => {
153
+ const repl = new EvRepl({ writeEvmux: async () => {} })
154
+ const variableName = '__evcore_python_boundary'
155
+ const source = `${'x'.repeat(470)}😀\nprint("done")`
156
+ assert.ok(repl.execInputBytes(source) <= repl.inputMaxBytes)
157
+ const assignments = repl.createInlineSourceAssignments(source, variableName)
158
+ assert.ok(assignments.length > 1, 'the private assignment prefix requires safe chunks at this boundary')
159
+ assert.ok(assignments.every(statement => repl.execInputBytes(statement) <= repl.inputMaxBytes))
160
+ const rebuilt = assignments.map((statement, index) => JSON.parse(statement.slice(variableName.length + (index === 0 ? 1 : 2)))).join('')
161
+ assert.equal(rebuilt, source)
162
+ const command = repl.createInlineScriptRunCommand(variableName)
163
+ assert.ok(new TextEncoder().encode(command).length <= repl.inputMaxBytes)
164
+ assert.match(command, new RegExp(`exec\\(${variableName}\\)`))
165
+ assert.match(command, new RegExp(`del ${variableName}`))
166
+ })
167
+
168
+ test('python.execute small source waits past 30 seconds without requiring device storage', async (t) => {
169
+ t.mock.timers.enable({ apis: ['setTimeout'] })
170
+ const fixture = executionFixture({ deferRun: true, features: [] })
171
+ t.after(() => fixture.session.close())
172
+ let settled = false
173
+ const execution = fixture.session.executePython('while True: pass').then(output => {
174
+ settled = true
175
+ return output
176
+ })
177
+ await new Promise(resolve => setImmediate(resolve))
178
+ assert.equal(fixture.session.runCapture?.timer, null, 'omitted timeout leaves completion pending')
179
+ assert.equal(fixture.session.runCapture?.path, null, 'small source does not create a device file')
180
+ assert.ok(fixture.requests.some(({ metadata, payload }) => metadata.channel === 'repl.stdin' && String(payload).includes('__evcore_python_')))
181
+ assert.ok(!fixture.requests.some(({ metadata }) => ['file.write', 'script.run'].includes(metadata.method)))
182
+ t.mock.timers.tick(31000)
183
+ await Promise.resolve()
184
+ assert.equal(settled, false, 'silent Python still waits beyond the former fixed completion timeout')
185
+ fixture.finish()
186
+ assert.match(await execution, /^\s*$/)
187
+ })
188
+
189
+ test('Python deadlines include REPL setup and cancellation releases API ownership', async (t) => {
190
+ t.mock.timers.enable({ apis: ['setTimeout'] })
191
+ const timed = executionFixture({ deferExec: true })
192
+ t.after(() => timed.session.close())
193
+ const state = {}
194
+ const python = createPythonApi({ session: timed.session, state })
195
+ const execution = python.execute({ source: 'print(42)', timeout: 1000 })
196
+ const timedOut = assert.rejects(execution, { code: 'TIMEOUT' })
197
+ await new Promise(resolve => setImmediate(resolve))
198
+ t.mock.timers.tick(1000)
199
+ await timedOut
200
+ assert.equal(state.operation, null, 'deadline releases the operation lock even during setup')
201
+
202
+ const cancelled = executionFixture({ deferRun: true })
203
+ t.after(() => cancelled.session.close())
204
+ const cancelledState = {}
205
+ const cancelledPython = createPythonApi({ session: cancelled.session, state: cancelledState })
206
+ const controller = new AbortController()
207
+ const cancellation = cancelledPython.execute({ source: 'while True: pass', signal: controller.signal })
208
+ const aborted = assert.rejects(cancellation, { name: 'AbortError' })
209
+ await new Promise(resolve => setImmediate(resolve))
210
+ assert.ok(cancelledState.operation, 'the operation remains owned while Python is still running')
211
+ controller.abort()
212
+ await aborted
213
+ await new Promise(resolve => setImmediate(resolve))
214
+ assert.equal(cancelledState.operation, null, 'cancellation releases the operation lock')
215
+ assert.equal(cancelledState.run, null, 'cancellation does not leave a false active-run claim')
216
+ assert.ok(cancelled.requests.some(({ metadata, payload }) => metadata.method === 'device.control' && payload.action === 'stop'),
217
+ 'cancellation requests a stop but the API still reports cancellation, not success')
218
+ })
219
+
92
220
  test('interactive execution survives the bounded-run deadline, streams output, and completes on shared stop', async (t) => {
93
221
  t.mock.timers.enable({ apis: ['setTimeout'] })
94
222
  const fixture = executionFixture()
@@ -111,11 +239,75 @@ test('interactive execution survives the bounded-run deadline, streams output, a
111
239
  assert.ok(!fixture.terminal.includes('~~EVSCRIPT-END'), 'shared completion markers never leak into terminal output')
112
240
  })
113
241
 
114
- test('short scripts can complete while startScript is returning', async () => {
242
+ test('startScript waits for the VM-entry marker and ignores the echoed marker in its command', async () => {
243
+ const fixture = executionFixture({ deferStart: true })
244
+ let resolved = false
245
+ const starting = fixture.session.startScript('/main.py', undefined, { inlineCompletion: true }).then(run => {
246
+ resolved = true
247
+ return run
248
+ })
249
+ await new Promise(resolve => setImmediate(resolve))
250
+ assert.equal(resolved, false, 'a successful stdin write is not evidence that MicroPython entered the runner')
251
+ assert.equal(fixture.running, false)
252
+ assert.equal(fixture.session.runCapture?.timer, null, 'interactive start acknowledgement does not create an aggregate run deadline')
253
+ fixture.enter()
254
+ const run = await starting
255
+ assert.equal(fixture.running, true)
256
+ assert.doesNotMatch(fixture.terminal, /~~EVSCRIPT-START:/, 'the private entry marker is filtered from terminal output')
257
+ fixture.finish()
258
+ await run.done
259
+ fixture.session.close()
260
+ })
261
+
262
+ test('startScript entry acknowledgement has a bounded setup wait', async (t) => {
263
+ t.mock.timers.enable({ apis: ['setTimeout'] })
264
+ const fixture = executionFixture({ deferStart: true })
265
+ const starting = fixture.session.startScript('/main.py', undefined, { inlineCompletion: true })
266
+ const timedOut = assert.rejects(starting, { code: 'SCRIPT_START_TIMEOUT' })
267
+ await new Promise(resolve => setImmediate(resolve))
268
+ t.mock.timers.tick(5000)
269
+ await timedOut
270
+ assert.equal(fixture.session.scriptRunSnapshot(), null)
271
+ fixture.session.close()
272
+ })
273
+
274
+ test('cancellation while waiting for VM entry rejects start and releases the run capture', async () => {
275
+ const fixture = executionFixture({ deferStart: true })
276
+ const controller = new AbortController()
277
+ const starting = fixture.session.startScript('/main.py', undefined, {
278
+ inlineCompletion: true,
279
+ signal: controller.signal,
280
+ })
281
+ const cancelled = assert.rejects(starting, { name: 'AbortError' })
282
+ await new Promise(resolve => setImmediate(resolve))
283
+ controller.abort()
284
+ await cancelled
285
+ await new Promise(resolve => setImmediate(resolve))
286
+ assert.equal(fixture.session.scriptRunSnapshot(), null)
287
+ assert.ok(fixture.requests.some(({ metadata, payload }) => metadata.method === 'device.control' && payload.action === 'stop'))
288
+ fixture.session.close()
289
+ })
290
+
291
+ test('closing the transport while waiting for VM entry rejects start and releases the run capture', async () => {
292
+ const fixture = executionFixture({ deferStart: true })
293
+ const starting = fixture.session.startScript('/main.py', undefined, { inlineCompletion: true })
294
+ const disconnected = assert.rejects(starting, /device disconnected/)
295
+ await new Promise(resolve => setImmediate(resolve))
296
+ assert.ok(fixture.session.repl.scriptStartWaiter, 'startup is waiting for the VM-entry acknowledgement')
297
+ fixture.session.close(new Error('device disconnected'))
298
+ await disconnected
299
+ assert.equal(fixture.session.scriptRunSnapshot(), null)
300
+ assert.equal(fixture.session.repl.scriptStartWaiter, null)
301
+ assert.doesNotMatch(fixture.terminal, /~~EVSCRIPT-START:/)
302
+ })
303
+
304
+ test('short scripts can complete in the same frame while startScript is returning', async () => {
115
305
  const fixture = executionFixture({ short: true })
116
- const run = await fixture.session.startScript('/short.py', 'print(1)')
306
+ const run = await fixture.session.startScript('/short.py', undefined, { inlineCompletion: true })
307
+ assert.equal(fixture.startAndCompletionInSameFrame, true)
117
308
  assert.equal((await run.done).path, '/short.py')
118
309
  assert.equal(fixture.session.scriptRunSnapshot(), null)
310
+ assert.doesNotMatch(fixture.terminal, /~~EVSCRIPT-START:/)
119
311
  fixture.session.close()
120
312
  })
121
313