Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ bump may contain breaking changes, and each one is listed below.
`-handleVoIPPushPayload:completion:` used to add a second nudge on top of the
one `-prepareIncomingCallWithUUID:caller:` already performs, sending two
`REGISTER`s per call.
- Starting the engine no longer aborts with PJLIB's "Calling pjlib from
unknown/external thread" when something else in the process has already
initialized PJLIB. `pjsua_create()` registers its caller only when it is the
one bringing PJLIB up, and a push-driven start runs on a transient SIP queue
worker; the stack is now initialized and the thread registered before
`pjsua_create()`, and the teardown balances that initialization.

## [0.7.1] - 2026-09-08

Expand Down
29 changes: 27 additions & 2 deletions CallWaveKit/CallWaveClient.m
Original file line number Diff line number Diff line change
Expand Up @@ -1064,12 +1064,31 @@ - (pj_status_t)startEngineLocked {
CallWaveEngineConfiguration *engine = self.engineConfiguration;

if (!gPJSUACreated) {
pj_status_t status = pjsua_create();
// pjsua_create() starts with pj_init(), but pj_init() registers the
// calling thread only when it is the one that brings PJLIB up. A host
// that already runs another PJSIP-based stack — or that called
// pj_init() itself — leaves this thread, a transient sipQueue worker,
// unknown to PJLIB, and pjsua_create() aborts on the first per-thread
// call it makes. Initializing PJLIB first makes the registration below
// legal; pjsua_destroy() undoes only its own pj_init(), so the
// teardown balances this one.
pj_status_t status = pj_init();
if (status != PJ_SUCCESS) {
return status;
}
gPJSUACreated = PJ_TRUE;
gPJInitialized = PJ_TRUE;
if (!ensurePJThreadRegistered("CallWaveCreate")) {
pj_shutdown();
gPJInitialized = PJ_FALSE;
return PJ_EUNKNOWN;
}
status = pjsua_create();
if (status != PJ_SUCCESS) {
pj_shutdown();
gPJInitialized = PJ_FALSE;
return status;
}
gPJSUACreated = PJ_TRUE;
}

if (!ensurePJThreadRegistered("CallWaveConfig")) {
Expand Down Expand Up @@ -1495,6 +1514,12 @@ + (void)destroyRuntimeOnQueue:(dispatch_queue_t)queue {
if (gPJSUACreated) {
pjsua_destroy();
}
if (gPJInitialized) {
// Balances the pj_init() in -startEngineLocked. pjsua_destroy()
// releases only the reference pjsua_create() took, and PJLIB stays
// up for whoever else in the process initialized it.
pj_shutdown();
}
gPJSUAStarted = PJ_FALSE;
gPJSUACreated = PJ_FALSE;
gPJInitialized = PJ_FALSE;
Expand Down
49 changes: 49 additions & 0 deletions Tests/CallWaveKitRegistryTests/CallWavePushWakeRegistrationTests.m
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@
#import "CallWaveClient.h"
#import "CallWaveLogInternal.h"

#if __has_include(<PJSIP/pjsua.h>)
#import <PJSIP/pjsua.h>
#else
#import <pjsua.h>
#endif

// A push for a host that only learns its credentials from that push used to
// run the full -startWithError: path, which has no configuration to register
// and therefore failed with CallWaveErrorNotConfigured on every single call.
Expand Down Expand Up @@ -123,4 +129,47 @@ - (void)testHostOwnedPushNudgesRegistrationOnce {
XCTAssertEqual(client.wakeCount, 1u);
}

- (CallWaveConfiguration *)configuration {
return [[CallWaveConfiguration alloc] initWithHost:@"127.0.0.1"
port:5099
transport:CallWaveTransportUDP
username:@"test"
password:@"test"
includesCallsInRecents:NO];
}

- (void)testPushWithCredentialsStartsTheStack {
CallWaveClient *client = [[CallWaveClient alloc] initWithConfiguration:[self configuration]
options:CallWaveIntegrationOptionNone
provider:nil
engineConfiguration:[self engine]];

[client prepareIncomingCallWithUUID:[NSUUID UUID] caller:@"1001"];
[client performSIPSync:^{}];

XCTAssertTrue(client.isRunning);
[client stop];
}

- (void)testEngineStartsWhenSomethingElseAlreadyInitializedPJLIB {
// A second PJSIP consumer in the process — the host itself, or another
// library — leaves PJLIB initialized. pjsua_create() then no longer
// registers the thread it runs on, which for a push-driven start is a
// transient sipQueue worker: every PJLIB call on it used to abort.
XCTAssertEqual(pj_init(), PJ_SUCCESS);

CallWaveClient *client = [[CallWaveClient alloc] initWithConfiguration:[self configuration]
options:CallWaveIntegrationOptionNone
provider:nil
engineConfiguration:[self engine]];

[client prepareIncomingCallWithUUID:[NSUUID UUID] caller:@"1001"];
[client performSIPSync:^{}];

XCTAssertTrue(client.isRunning);
[client stop];
// The client's own pj_init()/pj_shutdown() pair leaves this one standing.
pj_shutdown();
}

@end