fix(sim): prevent duplicate stream start loops and noisy recording fallback errors

This commit is contained in:
2026-02-17 16:15:00 +00:00
parent ff9d4097e1
commit ef652ea7e5

View File

@@ -186,6 +186,9 @@ let sfuSendTransport = null;
let sfuRecvTransport = null; let sfuRecvTransport = null;
let sfuPublishedProducerId = null; let sfuPublishedProducerId = null;
let sfuConsumedTrack = null; let sfuConsumedTrack = null;
let streamRequestInFlight = false;
let hasAutoRequestedInitialStream = false;
const inflightCameraStreamCommands = new Set();
const rtcConfig = { const rtcConfig = {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }], iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
}; };
@@ -914,7 +917,14 @@ const finalizeRecordingForStream = async (streamSessionId, captureResult) => {
if (recording?.id) { if (recording?.id) {
try { try {
if (!captureResult?.blob || captureResult.blob.size === 0) { if (!captureResult?.blob || captureResult.blob.size === 0) {
throw new Error('No captured video blob to upload'); const fallbackObjectKey = `sim/${streamSessionId}/${Date.now()}.webm`;
await API.events.finalizeRecording(recording.id, {
objectKey: fallbackObjectKey,
durationSeconds: captureResult?.durationSeconds ?? 15,
sizeBytes: captureResult?.blob?.size ?? 0,
});
addActivity('Recording', 'No local blob; finalized with simulator fallback');
return true;
} }
const uploadMeta = await API.request('/videos/upload-url', { const uploadMeta = await API.request('/videos/upload-url', {
@@ -998,11 +1008,26 @@ const connectSocket = () => {
try { try {
if (payload.commandType === 'start_stream') { if (payload.commandType === 'start_stream') {
const streamId = payload.payload.streamSessionId; const streamId = payload.payload.streamSessionId;
if (inflightCameraStreamCommands.has(streamId)) {
addActivity('Stream', `Duplicate start ignored for ${streamId.substring(0, 8)}`);
socket.emit('command:ack', { commandId: payload.commandId, status: 'acknowledged' });
return;
}
inflightCameraStreamCommands.add(streamId);
const ready = await startCameraPreview(); const ready = await startCameraPreview();
if (!ready) { if (!ready) {
throw new Error('Camera permission is required before streaming'); throw new Error('Camera permission is required before streaming');
} }
await API.streams.accept(streamId); try {
await API.streams.accept(streamId);
} catch (error) {
const message = error?.message || '';
if (!message.includes('status 409')) {
throw error;
}
addActivity('Stream', 'Accept already handled, continuing publish setup');
}
await API.streams.getPublishCreds(streamId); await API.streams.getPublishCreds(streamId);
await startLocalRecording(); await startLocalRecording();
if (isSfuMode()) { if (isSfuMode()) {
@@ -1018,25 +1043,32 @@ const connectSocket = () => {
addActivity('Stream', 'Accepted & Published'); addActivity('Stream', 'Accepted & Published');
// Auto-stop after 15s for simulation // Auto-stop after 15s for simulation
setTimeout(async () => { setTimeout(async () => {
const captureResult = await stopLocalRecording(); try {
await API.streams.end(streamId); const captureResult = await stopLocalRecording();
await finalizeRecordingForStream(streamId, captureResult); await API.streams.end(streamId);
stopFrameRelay(); await finalizeRecordingForStream(streamId, captureResult);
if (!isSfuMode() && socket && payload.sourceDeviceId) { stopFrameRelay();
socket.emit('webrtc:signal', { if (!isSfuMode() && socket && payload.sourceDeviceId) {
toDeviceId: payload.sourceDeviceId, socket.emit('webrtc:signal', {
streamSessionId: streamId, toDeviceId: payload.sourceDeviceId,
signalType: 'hangup', streamSessionId: streamId,
}); signalType: 'hangup',
});
}
teardownPeerConnection();
store.update({ activeCameraDeviceId: null, activeStreamSessionId: null });
addActivity('Stream', 'Ended auto-simulation');
} finally {
inflightCameraStreamCommands.delete(streamId);
} }
teardownPeerConnection();
store.update({ activeCameraDeviceId: null, activeStreamSessionId: null });
addActivity('Stream', 'Ended auto-simulation');
}, 15000); }, 15000);
} }
socket.emit('command:ack', { commandId: payload.commandId, status: 'acknowledged' }); socket.emit('command:ack', { commandId: payload.commandId, status: 'acknowledged' });
} catch (e) { } catch (e) {
if (payload?.payload?.streamSessionId) {
inflightCameraStreamCommands.delete(payload.payload.streamSessionId);
}
socket.emit('command:ack', { commandId: payload.commandId, status: 'rejected', error: e.message }); socket.emit('command:ack', { commandId: payload.commandId, status: 'rejected', error: e.message });
} }
}); });
@@ -1348,12 +1380,25 @@ const Actions = {
}, },
requestStream: async (camId) => { requestStream: async (camId) => {
if (streamRequestInFlight) {
return;
}
const current = store.get();
if (current.activeStreamSessionId) {
return;
}
streamRequestInFlight = true;
try { try {
store.update({ activeCameraDeviceId: camId }); store.update({ activeCameraDeviceId: camId });
Toast.show('Requesting Stream...', 'info'); Toast.show('Requesting Stream...', 'info');
await API.streams.request(camId); await API.streams.request(camId);
// Socket will handle the rest ('stream:started') // Socket will handle the rest ('stream:started')
} catch (e) { } } catch (e) {
store.update({ activeCameraDeviceId: null });
} finally {
streamRequestInFlight = false;
}
}, },
openRecording: async (recordingId) => { openRecording: async (recordingId) => {
@@ -1469,7 +1514,8 @@ const render = (state) => {
// 5. Client Mode Lists // 5. Client Mode Lists
if (state.device?.role === 'client' && state.screen === 'home') { if (state.device?.role === 'client' && state.screen === 'home') {
if (!state.activeCameraDeviceId && state.linkedCameras.length > 0) { if (!hasAutoRequestedInitialStream && !state.activeCameraDeviceId && state.linkedCameras.length > 0 && !streamRequestInFlight) {
hasAutoRequestedInitialStream = true;
void Actions.requestStream(state.linkedCameras[0].cameraDeviceId); void Actions.requestStream(state.linkedCameras[0].cameraDeviceId);
} }