Skip to content

Match publisher answers to offers by offer id - #1262

Open
hiroshihorie wants to merge 2 commits into
mainfrom
hiroshi/offer-ids
Open

hiroshihorie wants to merge 2 commits into
mainfrom
hiroshi/offer-ids

Conversation

@hiroshihorie

@hiroshihorie hiroshihorie commented Oct 27, 2026 •

Copy link
Copy Markdown
Member

Follow-up to #1257, which added the signaling-state check this PR keeps as the fallback for servers that send no offer id.

The problem

The Flutter SDK sends offers and answers without SessionDescription.id, so the publisher transport can only use the signaling state to decide whether an answer is still wanted. That works for a duplicate that lands in stable (#1257), but not for an answer to an offer that was already replaced.

Example: the transport sends offer 2, and before its answer arrives an ICE restart sends offer 3. The answer to offer 2 then arrives in have-local-offer, the same state the answer to offer 3 will find. It gets applied to the restart offer, and the real answer to offer 3 is dropped as a duplicate.

The same thing happens to a duplicate of answer N that arrives while answer N is being applied. Answers are handled one at a time, and handling answer N also sends the deferred offer N+1. So the copy is only processed once offer N+1 is waiting, and it gets applied to offer N+1.

JS (livekit/client-sdk-js#1547, livekit/client-sdk-js#2114), Swift (livekit/client-sdk-swift#845) and Android (livekit/client-sdk-android#813) already match answers to offers by id. The server supports this since livekit/protocol#1105: it echoes the client's offer id on its answer and numbers its own offers.

The fix

  • Each publisher offer gets an id starting at 1. The id is sent with the offer and handed to onOffer.
  • An answer whose id is not the latest offer's is ignored as stale.
  • A numbered answer is a duplicate once its offer's answer has been applied. A failed apply records nothing, so a retry is still accepted.
  • The in-flight claim is now taken before any await, as JS does.
  • Answers with id 0, from servers that send no id, keep the signaling-state fallback from Ignore a duplicated server answer #1257.
  • createAndSendOffer holds a lock, like JS's offerLock. A call that overlaps another waits for it, finds that offer waiting for its answer and defers to renegotiate instead of sending a second offer.
  • SignalOfferEvent and SignalAnswerEvent carry the id, and subscriber answers echo the server's offer id.

Verification

  • flutter test: 475 pass. flutter analyze, dart format and import_sorter are clean.
  • Six new tests in transport_negotiate_test.dart. Each one fails when the check it covers is removed: the stale check, the in-flight claim, the answered id, recording before apply, the offer lock, and passing the id to onOffer.
  • e2e on macOS against a local livekit-server --dev, with two new scenarios from livekit/e2e-flutter#13 and livekit/e2e-flutter#14:
scenario main before #1257 #1257 this PR
duplicated publisher answer fail ("Called in wrong state: stable") pass pass
stale publisher answer after ICE restart fail (old answer applied) fail (old answer applied, real answer dropped) pass
  • The server log for those runs shows client offer ids 1 to 4 arriving and subscriber answers echoing the server's ids. There are no sdp state: answer id mismatch warnings.

@hiroshihorie
hiroshihorie added this pull request to stack #1263 October 27, 2026 08:56
@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Caution

Breaking change detected without major changeset

dart-apitool detected the following breaking changes:

Preparing git repository: https://github.com/livekit/client-sdk-flutter
Using ref: main
Cloning git repository: https://github.com/livekit/client-sdk-flutter
Checking out ref: main
Successfully cloned to: /tmp/BQVLAH
Preparing package dependencies for git package https://github.com/livekit/client-sdk-flutter (forced Flutter)
Resolving dependencies...
Downloading packages...
  _fe_analyzer_shared 100.0.0 (110.0.0 available)
  analyzer 13.0.0 (14.6.0 available)
  archive 4.3.0 (4.5.2 available)
  asn1lib 1.6.5 (1.6.7 available)
  build 4.0.7 (5.0.0 available)
  build_config 1.3.2 (1.4.0 available)
  build_daemon 4.1.5 (4.1.7 available)
  build_runner 2.15.1 (2.17.1 available)
  built_collection 5.1.1 (6.0.0 available)
  built_value 8.12.7 (8.13.0 available)
  clock 1.1.2 (1.1.3 available)
  code_assets 1.2.1 (2.1.0 available)
  code_builder 4.11.1 (4.13.0 available)
  connectivity_plus 7.3.1 (7.3.2 available)
  crypto 3.0.7 (3.0.8 available)
  dart_style 3.1.9 (3.1.14 available)
  dart_webrtc 1.8.1 (1.8.2 available)
  dbus 0.7.14 (0.8.0 available)
  device_info_plus 13.2.0 (13.3.0 available)
  glob 2.1.3 (2.2.0 available)
  hooks 2.0.2 (2.2.0 available)
  http_multi_server 3.2.2 (3.2.3 available)
  io 1.0.5 (1.1.0 available)
  jni 1.0.3 (1.1.0 available)
  jni_flutter 1.0.2 (1.0.4+1 available)
  json_serializable 6.14.1 (6.15.2 available)
  logger 2.7.0 (2.8.0 available)
  material_color_utilities 0.13.0 (0.13.1 available)
  mime 2.0.0 (2.1.0 available)
  mockito 5.7.0 (5.8.1 available)
  nm 0.5.0 (0.6.0 available)
  objective_c 9.5.0 (9.6.2 available)
  package_config 2.2.0 (3.0.0 available)
  petitparser 7.0.2 (7.1.0 available)
  platform 3.1.6 (3.2.0 available)
  pool 1.5.2 (1.5.3 available)
  posix 6.5.2 (6.5.3 available)
  protobuf 6.0.0 (6.1.0 available)
  pub_semver 2.2.0 (2.2.1 available)
  pubspec_parse 1.5.0 (1.7.0 available)
  record_use 0.6.0 (1.1.1 available)
  sdp_transform 0.3.2 (0.4.0 available)
  shelf 1.4.2 (1.4.3 available)
  source_gen 4.2.4 (4.3.1 available)
  source_helper 1.3.13 (1.4.0 available)
  stack_trace 1.12.1 (1.12.2 available)
  stream_transform 2.1.1 (2.1.2 available)
  synchronized 3.4.1+1 (3.5.0 available)
  test_api 0.7.12 (0.7.15 available)
  vector_math 2.4.2 (2.4.4 available)
  vm_service 15.2.0 (15.4.0 available)
  web 1.1.1 (1.1.2 available)
  xml 6.6.1 (7.1.0 available)
  yaml 3.1.3 (3.1.4 available)
Got dependencies!
54 packages have newer versions incompatible with dependency constraints.
Try `flutter pub outdated` for more information.
Resolving dependencies in `./example`...
Downloading packages...
Got dependencies in `./example`.
Preparing . (forced Flutter)
Copying sources from .
Preparing package dependencies for local package . (forced Flutter)
Resolving dependencies...
Downloading packages...
  _fe_analyzer_shared 100.0.0 (110.0.0 available)
  analyzer 13.0.0 (14.6.0 available)
  archive 4.3.0 (4.5.2 available)
  asn1lib 1.6.5 (1.6.7 available)
  build 4.0.7 (5.0.0 available)
  build_config 1.3.2 (1.4.0 available)
  build_daemon 4.1.5 (4.1.7 available)
  build_runner 2.15.1 (2.17.1 available)
  built_collection 5.1.1 (6.0.0 available)
  built_value 8.12.7 (8.13.0 available)
  clock 1.1.2 (1.1.3 available)
  code_assets 1.2.1 (2.1.0 available)
  code_builder 4.11.1 (4.13.0 available)
  connectivity_plus 7.3.1 (7.3.2 available)
  crypto 3.0.7 (3.0.8 available)
  dart_style 3.1.9 (3.1.14 available)
  dart_webrtc 1.8.1 (1.8.2 available)
  dbus 0.7.14 (0.8.0 available)
  device_info_plus 13.2.0 (13.3.0 available)
  glob 2.1.3 (2.2.0 available)
  hooks 2.0.2 (2.2.0 available)
  http_multi_server 3.2.2 (3.2.3 available)
  io 1.0.5 (1.1.0 available)
  jni 1.0.3 (1.1.0 available)
  jni_flutter 1.0.2 (1.0.4+1 available)
  json_serializable 6.14.1 (6.15.2 available)
  logger 2.7.0 (2.8.0 available)
  material_color_utilities 0.13.0 (0.13.1 available)
  mime 2.0.0 (2.1.0 available)
  mockito 5.7.0 (5.8.1 available)
  nm 0.5.0 (0.6.0 available)
  objective_c 9.5.0 (9.6.2 available)
  package_config 2.2.0 (3.0.0 available)
  petitparser 7.0.2 (7.1.0 available)
  platform 3.1.6 (3.2.0 available)
  pool 1.5.2 (1.5.3 available)
  posix 6.5.2 (6.5.3 available)
  protobuf 6.0.0 (6.1.0 available)
  pub_semver 2.2.0 (2.2.1 available)
  pubspec_parse 1.5.0 (1.7.0 available)
  record_use 0.6.0 (1.1.1 available)
  sdp_transform 0.3.2 (0.4.0 available)
  shelf 1.4.2 (1.4.3 available)
  source_gen 4.2.4 (4.3.1 available)
  source_helper 1.3.13 (1.4.0 available)
  stack_trace 1.12.1 (1.12.2 available)
  stream_transform 2.1.1 (2.1.2 available)
  synchronized 3.4.1+1 (3.5.0 available)
  test_api 0.7.12 (0.7.15 available)
  vector_math 2.4.2 (2.4.4 available)
  vm_service 15.2.0 (15.4.0 available)
  web 1.1.1 (1.1.2 available)
  xml 6.6.1 (7.1.0 available)
  yaml 3.1.3 (3.1.4 available)
Got dependencies!
54 packages have newer versions incompatible with dependency constraints.
Try `flutter pub outdated` for more information.
Resolving dependencies in `./example`...
Downloading packages...
Got dependencies in `./example`.
Omitting pub get (package config already present)
Analyzing /tmp/BQVLAH
Omitting pub get (package config already present)
Analyzing .
Cleaning up
Cleaning up
Checking Package version
ignoring prerelease
-- Generating report using: Console Reporter --
BREAKING CHANGES
└─┬ Class Transport
  └─┬ Field onOffer
    └── Type of field changed. void Function(RTCSessionDescription)? -> void Function(RTCSessionDescription, int)? (CF04)
Non-Breaking changes
└─┬ Class Transport
  └─┬ Method setRemoteDescription
    └── Parameter "offerId" added (CE02) (minor)
To learn more about the detected changes visit: https://github.com/bmw-tech/dart_apitool/blob/main/readme/change_codes.md
Version Check
New Version is too low!

Old version: "2.13.1"
New version: "2.13.1"
Needed version: "3.0.0"

Got "2.13.1" expected >= "3.0.0" (breaking changes)

If this is intentional, please add a changeset with major level in .changes/:

major type="changed" "Description of breaking change"

Base automatically changed from port/js-2114-duplicate-answer to main October 27, 2026 09:27
Offers and answers were sent without SessionDescription.id, so the
transport could only tell whether an answer was still wanted from the
signaling state. An answer to an offer that an ICE restart or a newer
offer replaced arrives in have-local-offer like the one that is wanted,
so it was applied to the wrong offer and the real answer was then
dropped as a duplicate.

Each offer now gets an id starting at 1, sent with the offer. The server
echoes it on its answer, and an answer whose id is not the latest
offer's is ignored. A numbered answer is a duplicate once its offer's
answer has been applied, and the in-flight claim is taken before any
await. Answers from servers that send no id keep the signaling state
fallback from the duplicate answer fix. An offer superseded while it
was being built is not sent. Subscriber answers echo the server's offer
id, as the JS, Swift and Android SDKs do.
@hiroshihorie
hiroshihorie marked this pull request as ready for review October 27, 2026 09:31
devin-ai-integration[bot]

This comment was marked as resolved.

createAndSendOffer checks for a waiting offer across an await, so two
overlapping calls could both pass the check and send two offers. The
offer ids then judged the answer to the first one stale, and if the
second call failed after taking its id, nothing the server answered
matched the latest id until a reconnect.

A lock around createAndSendOffer, as the JS SDK has, makes the second
call wait. It then finds the first offer waiting for its answer and
defers to renegotiate like any other overlapping change. The check that
dropped a superseded offer is no longer needed.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant