Skip to main content

Disconnect Timeout & Participant Rejoin System

This document explains the architecture and implementation of HyperStudy's disconnect timeout and participant rejoin system, which allows participants to return to their experiment after a connection loss while maintaining experiment integrity.

Overview

The disconnect timeout system provides two key features:

  1. Participant Rejoin: Participants who refresh or lose connection can return to their current experiment state without restarting
  2. Automatic Pause: Multi-person experiments automatically pause when a participant disconnects, with a configurable timeout before ending the experiment

Architecture

System Components

┌─────────────────┐
│ Participant │
│ Refreshes │
└────────┬────────┘


┌─────────────────────────────────────────┐
│ Frontend: Detect Rejoin │
│ - Check session status = 'active' │
│ - Check deviceSetupCompleted flag │
│ - Skip device setup if already done │
└────────┬────────────────────────────────┘


┌─────────────────────────────────────────┐
│ Backend: Handle Rejoin │
│ - Detect participant already in room │
│ - Clear disconnect timeout if paused │
│ - Resume state timer with remaining │
│ - Send current state to participant │
└─────────────────────────────────────────┘


┌─────────────────┐
│ Participant │
│ Disconnects │
└────────┬────────┘


┌─────────────────────────────────────────┐
│ Backend: Pause Experiment │
│ - Clear state timer │
│ - Set pause state │
│ - Start disconnect timeout (60s) │
│ - Notify all participants │
└────────┬────────────────────────────────┘


┌─────────────────────────────────────────┐
│ Frontend: Show Pause Overlay │
│ - Display countdown timer │
│ - Show participant status │
│ - Block user interaction │
└─────────────────────────────────────────┘

┌────┴────┐
│ │
▼ ▼
Rejoin Timeout
Resume Complete

Implementation Details

Frontend Components

1. Session Service (participantSessionService.js)

Tracks device setup completion to enable smart rejoin:

// Session schema includes:
deviceSetupCompleted: false,
deviceSetupTimestamp: null,

// Method to mark completion
async setDeviceSetupComplete(userId) {
await this.updateSession(userId, {
deviceSetupCompleted: true,
deviceSetupTimestamp: serverTimestamp()
});
}

2. Experiment Route (experiment.svelte)

Detects rejoin scenario and prevents unnecessary redirects:

// Detect rejoin: active session with roomId
if (session.status === 'active' && session.roomId) {
isRejoin = true;
// Continue to experiment, don't redirect to lobby/waiting
}

3. ExperimentRunner (ExperimentRunner.svelte)

Skips device setup for returning participants:

// Check if device setup already completed
let shouldSkipDeviceSetupForRejoin = false;
if (isRejoin && auth.currentUser) {
const session = await sessionService.getSession(auth.currentUser.uid);
shouldSkipDeviceSetupForRejoin = session?.deviceSetupCompleted === true;
}

const needsDeviceSetup = !skipDeviceSetup &&
!shouldSkipDeviceSetupForRejoin &&
shouldShowDeviceSetupCheck(experimentData);

Listens for pause/resume events:

experimentSocket.on('experiment-paused', (data) => {
isPaused = true;
pauseInfo = data;
});

experimentSocket.on('experiment-resumed', (data) => {
isPaused = false;
pauseInfo = null;
});

4. Pause Overlay (ExperimentPauseOverlay.svelte)

Displays full-screen overlay during pause:

  • Circular countdown timer: Updates every 100ms
  • Participant status: Shows who's connected/disconnected
  • Blocks interaction: Full-screen z-index 9999
  • Auto-cleanup: Clears interval on component destroy

Backend Components

1. Disconnect Detection (experimentStateServer.js - handleDisconnect)

Triggers pause for multi-person experiments:

// Guard clauses:
if (room.isStarted && // Experiment running
room.expectedCount > 1 && // Multi-person
room.experimentData?.disconnectTimeout?.enabled === true && // Feature enabled
!room.isPausedForDisconnect) { // Not already paused

// Pause state timer
clearTimeout(room.timerRef);
clearInterval(room.timerUpdateInterval);

// Set pause state
room.isPausedForDisconnect = true;
room.pausedAt = Date.now();
room.disconnectedParticipant = participantId;

// Start disconnect timeout
const timeoutDuration = room.experimentData.disconnectTimeout.durationMs || 60000;
room.disconnectTimeoutRef = setTimeout(async () => {
await this.endExperiment(roomId, 'DISCONNECT_TIMEOUT');
}, timeoutDuration);

// Notify participants
this.io.of('/experiment').to(`room:${roomId}`).emit('experiment-paused', {
reason: 'participant_disconnect',
participantId,
timeoutDurationMs: timeoutDuration,
timeoutEndTime: Date.now() + timeoutDuration,
message: `A participant disconnected. Waiting ${timeoutDuration/1000}s for reconnection...`
});
}

Why multi-person only?

  • Single-participant experiments don't need coordination
  • Participant can rejoin at their own pace
  • No other participants waiting

2. Rejoin Detection (experimentStateServer.js - handleJoinExperiment)

Resumes experiment when participant returns:

// Detect rejoin: participant already exists in room
if (room.participants.has(normalizedParticipantId)) {
// Add socket to existing participant
room.participants.get(normalizedParticipantId).sockets.add(socket.id);
room.participants.get(normalizedParticipantId).isActive = true;

// Check if paused for this participant
if (room.isPausedForDisconnect &&
room.disconnectedParticipant === normalizedParticipantId) {

// Clear timeout
clearTimeout(room.disconnectTimeoutRef);

// Resume state timer with remaining time
if (room.timerDetails && room.pausedAt) {
const elapsedBeforePause = room.pausedAt - room.timerDetails.startTime;
const remainingDuration = room.timerDetails.duration - elapsedBeforePause;

if (remainingDuration > 0) {
this.startStateTimer(roomId, room.currentState.id, remainingDuration);
}
}

// Notify participants
this.io.of('/experiment').to(`room:${roomId}`).emit('experiment-resumed', {
reason: 'participant_reconnected',
participantId: normalizedParticipantId,
message: 'Participant reconnected. Resuming experiment...'
});
}

// Send current state to rejoining participant
if (room.isStarted && room.currentState) {
socket.emit('state-update', {
state: room.currentState,
stateIndex: room.stateIndex,
totalStates: room.totalStates,
sharedData: room.sharedData
});
}
}

3. Experiment Completion (experimentStateServer.js - endExperiment)

Enhanced to include completion reason:

async endExperiment(roomId, reason = 'SUCCESS') {
// Clear disconnect timeout if exists
if (room.disconnectTimeoutRef) {
clearTimeout(room.disconnectTimeoutRef);
}

room.isPausedForDisconnect = false;

// Emit completion with reason
this.io.of('/experiment').to(`room:${roomId}`).emit('experiment-complete', {
experimentId: room.experimentId,
totalStates: room.totalStates,
reason: reason, // 'SUCCESS', 'DISCONNECT_TIMEOUT', etc.
completionTimestamp: experimentEndTime
});
}

Completion reasons:

  • SUCCESS: Normal completion through all states
  • DISCONNECT_TIMEOUT: Participant didn't rejoin in time
  • TIMEOUT: Waiting room timeout
  • TECHNICAL: Technical issue
  • NO_CONSENT: Participant declined consent

Configuration

Experiment Configuration Schema

Add to experiment document in Firebase:

{
"disconnectTimeout": {
"enabled": false, // Enable/disable feature (default: false)
"durationMs": 60000, // Timeout in milliseconds (default: 60s)
"action": "complete" // What to do on timeout (currently only 'complete')
}
}

Default Behavior

  • Disabled by default: Opt-in per experiment
  • 60-second timeout: Configurable per experiment
  • Completes with questionnaires: Follows normal completion flow

State Management

Room State Fields

New fields added to room objects:

{
isPausedForDisconnect: false, // Is experiment paused for disconnect?
pausedAt: null, // Timestamp when paused
disconnectedParticipant: null, // ID of disconnected participant
disconnectTimeoutRef: null, // setTimeout reference
disconnectTimeoutEndTime: null // When timeout expires
}

Session State Fields

New fields added to participant sessions:

{
deviceSetupCompleted: false, // Has device setup been completed?
deviceSetupTimestamp: null, // When was it completed?
completionOutcome: 'SUCCESS' // How did experiment end?
}

Completion outcomes:

  • SUCCESS: Completed all states normally
  • DISCONNECT_TIMEOUT: Ended due to participant disconnect
  • TIMEOUT: Waiting room timeout
  • TECHNICAL: Technical failure
  • NO_CONSENT: Declined consent

Event Flow

Disconnect Event Flow

  1. Participant socket disconnects
  2. handleDisconnect called
  3. Check if pause conditions met (multi-person, enabled, started, not already paused)
  4. Clear state timers
  5. Set pause state in room
  6. Start disconnect timeout (60s)
  7. Emit experiment-paused to all participants
  8. Frontend shows pause overlay
  9. Either:
    • Participant rejoins → Resume flow
    • Timeout expires → Complete experiment

Rejoin Event Flow

  1. Participant navigates to experiment
  2. Frontend checks session status
  3. If status = 'active', sets isRejoin = true
  4. handleJoinExperiment called with existing participant
  5. Check if experiment paused for this participant
  6. Clear disconnect timeout
  7. Calculate remaining time on state timer
  8. Restart state timer with remaining time
  9. Emit experiment-resumed to all participants
  10. Send current state to rejoining participant
  11. Frontend hides pause overlay
  12. Experiment continues

Resume Event Flow

Timer Management

State Timer Resume Logic

When resuming, state timer is restarted with remaining time:

// Calculate elapsed time before pause
const elapsedBeforePause = room.pausedAt - room.timerDetails.startTime;

// Calculate remaining duration
const remainingDuration = room.timerDetails.duration - elapsedBeforePause;

// Only restart if time remaining
if (remainingDuration > 0) {
this.startStateTimer(roomId, room.currentState.id, remainingDuration);
}

Safety checks:

  • Verify timerDetails exists
  • Verify pausedAt exists
  • Verify startTime and duration exist
  • Check remainingDuration > 0

Timer Cleanup

All timers properly cleaned up:

TimerCleared OnLocation
disconnectTimeoutRefRejoin, endExperimentMultiple
timerRefPause, clearStateTimerMultiple
timerUpdateIntervalPause, clearStateTimerMultiple
Pause overlay intervalComponent destroyFrontend

Edge Cases Handled

1. Multiple Participants Disconnect

Behavior: Only the first disconnect triggers pause

  • Guard clause: !room.isPausedForDisconnect
  • Subsequent disconnects don't reset timer
  • Any participant can rejoin to resume

2. Participant Reconnects Then Disconnects Again

Behavior: New disconnect triggers new pause

  • First rejoin clears pause state
  • Second disconnect triggers new pause with fresh timeout

3. Timer Already Expired

Behavior: Don't restart timer, let state advance

if (remainingDuration > 0) {
// Restart
} else {
console.log('[Rejoin] Timer already expired, not restarting');
}

4. All Participants Disconnect

Behavior: Experiment completes after timeout

  • Last participant disconnect triggers normal timeout
  • No participants to resume
  • Experiment ends with DISCONNECT_TIMEOUT reason

5. Single-Participant Disconnect

Behavior: No pause, participant can rejoin anytime

  • Guard clause: room.expectedCount > 1
  • Single participant has unlimited time to rejoin
  • Returns to current state

Data Persistence

What's Preserved on Rejoin

Preserved:

  • Current state and state index
  • All shared variables (randomization maintained)
  • Participant role
  • Room configuration
  • Timer remaining time
  • Completed components

Not Preserved:

  • Device setup UI state (skipped on rejoin)
  • Socket connection state (new socket created)
  • Local component state (reinitialized)

Randomization Safety

Question: Does rejoin cause re-randomization?

Answer: No. The backend maintains room.sharedData which contains all randomized variables:

socket.emit('state-update', {
state: room.currentState,
stateIndex: room.stateIndex,
sharedData: room.sharedData // Same randomization as before
});

Rejoining participant receives the exact same shared data, ensuring:

  • Consistent experimental conditions
  • No duplicate randomization
  • Maintained counterbalancing

Performance Considerations

Frontend

  • Pause overlay updates: 100ms interval (acceptable for 60s duration)
  • Memory cleanup: Interval cleared in onDestroy
  • Session checks: Only on page load (not polling)

Backend

  • Timer overhead: One setTimeout per disconnect (minimal)
  • Room state: Small additional fields (~100 bytes)
  • Event emission: Only to participants in paused room

Network

  • Rejoin cost: Same as initial join (state + shared data)
  • Pause events: Sent once per disconnect (~200 bytes)
  • Resume events: Sent once per rejoin (~200 bytes)

Security Considerations

Participant ID Validation

All participant IDs normalized before use:

const normalizedParticipantId = normalizeParticipantId(participantId);

Prevents:

  • ID spoofing
  • Invalid participant references
  • Race conditions on rejoin

Authorization

Rejoining participants must:

  • Have valid Firebase authentication
  • Match existing participant in room
  • Have active session in database

Timer Safety

Timeouts properly cleaned:

  • Cleared on rejoin
  • Cleared on experiment completion
  • Set to null after clearing
  • No dangling references

Testing

Test Coverage

Frontend Tests: 21 new tests

  • Session service: Device setup tracking (2 tests)
  • Pause overlay: Full component coverage (19 tests)

Backend Tests: 15 new tests

  • Disconnect pause logic (5 tests)
  • Rejoin resume logic (4 tests)
  • Timeout expiry (1 test)
  • Completion with reason (3 tests)
  • Edge cases (2 tests)

Total: 36 new tests, all passing ✅

Key Test Scenarios

  1. ✅ Multi-person experiment pauses on disconnect
  2. ✅ Single-person experiment doesn't pause
  3. ✅ Disabled timeout doesn't pause
  4. ✅ State timer clears on pause
  5. ✅ Participant rejoin resumes experiment
  6. ✅ State timer resumes with remaining time
  7. ✅ Timeout expiry completes experiment
  8. ✅ Completion reason sent correctly
  9. ✅ Multiple disconnects handled
  10. ✅ Expired timer edge case

Troubleshooting

Participant Can't Rejoin

Check:

  1. Session status is 'active' in Firebase
  2. Session has roomId field populated
  3. Room still exists on backend
  4. Experiment hasn't completed

Debug:

// Frontend console
console.log('[experiment] Session:', session);
console.log('[experiment] isRejoin:', isRejoin);

// Backend logs
[Rejoin] Participant X rejoined, resuming experiment

Pause Overlay Not Showing

Check:

  1. disconnectTimeout.enabled === true in experiment config
  2. Experiment has multiple participants (expectedCount > 1)
  3. Experiment has started (room.isStarted === true)
  4. Not already paused (!room.isPausedForDisconnect)

Timer Not Resuming

Check:

  1. room.timerDetails exists
  2. room.pausedAt is set
  3. Remaining duration > 0
  4. State has a timer configured

Debug:

[Rejoin] Restarting state timer with Xms remaining
// OR
[Rejoin] Timer already expired, not restarting

Experiment Completes Immediately on Disconnect

Check:

  1. Timeout duration configuration: disconnectTimeout.durationMs
  2. Verify timeout is 60000 (60s), not 0 or very small

Future Enhancements

Potential Improvements

  1. Admin UI for Configuration

    • Checkbox to enable disconnect timeout
    • Slider for timeout duration
    • Preview of participant experience
  2. Configurable Actions

    • pause_indefinitely: Wait for manual intervention
    • skip_questionnaire: Complete without post-exp questionnaire
    • resume_from_state: Jump to specific state on timeout
  3. Analytics & Monitoring

    • Track rejoin frequency per experiment
    • Monitor disconnect patterns
    • Alert experimenters on timeout expiry
  4. Notifications

    • Optional toast when participant reconnects
    • Email/Slack notification on timeout expiry
    • Admin dashboard real-time status
  5. Grace Periods

    • Short grace period (5-10s) before showing pause overlay
    • Handles brief network hiccups without alarming participants

References

  • Implementation PR: [Link to PR]
  • Related Documentation:
  • Test Files:
    • frontend/src/lib/services/__tests__/participantSessionService.test.js
    • frontend/src/components/experiment/__tests__/ExperimentPauseOverlay.test.js
    • backend/src/experiment/__tests__/experimentStateServer.rejoin.test.js

Version History

  • v0.4.0 (2025-10-31): Initial implementation
    • Participant rejoin functionality
    • Disconnect timeout with 60s default
    • Pause overlay UI
    • Session tracking enhancements