8 Commits

Author SHA1 Message Date
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 47 additions and 18 deletions

20
package-lock.json generated
View File

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

View File

@@ -1,6 +1,6 @@
{ {
"name": "@xtr-dev/rondevu-client", "name": "@xtr-dev/rondevu-client",
"version": "0.18.2", "version": "0.18.5",
"description": "TypeScript client for Rondevu with durable WebRTC connections, automatic reconnection, and message queuing", "description": "TypeScript client for Rondevu with durable WebRTC connections, automatic reconnection, and message queuing",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
@@ -25,7 +25,6 @@
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
"@types/node": "^25.0.2",
"@typescript-eslint/eslint-plugin": "^8.48.1", "@typescript-eslint/eslint-plugin": "^8.48.1",
"@typescript-eslint/parser": "^8.48.1", "@typescript-eslint/parser": "^8.48.1",
"eslint": "^9.39.1", "eslint": "^9.39.1",
@@ -42,6 +41,7 @@
"README.md" "README.md"
], ],
"dependencies": { "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 { RondevuAPI, Keypair, IceCandidate, BatcherOptions } from './api.js'
import { CryptoAdapter } from './crypto-adapter.js' import { CryptoAdapter } from './crypto-adapter.js'
import { EventEmitter } from 'events' import { EventEmitter } from 'eventemitter3'
// ICE server preset names // ICE server preset names
export type IceServerPreset = 'ipv4-turn' | 'hostname-turns' | 'google-stun' | 'relay-only' export type IceServerPreset = 'ipv4-turn' | 'hostname-turns' | 'google-stun' | 'relay-only'
@@ -259,6 +259,7 @@ export class Rondevu extends EventEmitter {
private filling = false private filling = false
private pollingInterval: ReturnType<typeof setInterval> | null = null private pollingInterval: ReturnType<typeof setInterval> | null = null
private lastPollTimestamp = 0 private lastPollTimestamp = 0
private isPolling = false // Guard against concurrent poll execution
private constructor( private constructor(
apiUrl: string, apiUrl: string,
@@ -634,6 +635,13 @@ export class Rondevu extends EventEmitter {
private async pollInternal(): Promise<void> { private async pollInternal(): Promise<void> {
if (!this.filling) return 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 { try {
const result = await this.api.poll(this.lastPollTimestamp) const result = await this.api.poll(this.lastPollTimestamp)
@@ -643,17 +651,25 @@ export class Rondevu extends EventEmitter {
if (activeOffer && !activeOffer.answered) { if (activeOffer && !activeOffer.answered) {
this.debug(`Received answer for offer ${answer.offerId}`) this.debug(`Received answer for offer ${answer.offerId}`)
await activeOffer.pc.setRemoteDescription({ // Mark as answered BEFORE setRemoteDescription to prevent race condition
type: 'answer',
sdp: answer.sdp
})
activeOffer.answered = true activeOffer.answered = true
this.lastPollTimestamp = answer.answeredAt
this.emit('offer:answered', answer.offerId, answer.answererId)
// Create replacement offer try {
this.fillOffers() await activeOffer.pc.setRemoteDescription({
type: 'answer',
sdp: answer.sdp
})
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
throw err
}
} }
} }
@@ -674,6 +690,8 @@ export class Rondevu extends EventEmitter {
} }
} catch (err) { } catch (err) {
console.error('[Rondevu] Polling error:', err) console.error('[Rondevu] Polling error:', err)
} finally {
this.isPolling = false
} }
} }
@@ -710,6 +728,7 @@ export class Rondevu extends EventEmitter {
stopFilling(): void { stopFilling(): void {
this.debug('Stopping offer filling and polling') this.debug('Stopping offer filling and polling')
this.filling = false this.filling = false
this.isPolling = false // Reset polling guard
// Stop polling // Stop polling
if (this.pollingInterval) { if (this.pollingInterval) {