Skip to content

Chore/pjsip ios15 rebuild - #6

Merged
PetrShtuka merged 4 commits into
mainfrom
chore/pjsip-ios15-rebuild
Aug 3, 2026
Merged

Chore/pjsip ios15 rebuild#6
PetrShtuka merged 4 commits into
mainfrom
chore/pjsip-ios15-rebuild

Conversation

@PetrShtuka

Copy link
Copy Markdown
Owner

No description provided.

PeterSthuka added 4 commits August 2, 2026 19:18
The XCFramework carried minos 16.0 while Package.swift, the podspec and the
build script all declare 15.0, so every consumer with a 15.x deployment target
linked it with one `built for newer 'iOS' version` warning per object file.
The floor had already been lowered in build-pjsip-xcframework.sh; the binary
had simply never been rebuilt since.

Rebuilt with MIN_IOS_VERSION=15.0. Every slice and every architecture now
reports minos 15.0 — ios-arm64/arm64, and both arm64 and x86_64 inside
ios-arm64_x86_64-simulator — with zero objects left at 16.x.

The rebuild changes nothing else: exported symbols are identical (2241 before
and after, none added, none removed), the headers are untouched, the
XCFramework's Info.plist is byte-identical, and the file inventory is the same
336 files per slice. A link probe against the device slice went from 240
warnings to none, and the package's own test suite still passes 42/42.

pod lib lint now passes without --allow-warnings, so the comments that
justified that flag by the version mismatch are corrected rather than removed:
the flag stays as a safety net, and the README section that told readers to
rebuild the binary to silence the warnings is replaced with a per-slice
verification snippet.
A VoIP push routinely beats the INVITE by a second or more — the logs show
"INVITE observed after 1840 ms" — and the two paths out of that window were
asymmetric. Answering polls for the call until answerTimeout; rejecting did
not. With no call id bound yet, -terminateCallWithUUID:declining:completion:
sent nothing to SIP, deleted the pending record and returned
CallWaveErrorNoActiveCall.

Nothing then told the peer anything. The INVITE arrived to a registry with no
pending call, on_incoming_call answered 180 Ringing before consulting any
state, and handleIncomingSIPCall: took the host-owned branch and registered a
second call under a fresh UUID. The intercom went on ringing until
incomingCallTimeout, and the host saw .incoming again after .ended.

The rejection is now remembered rather than discarded. A pending call that the
user rejects is marked cancelled and kept, the state goes to .ended and the
completion reports success — the user's intent did succeed, so an error there
was wrong. on_incoming_call asks for such a cancellation before it rings and
answers 603 Decline instead. This side is the callee, so it answers the INVITE
rather than sending CANCEL.

Two rules keep a stale cancellation from doing harm: it expires after
answerTimeout, the same budget an INVITE gets to arrive, and a call still
legitimately awaiting its INVITE always wins over a pending cancellation, so an
INVITE that may belong to a live call is never refused. Both checks run on the
PJSIP callback thread under the registry's own lock, like
-canAcceptAnotherIncomingCall — a 180 or a 603 cannot wait for a queue hop.

Nothing is reported to CallKit from this path: in host-owned mode the provider
belongs to the application, which ends the call from the state stream.

CallWaveCallRegistry sits behind a private header, so its tests are a small
Objective-C target that reaches it through a header search path — 14 cases
covering that a cancellation keeps the record, that a cancelled call is no
longer offered as awaiting an INVITE, that an expired cancellation is
equivalent to no record and is purged, and that a live pending call takes
precedence. No public API was widened for testing, and the public headers are
untouched.

The end-to-end behaviour needs a real intercom and is not covered here.
The unit tests cover parsing and property contracts; they cover none of the
answer path, the push path, the audio session or the registration lifecycle,
which is where the decline-before-INVITE bug lived and why nothing caught it.
FIELD-TESTING.md is the twelve-scenario pass that has to be run by hand on a
device against a real intercom, and RELEASING.md now names it as step 0 —
before the version bump, not after the tag.

Three claims in the checklist did not match the code and are corrected here:
the log categories are lower-case and are sip/call/audio/push/network/pjsip
with no "Engine"; the decline-before-INVITE case failed in 0.3.0 and earlier
rather than in 0.3.1, which is the release that fixes it; and scenario 4 now
names the log line that proves the fix ran.
Every log line, default and behavioural claim in the checklist was verified
against CallWaveKit/*.m and CallWaveKit/include/. The quoted message formats,
the defaults (acceptDelay 0.5 s, incomingCallTimeout 60 s, answerTimeout 10 s,
maximumCalls 1, redaction on), the 486/480 responses, the RFC 2833 to SIP INFO
fallback, the RTP-level mute, pjsua_handle_ip_change() and TLS verification all
matched and are left alone. So do the Swift spellings, including
CallWaveLog.redactsIdentifiers, which reads as isRedactingIdentifiers but is
still assignable under that name — confirmed by compiling it.

What did not match:

- the scenario markers used [Call], [Audio], [SIP] and [Push] while the
  categories the library registers are lower-case, contradicting the list in
  the same file. All markers are now lower-case, and the setup section says
  that the bracket stands for the category column rather than for something the
  library prints;
- scenario 10 sent the reader into the PJSIP trace at .debug to confirm a
  network handover, when there is an Info-level line for exactly that;
- scenario 3 described the DTMF fallback without naming the line that shows it.

Two shipped bugs had no scenario. Un-REGISTER reporting the account as still
registered — the 0.3.0 fix around PJSIP_EXPIRES_NOT_SPECIFIED — is now scenario
8, and ending one of two concurrent calls killing the wrong one is folded into
scenario 9 rather than given a number of its own.

Thirteen scenarios instead of twelve, still an evening: scenario 8 is a call to
unregister() and a state read, and the addition to 9 reuses the two callers that
scenario already needs. The remaining Fixed entries are thread races and a
deadlock, which have no honest by-hand reproduction, so no scenarios were
invented for them.
@PetrShtuka
PetrShtuka merged commit cd8837f into main Aug 3, 2026
3 checks passed
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