Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions docs/adr/ADR-405-iphone-lidar-closed-stream-completion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# ADR-405: Complete iPhone LiDAR captures when the relay stream closes

- Status: proposed
- Date: 2026-10-11
- Related: ADR-373 (bounded device readers)

## Context

The iPhone reader completes at its packet limit, timeout, or pre-open failure.
After an opened WebSocket closes normally, however, it keeps the capture timer
running despite having no remaining source of frames. A one-frame stream that
closes immediately waits the entire requested window; an empty normal stream
also delays its no_frames result. This wastes the user's capture time and uses
the idle post-close interval in its reported receive rate.

## Decision

Finish on every close event. Retain accepted depth statistics. Preserve the
existing relay_closed refusal for abnormal empty streams, pre-open connection
failure, and max_frames termination. A normal empty stream still returns
no_frames; a stream with valid depth frames remains a successful capture. The
requested seconds field retains its existing meaning.

## Verification

An owned loopback HTTP server performs a real WebSocket upgrade and sends a
valid one-pixel depth frame followed by a normal close handshake. The public
reader must return promptly instead of waiting its two-second capture timeout.
A normal empty stream and abnormal empty code1008 stream check refusal behavior;
a max_frames=1 control checks existing early termination. These are transport
fixtures, not iPhone hardware or depth-accuracy evidence.
4 changes: 2 additions & 2 deletions harness/ruview/.harness/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
"src/cli.js": "5e8467f0d0fb90d81347715143b3777e239165d707dd9d8c5d647294509a7fc3",
"src/devices/esp32.js": "4d7ff3d63d751222756e227a98de38c70a707e8be2a431d1b3ac604622f94225",
"src/devices/esphome.js": "83b0fbd8c9075e233a401fa9f9dad04fc911e04819e7a0c48eb32d7e35d226af",
"src/devices/lidar.js": "d753d88375358ced5174376753c276b5ca621f38ae48eac58dbb307b9004b629",
"src/devices/lidar.js": "7ea6e9a1d4a2ccc8d8b61a1cfc54b673d31f23f62d89e6bb2b760a159a569683",
"src/devices/mmwave.js": "09d828e3b3e140926194ab83bf27e7c63cbda1cf438385c82c0e89b9d8e4b526",
"src/devices/registry.js": "26725e43adaa908b41462f492c5f6bf63e9193f3ee076e3d64ebaa012b66f6be",
"src/devices/serial-pump.js": "bb1351cd129fd00aa71a5d06104efcff5c0a56f3507e39e1df05395fe7fe7844",
Expand All @@ -75,7 +75,7 @@
"src/training.js": "b8451c48db69c7d077d75f07500996213beaa52705116f64afafe084e68e3956",
"src/ui/console-widget.js": "0d2f707d57b5ec54968dee1dc80f31c7db9152dcadfc48700c18379dabb55462"
},
"filesDigest": "60935843295b67f3354b4b02cc31df62bac8b5093568b11f896dfc7fc3a91545",
"filesDigest": "93db37287ae9a02867a15e9c6e4d25dfc894e399562acb9d665be1768625201d",
"brainDigest": "c0fb7b079ded157059b91601361429944697dae3cc42abc00dfe1a680986b0f4",
"gateFingerprint": "6e53c784eee38310188948fc75fb49e6b4ebc04e247d01b903fa8c8a92d67bdd",
"developmentPins": {
Expand Down
2 changes: 1 addition & 1 deletion harness/ruview/.harness/manifest.sha256
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0f73608c5acd0547ef850c0944ef76633a40e957c226ce555309832f87491a1c manifest.json
9e0dc9f605fe7d594d2389f8d37f6f40c134017882cf24c84db49536eb15465b manifest.json
1 change: 1 addition & 0 deletions harness/ruview/src/devices/lidar.js
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ export function readIphoneLidar(args, deps = {}) {
ws.onclose = (event) => {
if (!opened) finish();
else if (!frames.length && event?.code && event.code !== 1000) finish({ ok: false, reason: 'relay_closed', detail: `close code ${event.code}${event.code === 1008 ? ' (token rejected?)' : ''}` });
else finish(); // The closed stream cannot contribute another frame.
};
});
}
57 changes: 57 additions & 0 deletions harness/ruview/test/iphone-close.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import { createHash } from 'node:crypto';
import { readIphoneLidar } from '../src/devices/lidar.js';

// A real owned loopback WebSocket producer: upgrade, optional depth frame,
// close handshake. No injected client event callbacks or hardware.
async function relay(t, { frame = true, code = 1000 } = {}) {
const server = http.createServer();
const sockets = new Set();
server.on('connection', s => { sockets.add(s); s.on('close', () => sockets.delete(s)); });
t.after(async () => { for (const s of sockets) s.destroy(); await new Promise(r => server.close(r)); });
server.on('upgrade', (req, socket) => {
const accept = createHash('sha1').update(req.headers['sec-websocket-key'] + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest('base64');
socket.write(`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`);
if (frame) {
const mm = Buffer.alloc(2); mm.writeUInt16LE(1234);
const packet = { type: 'ruview.lidar.depth.v1', depth: { encoding: 'u16le-mm+u8-confidence', width: 1, height: 1, millimetersBase64: mm.toString('base64'), confidenceBase64: Buffer.from([1]).toString('base64') } };
const payload = Buffer.from(JSON.stringify(packet));
const header = Buffer.alloc(4); header[0] = 0x81; header[1] = 126; header.writeUInt16BE(payload.length, 2);
socket.write(Buffer.concat([header, payload]));
}
const close = Buffer.from([0x88, 2, 0, 0]); close.writeUInt16BE(code, 2);
socket.end(close);
});
await new Promise(r => server.listen(0, '127.0.0.1', r));
return `ws://127.0.0.1:${server.address().port}/ws/lidar`;
}

test('public iPhone reader completes when a successful relay stream closes', async t => {
const url = await relay(t);
const started = performance.now();
const result = await readIphoneLidar({ url, seconds: 2 });
assert.equal(result.ok, true, JSON.stringify(result));
assert.equal(result.frames, 1);
assert.equal(result.last.medianDepthM, 1.234);
assert.ok(performance.now() - started < 1000, 'closed relay must finish before the capture timeout');
});

test('normal empty relay close completes as no_frames', async t => {
const url = await relay(t, { frame: false });
const started = performance.now();
const result = await readIphoneLidar({ url, seconds: 2 });
assert.equal(result.ok, false);
assert.equal(result.reason, 'no_frames');
assert.ok(performance.now() - started < 1000, 'empty closed relay must finish before timeout');
});

test('abnormal empty relay close and packet limit controls retain semantics', async t => {
const denied = await readIphoneLidar({ url: await relay(t, { frame: false, code: 1008 }), seconds: 2 });
assert.equal(denied.reason, 'relay_closed');
assert.match(denied.detail, /1008/);
const limited = await readIphoneLidar({ url: await relay(t), seconds: 2, max_frames: 1 });
assert.equal(limited.ok, true);
assert.equal(limited.frames, 1);
});