10 Commits

Author SHA1 Message Date
50d49d80d3 v0.18.6 - Fix duplicate answer bug (timestamp update) 2025-12-14 14:29:06 +01:00
b3b1751f63 Fix duplicate answer bug - update timestamp before processing
Root cause: When setRemoteDescription failed, lastPollTimestamp was
never updated, causing the server to return the same answer repeatedly.

Solution:
1. Update lastPollTimestamp BEFORE processing answers
2. Calculate max timestamp from all received answers upfront
3. Don't throw on setRemoteDescription errors - just log and continue
4. This ensures we advance the timestamp even if processing fails

This prevents the infinite loop of:
- Poll returns answer
- Processing fails
- Timestamp not updated
- Next poll returns same answer
- Repeat

Now the timestamp advances regardless of processing success,
preventing duplicate answer fetches from the server.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-14 14:28:57 +01:00
e48b3bb17a v0.18.5 - Fix duplicate answer processing regression 2025-12-14 14:19:44 +01:00
8f7e15e633 Fix duplicate answer processing regression
The activeOffer.answered flag was being set AFTER the async
setRemoteDescription() call, creating a race condition window
where the same answer could be processed multiple times.

Root cause:
- Check `!activeOffer.answered` happens
- setRemoteDescription() starts (async operation)
- Before it completes, another check could happen
- Same answer gets processed twice → "stable" state error

Fix:
- Set activeOffer.answered = true BEFORE setRemoteDescription
- Add try/catch to reset flag if setRemoteDescription fails
- This prevents duplicate processing while allowing retries on error

This regression was introduced when the answered flag assignment
was not moved along with other polling logic changes.

Fixes: #6 regression

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-14 14:19:34 +01:00
fcd0f8ead0 0.18.4 2025-12-14 14:06:57 +01:00
8fd4b249de Fix EventEmitter for cross-platform compatibility (v0.18.3)
Replace Node.js 'events' module with 'eventemitter3' package
to ensure compatibility in both browser and Node.js environments.

Changes:
- Replace import from 'events' to 'eventemitter3'
- Add eventemitter3 as dependency
- Remove @types/node (no longer needed)

Fixes browser bundling error where 'events' module was not available.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-14 14:05:36 +01:00
275c156c64 0.18.3 2025-12-14 13:35:45 +01:00
c60a5f332a Merge remote-tracking branch 'origin/main' 2025-12-14 13:35:38 +01:00
Bas
ecd6be7f8a Merge pull request #7 from xtr-dev/claude/fix-issue-6-CTKj9
Fix issue #6
2025-12-14 13:35:19 +01:00
Claude
e652fdc130 Fix duplicate answer processing race condition (#6)
Add polling guard to prevent concurrent pollInternal() execution.
The setInterval callback doesn't await the async pollInternal(),
which could cause multiple polls to process the same answer before
lastPollTimestamp is updated, resulting in "Called in wrong state:
stable" errors from setRemoteDescription().
2025-12-14 11:59:43 +00:00
3 changed files with 53 additions and 18 deletions

20
package-lock.json generated
View File

@@ -1,19 +1,19 @@
{
"name": "@xtr-dev/rondevu-client",
"version": "0.18.2",
"version": "0.18.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@xtr-dev/rondevu-client",
"version": "0.18.2",
"version": "0.18.6",
"license": "MIT",
"dependencies": {
"@noble/ed25519": "^3.0.0"
"@noble/ed25519": "^3.0.0",
"eventemitter3": "^5.0.1"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^25.0.2",
"@typescript-eslint/eslint-plugin": "^8.48.1",
"@typescript-eslint/parser": "^8.48.1",
"eslint": "^9.39.1",
@@ -1082,6 +1082,8 @@
"integrity": "sha512-gWEkeiyYE4vqjON/+Obqcoeffmk0NF15WSBwSs7zwVA2bAbTaE0SJ7P0WNGoJn8uE7fiaV5a7dKYIJriEqOrmA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -2001,6 +2003,12 @@
"node": ">=0.10.0"
}
},
"node_modules/eventemitter3": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -2844,7 +2852,9 @@
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"dev": true,
"license": "MIT"
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/update-browserslist-db": {
"version": "1.2.2",

View File

@@ -1,6 +1,6 @@
{
"name": "@xtr-dev/rondevu-client",
"version": "0.18.2",
"version": "0.18.6",
"description": "TypeScript client for Rondevu with durable WebRTC connections, automatic reconnection, and message queuing",
"type": "module",
"main": "dist/index.js",
@@ -25,7 +25,6 @@
"license": "MIT",
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^25.0.2",
"@typescript-eslint/eslint-plugin": "^8.48.1",
"@typescript-eslint/parser": "^8.48.1",
"eslint": "^9.39.1",
@@ -42,6 +41,7 @@
"README.md"
],
"dependencies": {
"@noble/ed25519": "^3.0.0"
"@noble/ed25519": "^3.0.0",
"eventemitter3": "^5.0.1"
}
}

View File

@@ -1,6 +1,6 @@
import { RondevuAPI, Keypair, IceCandidate, BatcherOptions } from './api.js'
import { CryptoAdapter } from './crypto-adapter.js'
import { EventEmitter } from 'events'
import { EventEmitter } from 'eventemitter3'
// ICE server preset names
export type IceServerPreset = 'ipv4-turn' | 'hostname-turns' | 'google-stun' | 'relay-only'
@@ -259,6 +259,7 @@ export class Rondevu extends EventEmitter {
private filling = false
private pollingInterval: ReturnType<typeof setInterval> | null = null
private lastPollTimestamp = 0
private isPolling = false // Guard against concurrent poll execution
private constructor(
apiUrl: string,
@@ -634,26 +635,47 @@ export class Rondevu extends EventEmitter {
private async pollInternal(): Promise<void> {
if (!this.filling) return
// Prevent concurrent poll execution to avoid duplicate answer processing
if (this.isPolling) {
this.debug('Poll already in progress, skipping')
return
}
this.isPolling = true
try {
const result = await this.api.poll(this.lastPollTimestamp)
// Update timestamp FIRST to prevent re-fetching same answers if processing fails
if (result.answers.length > 0) {
const maxAnswerTimestamp = Math.max(...result.answers.map(a => a.answeredAt))
this.lastPollTimestamp = Math.max(this.lastPollTimestamp, maxAnswerTimestamp)
}
// Process answers
for (const answer of result.answers) {
const activeOffer = this.activeOffers.get(answer.offerId)
if (activeOffer && !activeOffer.answered) {
this.debug(`Received answer for offer ${answer.offerId}`)
// Mark as answered BEFORE setRemoteDescription to prevent race condition
activeOffer.answered = true
try {
await activeOffer.pc.setRemoteDescription({
type: 'answer',
sdp: answer.sdp
})
activeOffer.answered = true
this.lastPollTimestamp = answer.answeredAt
this.emit('offer:answered', answer.offerId, answer.answererId)
// Create replacement offer
this.fillOffers()
} catch (err) {
// If setRemoteDescription fails, reset the answered flag
activeOffer.answered = false
this.debug(`Failed to set remote description for offer ${answer.offerId}:`, err)
// Don't throw - continue processing other answers
}
}
}
@@ -674,6 +696,8 @@ export class Rondevu extends EventEmitter {
}
} catch (err) {
console.error('[Rondevu] Polling error:', err)
} finally {
this.isPolling = false
}
}
@@ -710,6 +734,7 @@ export class Rondevu extends EventEmitter {
stopFilling(): void {
this.debug('Stopping offer filling and polling')
this.filling = false
this.isPolling = false // Reset polling guard
// Stop polling
if (this.pollingInterval) {