diff --git a/java/shared/java/org/signal/libsignal/internal/NativeTesting.java b/java/shared/java/org/signal/libsignal/internal/NativeTesting.java index 56b1b1328..b1b8d7167 100644 --- a/java/shared/java/org/signal/libsignal/internal/NativeTesting.java +++ b/java/shared/java/org/signal/libsignal/internal/NativeTesting.java @@ -63,6 +63,7 @@ private NativeTesting() {} public static native Object TESTING_ChatServiceResponseAndDebugInfoConvert() throws Exception; public static native Object TESTING_ChatServiceResponseConvert(boolean bodyPresent) throws Exception; public static native void TESTING_ChatService_InjectConnectionInterrupted(long chat); + public static native void TESTING_ChatService_InjectIntentionalDisconnect(long chat); public static native void TESTING_ChatService_InjectRawServerRequest(long chat, byte[] bytes); public static native void TESTING_ErrorOnBorrowAsync(Object input); public static native CompletableFuture TESTING_ErrorOnBorrowIo(long asyncRuntime, Object input); diff --git a/node/Native.d.ts b/node/Native.d.ts index 87aa6427b..2ab3ea2cd 100644 --- a/node/Native.d.ts +++ b/node/Native.d.ts @@ -3,6 +3,8 @@ // SPDX-License-Identifier: AGPL-3.0-only // +import { LibSignalError } from './ts/Errors'; + // WARNING: this file was automatically generated type Uuid = Buffer; @@ -120,7 +122,7 @@ export abstract class ChatListener { ack: ServerMessageAck ): void; _queue_empty(): void; - _connection_interrupted(): void; + _connection_interrupted(reason: LibSignalError | null): void; } export abstract class MakeChatListener extends ChatListener {} @@ -498,6 +500,7 @@ export function TESTING_ChatServiceErrorConvert(errorDescription: string): void; export function TESTING_ChatServiceResponseAndDebugInfoConvert(): ResponseAndDebugInfo; export function TESTING_ChatServiceResponseConvert(bodyPresent: boolean): ChatResponse; export function TESTING_ChatService_InjectConnectionInterrupted(chat: Wrapper): void; +export function TESTING_ChatService_InjectIntentionalDisconnect(chat: Wrapper): void; export function TESTING_ChatService_InjectRawServerRequest(chat: Wrapper, bytes: Buffer): void; export function TESTING_ErrorOnBorrowAsync(_input: null): Promise; export function TESTING_ErrorOnBorrowIo(asyncRuntime: Wrapper, _input: null): Promise; diff --git a/node/ts/net.ts b/node/ts/net.ts index e23bf6859..3a6a77e6f 100644 --- a/node/ts/net.ts +++ b/node/ts/net.ts @@ -126,10 +126,11 @@ export interface ConnectionEventsListener { /** * Called when the client gets disconnected from the server. * - * This includes both deliberate disconnects as well as unexpected socket closures that will be - * automatically retried. + * This includes both deliberate disconnects as well as unexpected socket + * closures. If the closure was not due to a deliberate disconnect, the error + * will be provided. */ - onConnectionInterrupted(): void; + onConnectionInterrupted(cause: LibSignalError | null): void; } export interface ChatServiceListener extends ConnectionEventsListener { @@ -251,8 +252,8 @@ export class AuthenticatedChatService implements ChatService { _queue_empty(): void { listener.onQueueEmpty(); }, - _connection_interrupted(): void { - listener.onConnectionInterrupted(); + _connection_interrupted(cause: LibSignalError | null): void { + listener.onConnectionInterrupted(cause); }, }; Native.ChatService_SetListenerAuth( @@ -331,8 +332,8 @@ export class UnauthenticatedChatService implements ChatService { _queue_empty(): void { throw new Error('Event not supported on unauthenticated connection'); }, - _connection_interrupted(): void { - listener.onConnectionInterrupted(); + _connection_interrupted(cause: LibSignalError | null): void { + listener.onConnectionInterrupted(cause); }, }; Native.ChatService_SetListenerUnauth( diff --git a/node/ts/test/NetTest.ts b/node/ts/test/NetTest.ts index 6275c3198..ec081edcb 100644 --- a/node/ts/test/NetTest.ts +++ b/node/ts/test/NetTest.ts @@ -23,6 +23,7 @@ import { import { randomBytes } from 'crypto'; import { ChatResponse } from '../../Native'; import { CompletablePromise } from './util'; +import { fail } from 'assert'; use(chaiAsPromised); use(sinonChai); @@ -290,15 +291,23 @@ describe('chat service api', () => { INVALID_MESSAGE, INCOMING_MESSAGE_2, ]; - const callsReceived: string[] = []; - const callsExpected: string[] = [ - '_incoming_message', - '_queue_empty', - '_incoming_message', - '_connection_interrupted', + const callsReceived: [string, (object | null)[]][] = []; + const callsExpected: [string, ((value: object | null) => void)[]][] = [ + ['_incoming_message', []], + ['_queue_empty', []], + ['_incoming_message', []], + [ + '_connection_interrupted', + [ + (error: object | null) => + expect(error) + .instanceOf(LibSignalErrorBase) + .property('code', ErrorCode.IoError), + ], + ], ]; - const recordCall = function (name: string) { - callsReceived.push(name); + const recordCall = function (name: string, ...args: (object | null)[]) { + callsReceived.push([name, args]); if (callsReceived.length == callsExpected.length) { completable.complete(); } @@ -314,8 +323,8 @@ describe('chat service api', () => { onQueueEmpty(): void { recordCall('_queue_empty'); }, - onConnectionInterrupted(): void { - recordCall('_connection_interrupted'); + onConnectionInterrupted(cause: object | null): void { + recordCall('_connection_interrupted', cause); }, }; const chat = net.newAuthenticatedChatService('', '', false, listener); @@ -327,7 +336,43 @@ describe('chat service api', () => { ); Native.TESTING_ChatService_InjectConnectionInterrupted(chat.chatService); await completable.done(); - expect(callsReceived).to.eql(callsExpected); + + expect(callsReceived).to.have.lengthOf(callsExpected.length); + callsReceived.forEach((element, index) => { + const [call, args] = element; + const [expectedCall, expectedArgs] = callsExpected[index]; + expect(call).to.eql(expectedCall); + expect(args.length).to.eql(expectedArgs.length); + args.map((arg, i) => { + expectedArgs[i](arg); + }); + }); + }); + + it('listener gets null cause for intentional disconnect', async () => { + const net = new Net(Environment.Staging, userAgent); + const completable = new CompletablePromise(); + const connectionInterruptedReasons: (object | null)[] = []; + const listener: ChatServiceListener = { + onIncomingMessage( + _envelope: Buffer, + _timestamp: number, + _ack: ChatServerMessageAck + ): void { + fail('unexpected call'); + }, + onQueueEmpty(): void { + fail('unexpected call'); + }, + onConnectionInterrupted(cause: object | null): void { + connectionInterruptedReasons.push(cause); + completable.complete(); + }, + }; + const chat = net.newAuthenticatedChatService('', '', false, listener); + Native.TESTING_ChatService_InjectIntentionalDisconnect(chat.chatService); + await completable.done(); + expect(connectionInterruptedReasons).to.eql([null]); }); it('client can respond with http status code to a server message', () => { diff --git a/rust/bridge/node/bin/Native.d.ts.in b/rust/bridge/node/bin/Native.d.ts.in index 7ded7322d..bdfa680d0 100644 --- a/rust/bridge/node/bin/Native.d.ts.in +++ b/rust/bridge/node/bin/Native.d.ts.in @@ -3,6 +3,8 @@ // SPDX-License-Identifier: AGPL-3.0-only // +import { LibSignalError } from './ts/Errors'; + // WARNING: this file was automatically generated type Uuid = Buffer; @@ -120,7 +122,7 @@ export abstract class ChatListener { ack: ServerMessageAck ): void; _queue_empty(): void; - _connection_interrupted(): void; + _connection_interrupted(reason: LibSignalError | null): void; } export abstract class MakeChatListener extends ChatListener {} diff --git a/rust/bridge/shared/testing/src/net.rs b/rust/bridge/shared/testing/src/net.rs index 400e403b4..67571c46a 100644 --- a/rust/bridge/shared/testing/src/net.rs +++ b/rust/bridge/shared/testing/src/net.rs @@ -151,6 +151,7 @@ make_error_testing_enum! { AllConnectionRoutesFailed => AllConnectionRoutesFailed, ServiceInactive => ServiceInactive, ServiceUnavailable => ServiceUnavailable, + ServiceIntentionallyDisconnected => ServiceIntentionallyDisconnected, } } @@ -185,6 +186,9 @@ fn TESTING_ChatServiceErrorConvert( } TestingChatServiceError::ServiceInactive => ChatServiceError::ServiceInactive, TestingChatServiceError::ServiceUnavailable => ChatServiceError::ServiceUnavailable, + TestingChatServiceError::ServiceIntentionallyDisconnected => { + ChatServiceError::ServiceIntentionallyDisconnected + } }) } @@ -278,6 +282,15 @@ fn TESTING_ChatService_InjectConnectionInterrupted(chat: &Chat) { .expect("not closed"); } +#[bridge_fn] +fn TESTING_ChatService_InjectIntentionalDisconnect(chat: &Chat) { + chat.synthetic_request_tx + .blocking_send(chat::ws::ServerEvent::Stopped( + ChatServiceError::ServiceIntentionallyDisconnected, + )) + .expect("not closed"); +} + #[bridge_fn(jni = false, ffi = false)] fn TESTING_ServerMessageAck_Create() -> ServerMessageAck { ServerMessageAck::new(Box::new(|_| Box::pin(std::future::ready(Ok(()))))) diff --git a/rust/bridge/shared/types/src/ffi/error.rs b/rust/bridge/shared/types/src/ffi/error.rs index d09ece6d9..2927f88ad 100644 --- a/rust/bridge/shared/types/src/ffi/error.rs +++ b/rust/bridge/shared/types/src/ffi/error.rs @@ -75,34 +75,35 @@ pub enum SignalErrorCode { UsernameLinkInvalidEntropyDataLength = 127, UsernameLinkInvalid = 128, - UsernameDiscriminatorCannotBeEmpty = 140, - UsernameDiscriminatorCannotBeZero = 141, - UsernameDiscriminatorCannotBeSingleDigit = 142, - UsernameDiscriminatorCannotHaveLeadingZeros = 143, - UsernameDiscriminatorTooLarge = 144, + UsernameDiscriminatorCannotBeEmpty = 130, + UsernameDiscriminatorCannotBeZero = 131, + UsernameDiscriminatorCannotBeSingleDigit = 132, + UsernameDiscriminatorCannotHaveLeadingZeros = 133, + UsernameDiscriminatorTooLarge = 134, - IoError = 130, + IoError = 140, #[allow(dead_code)] - InvalidMediaInput = 131, + InvalidMediaInput = 141, #[allow(dead_code)] - UnsupportedMediaInput = 132, + UnsupportedMediaInput = 142, - ConnectionTimedOut = 133, - NetworkProtocol = 134, - RateLimited = 135, - WebSocket = 136, - CdsiInvalidToken = 137, - ConnectionFailed = 138, - ChatServiceInactive = 139, + ConnectionTimedOut = 143, + NetworkProtocol = 144, + RateLimited = 145, + WebSocket = 146, + CdsiInvalidToken = 147, + ConnectionFailed = 148, + ChatServiceInactive = 149, + ChatServiceIntentionallyDisconnected = 150, - SvrDataMissing = 150, - SvrRestoreFailed = 151, - SvrRotationMachineTooManySteps = 152, + SvrDataMissing = 160, + SvrRestoreFailed = 161, + SvrRotationMachineTooManySteps = 162, - AppExpired = 160, - DeviceDeregistered = 161, + AppExpired = 170, + DeviceDeregistered = 171, - BackupValidation = 170, + BackupValidation = 180, } pub trait UpcastAsAny { @@ -546,6 +547,9 @@ impl FfiError for ChatServiceError { Self::ServiceInactive => "Chat service inactive".to_owned(), Self::AppExpired => "App expired".to_owned(), Self::DeviceDeregistered => "Device deregistered or delinked".to_owned(), + Self::ServiceIntentionallyDisconnected => { + "Chat service explicitly disconnected".to_owned() + } } } @@ -567,6 +571,9 @@ impl FfiError for ChatServiceError { Self::ServiceInactive => SignalErrorCode::ChatServiceInactive, Self::AppExpired => SignalErrorCode::AppExpired, Self::DeviceDeregistered => SignalErrorCode::DeviceDeregistered, + Self::ServiceIntentionallyDisconnected => { + SignalErrorCode::ChatServiceIntentionallyDisconnected + } } } } diff --git a/rust/bridge/shared/types/src/node/chat.rs b/rust/bridge/shared/types/src/node/chat.rs index 38a109590..69434cf5a 100644 --- a/rust/bridge/shared/types/src/node/chat.rs +++ b/rust/bridge/shared/types/src/node/chat.rs @@ -11,15 +11,21 @@ use neon::context::FunctionContext; use neon::event::Channel; use neon::handle::{Handle, Root}; use neon::prelude::{Context, Finalize, JsObject, Object}; +use neon::result::NeonResult; use signal_neon_futures::call_method; use crate::net::chat::{ChatListener, MakeChatListener, ServerMessageAck}; -use crate::node::ResultTypeInfo; +use crate::node::{ResultTypeInfo, SignalNodeError as _}; #[derive(Clone)] pub struct NodeChatListener { js_channel: Channel, - callback_object: Arc>, + roots: Arc, +} + +struct Roots { + callback_object: Root, + module: Root, } impl ChatListener for NodeChatListener { @@ -29,8 +35,9 @@ impl ChatListener for NodeChatListener { timestamp: Timestamp, ack: ServerMessageAck, ) { - let callback_object_shared = self.callback_object.clone(); + let roots_shared = self.roots.clone(); self.js_channel.send(move |mut cx| { + let callback_object_shared = &roots_shared.callback_object; let callback = callback_object_shared.to_inner(&mut cx); let ack = ack.convert_into(&mut cx)?; let timestamp = timestamp.convert_into(&mut cx)?.upcast(); @@ -41,28 +48,41 @@ impl ChatListener for NodeChatListener { "_incoming_message", [envelope, timestamp, ack], )?; - callback_object_shared.finalize(&mut cx); + roots_shared.finalize(&mut cx); Ok(()) }); } fn received_queue_empty(&mut self) { - let callback_object_shared = self.callback_object.clone(); + let roots_shared = self.roots.clone(); self.js_channel.send(move |mut cx| { + let callback_object_shared = &roots_shared.callback_object; let callback = callback_object_shared.to_inner(&mut cx); let _result = call_method(&mut cx, callback, "_queue_empty", [])?; - callback_object_shared.finalize(&mut cx); + roots_shared.finalize(&mut cx); Ok(()) }); } - // TODO: pass `_disconnect_cause` to `_connection_interrupted` - fn connection_interrupted(&mut self, _disconnect_cause: ChatServiceError) { - let callback_object_shared = self.callback_object.clone(); + fn connection_interrupted(&mut self, disconnect_cause: ChatServiceError) { + let disconnect_cause = match disconnect_cause { + ChatServiceError::ServiceIntentionallyDisconnected => None, + c => Some(c), + }; + let roots_shared = self.roots.clone(); self.js_channel.send(move |mut cx| { - let callback = callback_object_shared.to_inner(&mut cx); - let _result = call_method(&mut cx, callback, "_connection_interrupted", [])?; - callback_object_shared.finalize(&mut cx); + let Roots { + callback_object, + module, + } = &*roots_shared; + let module = module.to_inner(&mut cx); + let cause = disconnect_cause + .map(|cause| cause.into_throwable(&mut cx, module, "connection_interrupted")) + .convert_into(&mut cx)?; + + let callback = callback_object.to_inner(&mut cx); + let _result = call_method(&mut cx, callback, "_connection_interrupted", [cause])?; + roots_shared.finalize(&mut cx); Ok(()) }); } @@ -73,15 +93,21 @@ pub struct NodeMakeChatListener { } impl NodeMakeChatListener { - pub(crate) fn new(cx: &mut FunctionContext, callbacks: Handle) -> Self { + pub(crate) fn new(cx: &mut FunctionContext, callbacks: Handle) -> NeonResult { let mut channel = cx.channel(); channel.unref(cx); - Self { + + let module = cx.this::()?; + + Ok(Self { listener: NodeChatListener { js_channel: channel, - callback_object: Arc::new(callbacks.root(cx)), + roots: Arc::new(Roots { + callback_object: callbacks.root(cx), + module: module.root(cx), + }), }, - } + }) } } @@ -94,7 +120,14 @@ impl MakeChatListener for NodeMakeChatListener { impl Finalize for NodeMakeChatListener { fn finalize<'a, C: neon::prelude::Context<'a>>(self, cx: &mut C) { log::info!("finalize NodeMakeChatListener"); - self.listener.callback_object.finalize(cx); + self.listener.roots.finalize(cx); log::info!("finalize NodeMakeChatListener done"); } } + +impl Finalize for Roots { + fn finalize<'a, C: neon::prelude::Context<'a>>(self, cx: &mut C) { + self.callback_object.finalize(cx); + self.module.finalize(cx); + } +} diff --git a/rust/bridge/shared/types/src/node/convert.rs b/rust/bridge/shared/types/src/node/convert.rs index ca56b347c..26cfaf2c6 100644 --- a/rust/bridge/shared/types/src/node/convert.rs +++ b/rust/bridge/shared/types/src/node/convert.rs @@ -660,7 +660,7 @@ impl<'storage, 'context: 'storage> ArgTypeInfo<'storage, 'context> cx: &mut FunctionContext<'context>, foreign: Handle<'context, Self::ArgType>, ) -> NeonResult { - Ok(NodeMakeChatListener::new(cx, foreign)) + NodeMakeChatListener::new(cx, foreign) } fn load_from(stored: &'storage mut Self::StoredType) -> Self { diff --git a/rust/net/src/chat/error.rs b/rust/net/src/chat/error.rs index 56c628981..462b5b81d 100644 --- a/rust/net/src/chat/error.rs +++ b/rust/net/src/chat/error.rs @@ -36,6 +36,8 @@ pub enum ChatServiceError { ServiceInactive, /// Service is unavailable due to the lost connection ServiceUnavailable, + /// Service was disconnected by an intentional local call + ServiceIntentionallyDisconnected, } impl LogSafeDisplay for ChatServiceError {} diff --git a/rust/net/src/chat/ws.rs b/rust/net/src/chat/ws.rs index 5433316ec..2466f33eb 100644 --- a/rust/net/src/chat/ws.rs +++ b/rust/net/src/chat/ws.rs @@ -326,7 +326,7 @@ where async fn send(&self, msg: Request, timeout: Duration) -> Result { // checking if channel has been closed if self.service_cancellation.is_cancelled() { - return Err(WebSocketServiceError::ChannelClosed.into()); + return Err(ChatServiceError::ServiceIntentionallyDisconnected); } let (response_tx, response_rx) = oneshot::channel::(); diff --git a/swift/Sources/LibSignalClient/Error.swift b/swift/Sources/LibSignalClient/Error.swift index 7f40b6b04..34411505e 100644 --- a/swift/Sources/LibSignalClient/Error.swift +++ b/swift/Sources/LibSignalClient/Error.swift @@ -60,6 +60,7 @@ public enum SignalError: Error { case svrRestoreFailed(triesRemaining: UInt32, message: String) case svrRotationMachineTooManySteps(String) case chatServiceInactive(String) + case chatServiceIntentionallyDisconnected(String) case appExpired(String) case deviceDeregistered(String) case backupValidation(unknownFields: [String], message: String) @@ -201,6 +202,8 @@ internal func checkError(_ error: SignalFfiErrorRef?) throws { throw SignalError.svrRotationMachineTooManySteps(errStr) case SignalErrorCodeChatServiceInactive: throw SignalError.chatServiceInactive(errStr) + case SignalErrorCodeChatServiceIntentionallyDisconnected: + throw SignalError.chatServiceIntentionallyDisconnected(errStr) case SignalErrorCodeAppExpired: throw SignalError.appExpired(errStr) case SignalErrorCodeDeviceDeregistered: diff --git a/swift/Sources/SignalFfi/signal_ffi.h b/swift/Sources/SignalFfi/signal_ffi.h index aa548bf3b..dd201a91a 100644 --- a/swift/Sources/SignalFfi/signal_ffi.h +++ b/swift/Sources/SignalFfi/signal_ffi.h @@ -175,27 +175,28 @@ typedef enum { SignalErrorCodeUsernameTooLong = 126, SignalErrorCodeUsernameLinkInvalidEntropyDataLength = 127, SignalErrorCodeUsernameLinkInvalid = 128, - SignalErrorCodeUsernameDiscriminatorCannotBeEmpty = 140, - SignalErrorCodeUsernameDiscriminatorCannotBeZero = 141, - SignalErrorCodeUsernameDiscriminatorCannotBeSingleDigit = 142, - SignalErrorCodeUsernameDiscriminatorCannotHaveLeadingZeros = 143, - SignalErrorCodeUsernameDiscriminatorTooLarge = 144, - SignalErrorCodeIoError = 130, - SignalErrorCodeInvalidMediaInput = 131, - SignalErrorCodeUnsupportedMediaInput = 132, - SignalErrorCodeConnectionTimedOut = 133, - SignalErrorCodeNetworkProtocol = 134, - SignalErrorCodeRateLimited = 135, - SignalErrorCodeWebSocket = 136, - SignalErrorCodeCdsiInvalidToken = 137, - SignalErrorCodeConnectionFailed = 138, - SignalErrorCodeChatServiceInactive = 139, - SignalErrorCodeSvrDataMissing = 150, - SignalErrorCodeSvrRestoreFailed = 151, - SignalErrorCodeSvrRotationMachineTooManySteps = 152, - SignalErrorCodeAppExpired = 160, - SignalErrorCodeDeviceDeregistered = 161, - SignalErrorCodeBackupValidation = 170, + SignalErrorCodeUsernameDiscriminatorCannotBeEmpty = 130, + SignalErrorCodeUsernameDiscriminatorCannotBeZero = 131, + SignalErrorCodeUsernameDiscriminatorCannotBeSingleDigit = 132, + SignalErrorCodeUsernameDiscriminatorCannotHaveLeadingZeros = 133, + SignalErrorCodeUsernameDiscriminatorTooLarge = 134, + SignalErrorCodeIoError = 140, + SignalErrorCodeInvalidMediaInput = 141, + SignalErrorCodeUnsupportedMediaInput = 142, + SignalErrorCodeConnectionTimedOut = 143, + SignalErrorCodeNetworkProtocol = 144, + SignalErrorCodeRateLimited = 145, + SignalErrorCodeWebSocket = 146, + SignalErrorCodeCdsiInvalidToken = 147, + SignalErrorCodeConnectionFailed = 148, + SignalErrorCodeChatServiceInactive = 149, + SignalErrorCodeChatServiceIntentionallyDisconnected = 150, + SignalErrorCodeSvrDataMissing = 160, + SignalErrorCodeSvrRestoreFailed = 161, + SignalErrorCodeSvrRotationMachineTooManySteps = 162, + SignalErrorCodeAppExpired = 170, + SignalErrorCodeDeviceDeregistered = 171, + SignalErrorCodeBackupValidation = 180, } SignalErrorCode; /** diff --git a/swift/Sources/SignalFfi/signal_ffi_testing.h b/swift/Sources/SignalFfi/signal_ffi_testing.h index 62cf7981b..ad01d28e5 100644 --- a/swift/Sources/SignalFfi/signal_ffi_testing.h +++ b/swift/Sources/SignalFfi/signal_ffi_testing.h @@ -204,4 +204,6 @@ SignalFfiError *signal_testing_chat_service_inject_raw_server_request(const Sign SignalFfiError *signal_testing_chat_service_inject_connection_interrupted(const SignalChat *chat); +SignalFfiError *signal_testing_chat_service_inject_intentional_disconnect(const SignalChat *chat); + #endif /* SIGNAL_FFI_TESTING_H_ */