Skip to content
Open
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
5 changes: 3 additions & 2 deletions packages/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@
"private": true,
"scripts": {
"build": "tsc -p tsconfig.build.json",
"webpack": "webpack --env mode=development && cp ./lib/bundle.cjs ../backend-bundle/bundle.cjs",
"webpack:prod": "webpack --env mode=production && cp ./lib/bundle.cjs ../backend-bundle/bundle.cjs",
"webpack": "webpack --env mode=development && npm run sync-bundle-consumers",
"webpack:prod": "webpack --env mode=production && npm run sync-bundle-consumers",
"sync-bundle-consumers": "node ./scripts/sync-bundle-consumers.cjs",
"postinstall": "npm run applyPatches",
"applyPatches": "npm run applyPatches:it && npm run applyPatches:libp2p",
"applyPatches:libp2p": "patch -f -p0 < ./patch/libp2p/mplex.patch || true",
Expand Down
13 changes: 13 additions & 0 deletions packages/backend/scripts/sync-bundle-consumers.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
const { copyFileSync, mkdirSync } = require('node:fs')
const { dirname, resolve } = require('node:path')

const bundle = resolve(__dirname, '../lib/bundle.cjs')
const consumers = [
resolve(__dirname, '../../backend-bundle/bundle.cjs'),
resolve(__dirname, '../../mobile/nodejs-assets/nodejs-project/bundle.cjs'),
]

for (const consumer of consumers) {
mkdirSync(dirname(consumer), { recursive: true })
copyFileSync(bundle, consumer)
}
61 changes: 43 additions & 18 deletions packages/backend/src/backendManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,19 @@ import { HttpsProxyAgent } from 'https-proxy-agent'
import { randomBytes } from 'crypto'
import { sleep } from './nest/common/sleep'
import { type BackendLeaveCommunityMessage } from '@quiet/types'
import { MobileLifecycleCoordinator } from './mobile-lifecycle-coordinator'

// Shutdown helper constants
const SHUTDOWN_TIMEOUT = 60_000 // 1 minute

const parseMobilePort = (value: unknown, name: string): number => {
const port = Number(value)
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
throw new Error(`Invalid mobile ${name}`)
}
return port
}

const logger = createLogger('backendManager')

logger.info('Launching backend manager')
Expand Down Expand Up @@ -232,13 +241,21 @@ export const runBackendMobile = async (rn_bridge: any, secret: string) => {
process.env['BACKEND'] = 'mobile'
process.env['CONNECTION_TIME'] = (new Date().getTime() / 1000).toString()

const socketIOPort = parseMobilePort(options.dataPort, 'data port')
const httpTunnelPort = options.httpTunnelPort
? parseMobilePort(options.httpTunnelPort, 'HTTP tunnel port')
: undefined
const torControlPort = options.controlPort
? parseMobilePort(options.controlPort, 'Tor control port')
: await getPort()

const app: INestApplicationContext = await NestFactory.createApplicationContext(
AppModule.forOptions({
socketIOPort: options.dataPort,
socketIOPort,
socketIOSecret: secret,
httpTunnelPort: options.httpTunnelPort ? options.httpTunnelPort : null,
httpTunnelPort,
torAuthCookie: options.authCookie ? options.authCookie : null,
torControlPort: options.controlPort ? options.controlPort : await getPort(),
torControlPort,
torBinaryPath: options.torBinary ? options.torBinary : null,
options: {
env: {
Expand All @@ -249,11 +266,29 @@ export const runBackendMobile = async (rn_bridge: any, secret: string) => {
}),
{ logger: ['warn', 'error', 'log', 'debug', 'verbose'] }
)
let proxyAgent: HttpsProxyAgent<string> | undefined
const connectionsManager = app.get<ConnectionsManagerService>(ConnectionsManagerService)
const tor = app.get<Tor>(Tor)
const proxyAgent = app.get<HttpsProxyAgent<string>>(SOCKS_PROXY_AGENT)
const mobileLifecycle = new MobileLifecycleCoordinator({
pause: async () => connectionsManager.pause(),
activate: async (msg: OpenServices) => {
const torControlPort = parseMobilePort(msg.torControlPort, 'Tor control port')
const httpTunnelPort = parseMobilePort(msg.httpTunnelPort, 'HTTP tunnel port')
tor.rewireNativeTor({
controlPort: torControlPort,
httpTunnelPort,
authCookie: msg.authCookie,
})
proxyAgent.connectOpts.port = httpTunnelPort
proxyAgent.proxy.port = httpTunnelPort.toString()
await connectionsManager.resume()
},
})
let shutdownRequestedFromBridge = false
rn_bridge.channel.on('close', () => {
const connectionsManager = app.get<ConnectionsManagerService>(ConnectionsManagerService)
connectionsManager.pause()
void mobileLifecycle.pause().catch(error => {
logger.error('Failed to pause mobile services', error)
})
})
rn_bridge.channel.on('hibernate', async () => {
logger.info('Received hibernate message from RN bridge')
Expand All @@ -276,19 +311,9 @@ export const runBackendMobile = async (rn_bridge: any, secret: string) => {
}
})
rn_bridge.channel.on('open', (msg: OpenServices) => {
const connectionsManager = app.get<ConnectionsManagerService>(ConnectionsManagerService)
const tor = app.get<Tor>(Tor)
proxyAgent = app.get<HttpsProxyAgent<string>>(SOCKS_PROXY_AGENT)
const torControlPort = Number(msg.torControlPort)
const httpTunnelPort = Number(msg.httpTunnelPort)
tor.rewireNativeTor({
controlPort: torControlPort,
httpTunnelPort,
authCookie: msg.authCookie,
void mobileLifecycle.activate(msg).catch(error => {
logger.error('Failed to activate mobile services', error)
})
proxyAgent.connectOpts.port = httpTunnelPort
proxyAgent.proxy.port = httpTunnelPort.toString()
connectionsManager.resume()
})
const shutdown = setupGracefulShutdown(app, () => app.get<ConnectionsManagerService>(ConnectionsManagerService))
rn_bridge.channel.on('shutdown', async () => {
Expand Down
117 changes: 117 additions & 0 deletions packages/backend/src/mobile-lifecycle-coordinator.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { jest } from '@jest/globals'

import { MobileLifecycleCoordinator } from './mobile-lifecycle-coordinator'
import type { OpenServices } from './options'

const deferred = <T>() => {
let resolve!: (value: T | PromiseLike<T>) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve
reject = promiseReject
})
return { promise, reject, resolve }
}

const services = (authCookie: string): OpenServices => ({
authCookie,
httpTunnelPort: 12_345,
torControlPort: 23_456,
})

describe('MobileLifecycleCoordinator', () => {
it('serializes active behind an in-flight pause', async () => {
const pauseDeferred = deferred<void>()
const pause = jest.fn(() => pauseDeferred.promise)
const activate = jest.fn(async (_services: OpenServices) => undefined)
const coordinator = new MobileLifecycleCoordinator({ activate, pause })

const pauseRequest = coordinator.pause()
const activeRequest = coordinator.activate(services('cookie-a'))

expect(pause).toHaveBeenCalledTimes(1)
expect(activate).not.toHaveBeenCalled()

pauseDeferred.resolve()
await Promise.all([pauseRequest, activeRequest])

expect(activate).toHaveBeenCalledTimes(1)
expect(activate).toHaveBeenCalledWith(services('cookie-a'))
})

it('coalesces pause-active-pause to the final paused intent', async () => {
const pauseDeferred = deferred<void>()
const pause = jest.fn(() => pauseDeferred.promise)
const activate = jest.fn(async (_services: OpenServices) => undefined)
const coordinator = new MobileLifecycleCoordinator({ activate, pause })

const firstPause = coordinator.pause()
const active = coordinator.activate(services('cookie-a'))
const finalPause = coordinator.pause()

pauseDeferred.resolve()
await Promise.all([firstPause, active, finalPause])

expect(pause).toHaveBeenCalledTimes(1)
expect(activate).not.toHaveBeenCalled()
})

it('applies the newest active payload and skips an obsolete pause', async () => {
const firstActiveDeferred = deferred<void>()
const pause = jest.fn(async () => undefined)
const activate = jest.fn((payload: OpenServices) => {
if (payload.authCookie === 'cookie-a') {
return firstActiveDeferred.promise
}
return Promise.resolve()
})
const coordinator = new MobileLifecycleCoordinator({ activate, pause })

const firstActive = coordinator.activate(services('cookie-a'))
const pauseRequest = coordinator.pause()
const latestActive = coordinator.activate(services('cookie-b'))

expect(activate).toHaveBeenCalledTimes(1)
firstActiveDeferred.resolve()
await Promise.all([firstActive, pauseRequest, latestActive])

expect(pause).not.toHaveBeenCalled()
expect(activate).toHaveBeenCalledTimes(2)
expect(activate).toHaveBeenNthCalledWith(1, services('cookie-a'))
expect(activate).toHaveBeenNthCalledWith(2, services('cookie-b'))
})

it('applies a queued newer intent before reporting an earlier failure', async () => {
const error = new Error('pause failed')
const pauseDeferred = deferred<void>()
const pause = jest.fn(() => pauseDeferred.promise)
const activate = jest.fn(async (_services: OpenServices) => undefined)
const coordinator = new MobileLifecycleCoordinator({ activate, pause })

const pauseRequest = coordinator.pause()
const activeRequest = coordinator.activate(services('cookie-b'))
const requestsSettled = Promise.allSettled([pauseRequest, activeRequest])

pauseDeferred.reject(error)
const results = await requestsSettled

expect(activate).toHaveBeenCalledTimes(1)
expect(activate).toHaveBeenCalledWith(services('cookie-b'))
expect(results).toEqual([
{ reason: error, status: 'rejected' },
{ reason: error, status: 'rejected' },
])
})

it('recovers after a rejected transition', async () => {
const error = new Error('pause failed')
const pause = jest.fn<() => Promise<void>>().mockRejectedValueOnce(error).mockResolvedValueOnce(undefined)
const activate = jest.fn(async (_services: OpenServices) => undefined)
const coordinator = new MobileLifecycleCoordinator({ activate, pause })

await expect(coordinator.pause()).rejects.toThrow(error)
await coordinator.pause()

expect(pause).toHaveBeenCalledTimes(2)
})
})
94 changes: 94 additions & 0 deletions packages/backend/src/mobile-lifecycle-coordinator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import type { OpenServices } from './options'
import {
MobileLifecycleIntentType,
type MobileLifecycleHandlers,
type MobileLifecycleIntent,
} from './mobile-lifecycle-coordinator.types'

/**
* Serializes mobile close/open events while coalescing superseded intents.
* Active intents retain their payload because a new payload may describe a
* replacement native Tor session even when the app is already foregrounded.
*/
export class MobileLifecycleCoordinator {
private desiredIntent: MobileLifecycleIntent | undefined
private transitionInFlight: Promise<void> | undefined

constructor(private readonly handlers: MobileLifecycleHandlers) {}

public pause(): Promise<void> {
return this.request({ type: MobileLifecycleIntentType.PAUSED })
}

public activate(services: OpenServices): Promise<void> {
return this.request({ type: MobileLifecycleIntentType.ACTIVE, services: { ...services } })
}

private request(intent: MobileLifecycleIntent): Promise<void> {
this.desiredIntent = intent

if (!this.transitionInFlight) {
const transition = this.drain()
this.transitionInFlight = transition
void transition.then(
() => this.clearTransition(transition),
() => this.clearTransition(transition)
)
}

return this.transitionInFlight
}

private clearTransition(transition: Promise<void>): void {
if (this.transitionInFlight === transition) {
this.transitionInFlight = undefined
}
}

private async drain(): Promise<void> {
let transitionError: unknown
let transitionFailed = false

while (this.desiredIntent) {
const intent = this.desiredIntent

try {
if (intent.type === MobileLifecycleIntentType.PAUSED) {
await this.handlers.pause()
} else {
await this.handlers.activate(intent.services)
}
} catch (error) {
if (!transitionFailed) {
transitionError = error
transitionFailed = true
}

// A newer request must still be applied even when the transition it
// superseded failed. The shared request promise reports the failure
// after the coordinator reaches the latest requested state.
if (this.desiredIntent !== intent) {
continue
}
throw transitionError
}

const latestIntent = this.desiredIntent
if (latestIntent === intent) {
if (transitionFailed) {
throw transitionError
}
return
}

// Repeated close requests require no additional work. Active requests
// are always replayed so the newest Tor control payload is applied.
if (intent.type === MobileLifecycleIntentType.PAUSED && latestIntent.type === MobileLifecycleIntentType.PAUSED) {
if (transitionFailed) {
throw transitionError
}
return
}
}
}
}
18 changes: 18 additions & 0 deletions packages/backend/src/mobile-lifecycle-coordinator.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { OpenServices } from './options'

export enum MobileLifecycleIntentType {
PAUSED = 'paused',
ACTIVE = 'active',
}

export type MobileLifecycleIntent =
| { type: MobileLifecycleIntentType.PAUSED }
| {
type: MobileLifecycleIntentType.ACTIVE
services: OpenServices
}

export interface MobileLifecycleHandlers {
pause: () => Promise<void>
activate: (services: OpenServices) => Promise<void>
}
Loading
Loading