Skip to content

Commit d68fc6a

Browse files
committed
Fix VLESS logging, managed proxy bootstrap and replay safety
1 parent d35c327 commit d68fc6a

13 files changed

Lines changed: 302 additions & 46 deletions

File tree

‎.github/workflows/build.yml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,12 @@ jobs:
6767
run: |
6868
python3 Tests/ArchiveLock/run.py
6969
python3 Tests/ArchiveLock/password_flow.py
70+
python3 Tests/ArchiveLock/theme_lifetime.py
7071
python3 Tests/ArchiveLock/migration.py
7172
- name: Managed proxy observation and customization tests
7273
run: |
7374
python3 Tests/ManagedProxy/run.py
75+
python3 Tests/ManagedProxy/bootstrap.py
7476
python3 Tests/Customization/run.py
7577
python3 Tests/MenuPresentation/run.py
7678
python3 Tests/NetworkPolicies/run.py

‎.github/workflows/client-regression.yml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ on:
1010
- 'submodules/TelegramCore/Sources/Network/MTWebSocketConnectionInterface.swift'
1111
- 'submodules/TelegramCore/Sources/Network/FetchV2.swift'
1212
- 'submodules/TelegramCore/Sources/State/**'
13+
- 'submodules/TelegramCore/Sources/Settings/ProxySettings.swift'
14+
- 'submodules/TelegramCore/Sources/Account/AccountManager.swift'
1315
- 'submodules/TelegramPresentationData/Sources/PresentationData.swift'
1416
- 'Tests/ManagedProxy/**'
1517
- 'Tests/Customization/**'
@@ -33,9 +35,11 @@ jobs:
3335
- uses: actions/checkout@v5
3436
- run: swift test --package-path submodules/TelegramVLESS
3537
- run: python3 Tests/ManagedProxy/run.py
38+
- run: python3 Tests/ManagedProxy/bootstrap.py
3639
- run: python3 Tests/Customization/run.py
3740
- run: python3 Tests/MenuPresentation/run.py
3841
- run: python3 Tests/ArchiveLock/password_flow.py
42+
- run: python3 Tests/ArchiveLock/theme_lifetime.py
3943
- run: python3 Tests/ArchiveLock/migration.py
4044
- run: python3 Tests/NetworkPolicies/run.py
4145
- run: python3 Tests/NetworkRecovery/run.py

‎Tests/ArchiveLock/password_flow.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
root = pathlib.Path(__file__).resolve().parents[2]
66
source = (root / 'submodules/ChatListUI/Sources/ArchiveLockHelpers.swift').read_text(encoding='utf-8')
77
start = source.index('private var activePasswordPrompts:')
8-
end = source.index('\nprivate func presentUIAlert(', start)
8+
end = source.index('\nprivate var archiveAlertThemeSubscriptionKey:', start)
99
fixtures = (root / 'Tests/ArchiveLock/PasswordFlowFixtures.swift').read_text(encoding='utf-8')
1010
tests = '''
1111
let context = AccountContext()
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"""Exercise production alert association/cleanup with SwiftSignalKit on macOS."""
2+
import json
3+
import pathlib
4+
import subprocess
5+
import tempfile
6+
7+
root = pathlib.Path(__file__).resolve().parents[2]
8+
source = (root / 'submodules/ChatListUI/Sources/ArchiveLockHelpers.swift').read_text(encoding='utf-8')
9+
start = source.index('private var archiveAlertThemeSubscriptionKey:')
10+
end = source.index('\npublic func setArchivePassword(', start)
11+
fixtures = r'''
12+
import Foundation
13+
import ObjectiveC
14+
import SwiftSignalKit
15+
struct Theme {
16+
var overallDarkAppearance = true
17+
var actionSheet = ActionSheet()
18+
var rootController = RootController()
19+
struct ActionSheet { var controlAccentColor = 7 }
20+
struct RootController { var keyboardColor = KeyboardColor() }
21+
struct KeyboardColor { var keyboardAppearance = 3 }
22+
}
23+
struct PresentationData { var theme = Theme() }
24+
class UIViewController: NSObject {
25+
var presentedViewController: UIViewController?
26+
func present(_ alert: UIAlertController, animated: Bool) { presentedViewController = alert }
27+
}
28+
final class UIAlertController: UIViewController {
29+
enum Style { case light, dark }
30+
var overrideUserInterfaceStyle = Style.light
31+
final class View { var tintColor = 0 }
32+
final class Field { var keyboardAppearance = 0 }
33+
let view = View()
34+
var textFields: [Field]? = [Field()]
35+
}
36+
final class Window { var rootViewController: UIViewController? = UIViewController() }
37+
final class Bindings {
38+
var window: Window? = Window()
39+
func getTopWindow() -> Window? { window }
40+
}
41+
final class SharedContext {
42+
let applicationBindings = Bindings()
43+
var listeners = 0
44+
var emit: ((PresentationData) -> Void)?
45+
var presentationData: Signal<PresentationData, NoError> {
46+
return Signal { subscriber in
47+
self.listeners += 1
48+
self.emit = { subscriber.putNext($0) }
49+
subscriber.putNext(PresentationData())
50+
return ActionDisposable {
51+
self.listeners -= 1
52+
self.emit = nil
53+
}
54+
}
55+
}
56+
}
57+
final class AccountContext { let sharedContext = SharedContext() }
58+
'''
59+
tests = r'''
60+
let context = AccountContext()
61+
for _ in 0..<100 {
62+
weak var releasedAlert: UIAlertController?
63+
autoreleasepool {
64+
let alert = UIAlertController()
65+
releasedAlert = alert
66+
presentUIAlert(context: context, alert: alert, onUnavailableHost: { preconditionFailure() })
67+
precondition(context.sharedContext.listeners == 1)
68+
precondition(alert.overrideUserInterfaceStyle == .dark)
69+
precondition(alert.view.tintColor == 7 && alert.textFields?.first?.keyboardAppearance == 3)
70+
var updated = PresentationData()
71+
updated.theme.overallDarkAppearance = false
72+
updated.theme.actionSheet.controlAccentColor = 9
73+
context.sharedContext.emit?(updated)
74+
precondition(alert.overrideUserInterfaceStyle == .light && alert.view.tintColor == 9)
75+
context.sharedContext.applicationBindings.window?.rootViewController?.presentedViewController = nil
76+
}
77+
precondition(releasedAlert == nil)
78+
precondition(context.sharedContext.listeners == 0, "Dismissed alert still listens without another theme event")
79+
}
80+
context.sharedContext.applicationBindings.window = nil
81+
var unavailable = 0
82+
autoreleasepool {
83+
presentUIAlert(context: context, alert: UIAlertController(), onUnavailableHost: { unavailable += 1 })
84+
}
85+
precondition(unavailable == 1 && context.sharedContext.listeners == 0)
86+
print("Archive alert live theme, 100 release cycles and unavailable-host cleanup: passed")
87+
'''
88+
with tempfile.TemporaryDirectory(prefix='archive-theme-') as tmp:
89+
package = pathlib.Path(tmp)
90+
sources = package / 'Sources/Checks'
91+
sources.mkdir(parents=True)
92+
(sources / 'main.swift').write_text(fixtures + source[start:end] + tests, encoding='utf-8')
93+
dependency = json.dumps(str(root / 'submodules/SSignalKit'))
94+
(package / 'Package.swift').write_text('''// swift-tools-version:5.5
95+
import PackageDescription
96+
let package = Package(name: "ArchiveThemeChecks", platforms: [.macOS(.v10_15)],
97+
dependencies: [.package(path: ''' + dependency + ''')],
98+
targets: [.executableTarget(name: "Checks", dependencies: [.product(name: "SwiftSignalKit", package: "SSignalKit")])])
99+
''', encoding='utf-8')
100+
subprocess.run(['swift', 'run', '--package-path', tmp, 'Checks'], check=True)

‎Tests/ManagedProxy/bootstrap.py‎

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
"""Run the production route application against a synchronous MTContext fixture."""
2+
import pathlib
3+
import subprocess
4+
import tempfile
5+
6+
root = pathlib.Path(__file__).resolve().parents[2]
7+
source = (root / 'submodules/TelegramCore/Sources/Settings/ProxySettings.swift').read_text(encoding='utf-8')
8+
start = source.index('func applySharedProxySettingsToNetwork(')
9+
apply = source[start:source.index('\n}\n', start) + 2]
10+
fixtures = r'''
11+
import Foundation
12+
final class MTSocksProxySettings: NSObject {
13+
let id: String
14+
init(_ id: String) { self.id = id }
15+
override func isEqual(_ object: Any?) -> Bool {
16+
return (object as? MTSocksProxySettings)?.id == self.id
17+
}
18+
}
19+
struct Connection {
20+
var isWebProxy = false
21+
var isVlessProxy = false
22+
}
23+
struct ProxyServerSettings {
24+
static let managedBootstrapProxySettings = MTSocksProxySettings("blocked")
25+
var connection: Connection
26+
var webProxyConfiguration: String? = nil
27+
var vlessProxyURL: String? = nil
28+
var mtProxySettings: MTSocksProxySettings? = nil
29+
}
30+
struct ProxySettings {
31+
var useLocalDNSForProxyHosts = false
32+
var effectiveActiveServer: ProxyServerSettings?
33+
}
34+
final class WebProxyManager {
35+
static let shared = WebProxyManager()
36+
func configure(activeWebProxy: String?) {}
37+
func isReady(for configuration: String) -> Bool { true }
38+
}
39+
final class VlessManager {
40+
static let shared = VlessManager()
41+
func configure(activeProfileURL: String?) {}
42+
}
43+
final class Environment {
44+
let socksProxySettings: MTSocksProxySettings?
45+
init(_ route: MTSocksProxySettings?) { self.socksProxySettings = route }
46+
func withUpdatedSocksProxySettings(_ route: MTSocksProxySettings?) -> Environment {
47+
return Environment(route)
48+
}
49+
}
50+
final class Context {
51+
var forceLocalDNS = false
52+
var environment = Environment(nil)
53+
var updates = 0
54+
func updateApiEnvironment(_ f: (Environment?) -> Environment?) {
55+
if let updated = f(environment) { environment = updated; updates += 1 }
56+
}
57+
}
58+
final class Network {
59+
let context = Context()
60+
var paused = false
61+
var resumedRoutes: [String] = []
62+
var drops = 0
63+
func pauseForWebProxyBootstrap() { paused = true }
64+
func resumeIfWebProxyBootstrapPaused() {
65+
if paused { resumedRoutes.append(context.environment.socksProxySettings?.id ?? "direct") }
66+
paused = false
67+
}
68+
func dropConnectionStatus() { drops += 1 }
69+
func rebuildTransport() {}
70+
}
71+
'''
72+
tests = r'''
73+
for web in [false, true] {
74+
for previous in [nil, "old-remote-proxy", "old-loopback-profile", "blocked"] as [String?] {
75+
let network = Network()
76+
network.context.environment = Environment(previous.map { MTSocksProxySettings($0) })
77+
var server = ProxyServerSettings(connection: Connection(isWebProxy: web, isVlessProxy: !web))
78+
var settings = ProxySettings(effectiveActiveServer: server)
79+
applySharedProxySettingsToNetwork(settings: settings, network: network)
80+
precondition(network.paused)
81+
precondition(network.context.environment.socksProxySettings?.id == "blocked")
82+
precondition(network.resumedRoutes.isEmpty)
83+
let updates = network.context.updates
84+
for _ in 0..<100 { applySharedProxySettingsToNetwork(settings: settings, network: network) }
85+
precondition(network.context.updates == updates, "Repeated bootstrap rebuilt the route")
86+
87+
server.mtProxySettings = MTSocksProxySettings("new-loopback-profile")
88+
settings.effectiveActiveServer = server
89+
applySharedProxySettingsToNetwork(settings: settings, network: network)
90+
precondition(!network.paused)
91+
precondition(network.resumedRoutes == ["new-loopback-profile"], "Resumed before route update")
92+
settings.effectiveActiveServer = nil
93+
applySharedProxySettingsToNetwork(settings: settings, network: network)
94+
precondition(network.context.environment.socksProxySettings == nil)
95+
}
96+
}
97+
print("Managed bootstrap: previous routes blocked, repeated events coalesced, ready route installed before resume")
98+
'''
99+
with tempfile.TemporaryDirectory(prefix='managed-bootstrap-') as tmp:
100+
file = pathlib.Path(tmp) / 'main.swift'
101+
file.write_text(fixtures + apply + tests, encoding='utf-8')
102+
subprocess.run(['swift', str(file)], check=True)

‎submodules/ChatListUI/Sources/ArchiveLockHelpers.swift‎

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import Foundation
22
import UIKit
3+
import ObjectiveC
34
import UserNotifications
45
import Display
56
import SwiftSignalKit
@@ -639,13 +640,23 @@ private func presentArchivePasswordAlert(
639640
show(messageOverride: nil)
640641
}
641642

642-
private func presentUIAlert(context: AccountContext, alert: UIAlertController, onUnavailableHost: @escaping () -> Void) {
643+
private var archiveAlertThemeSubscriptionKey: UInt8 = 0
644+
645+
private final class ArchiveAlertThemeSubscription {
643646
let disposable = MetaDisposable()
644-
disposable.set((context.sharedContext.presentationData |> deliverOnMainQueue).startStrict(next: { [weak alert] presentationData in
645-
guard let alert = alert else {
646-
disposable.dispose()
647-
return
648-
}
647+
648+
deinit {
649+
self.disposable.dispose()
650+
}
651+
}
652+
653+
private func presentUIAlert(context: AccountContext, alert: UIAlertController, onUnavailableHost: @escaping () -> Void) {
654+
let subscription = ArchiveAlertThemeSubscription()
655+
// The alert owns its subscription, which captures the alert only weakly.
656+
// Releasing a dismissed alert disposes immediately, even if the theme never changes.
657+
objc_setAssociatedObject(alert, &archiveAlertThemeSubscriptionKey, subscription, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
658+
subscription.disposable.set((context.sharedContext.presentationData |> deliverOnMainQueue).startStrict(next: { [weak alert] presentationData in
659+
guard let alert = alert else { return }
649660
let theme = presentationData.theme
650661
if #available(iOS 13.0, *) {
651662
alert.overrideUserInterfaceStyle = theme.overallDarkAppearance ? .dark : .light

‎submodules/TelegramCore/Sources/Account/AccountManager.swift‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import SwiftSignalKit
44
import TelegramApi
55
import MtProtoKit
66
import WebProxyTransport
7+
import TelegramVLESS
78
import MTWebSocketTransport
89

910
private enum AccountKind {
@@ -257,12 +258,15 @@ private var declaredEncodables: Void = {
257258
public func initializeAccountManagement() {
258259
let _ = declaredEncodables
259260

260-
// Both transports sit below TelegramCore in the dependency graph and so cannot reach `Logger`
261+
// These transports sit below TelegramCore in the dependency graph and cannot reach `Logger`
261262
// themselves. Installing their sinks here is the one place that runs before either can be
262263
// asked to do anything, and is idempotent — a second call reinstalls the same closure.
263264
WebProxyLog.handler = { message in
264265
Logger.shared.log("WebProxy", message)
265266
}
267+
VlessLog.handler = { message in
268+
Logger.shared.log("VlessManager", message)
269+
}
266270
WebSocketTransportLog.handler = { message in
267271
Logger.shared.log("MTWebSocket", message)
268272
}

‎submodules/TelegramCore/Sources/Settings/ProxySettings.swift‎

Lines changed: 4 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -124,16 +124,10 @@ func applySharedProxySettingsToNetwork(settings: ProxySettings, network: Network
124124
network.context.updateApiEnvironment { environment in
125125
network.pauseForWebProxyBootstrap()
126126
let current = environment?.socksProxySettings
127-
// If a previous loopback endpoint is still alive (e.g. the same profile
128-
// restarted after a heartbeat failure), keep it so MTProto does not drop
129-
// in-flight requests while the runtime warm-starts. Only block on the
130-
// sentinel when there is no usable endpoint at all — mirrors WebProxy
131-
// behaviour at lines 142-148 above.
127+
// An unresolved managed profile must block every MTContext consumer,
128+
// including workers that are not covered by the primary MTProto pause.
129+
// The previous endpoint may belong to a different proxy/profile.
132130
let blocked = ProxyServerSettings.managedBootstrapProxySettings
133-
if let current, !current.isEqual(blocked) {
134-
// Non-sentinel: a real loopback endpoint from a prior start. Retain it.
135-
return nil
136-
}
137131
if current?.isEqual(blocked) == true {
138132
return nil
139133
}
@@ -145,20 +139,7 @@ func applySharedProxySettingsToNetwork(settings: ProxySettings, network: Network
145139

146140
network.context.updateApiEnvironment { environment in
147141
let current = environment?.socksProxySettings
148-
let updated: MTSocksProxySettings?
149-
if isActiveWebProxy {
150-
if let resolvedProxySettings = resolvedProxySettings {
151-
updated = resolvedProxySettings
152-
} else if let current = current {
153-
// Sidecar not ready yet (bootstrap / resume) — keep the previous endpoint
154-
// rather than falling back to a direct connection.
155-
updated = current
156-
} else {
157-
updated = nil
158-
}
159-
} else {
160-
updated = resolvedProxySettings
161-
}
142+
let updated = resolvedProxySettings
162143
let updateNetwork: Bool
163144
if previousForceLocalDNS != settings.useLocalDNSForProxyHosts {
164145
updateNetwork = true

‎submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift‎

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3927,7 +3927,6 @@ private func recordPeerActivityTimestamp(peerId: PeerId, timestamp: Int32, into
39273927

39283928
enum ReplayFinalStateError: Error {
39293929
case verificationFailed
3930-
case timeout
39313930
}
39323931

39333932
func replayFinalState(
@@ -3943,6 +3942,16 @@ func replayFinalState(
39433942
ignoreDate: Bool,
39443943
skipVerification: Bool
39453944
) throws -> AccountReplayedFinalState {
3945+
let replayStartTime = ProcessInfo.processInfo.systemUptime
3946+
defer {
3947+
let duration = ProcessInfo.processInfo.systemUptime - replayStartTime
3948+
if duration > 3.0 {
3949+
Logger.shared.log("State", "Slow replayFinalState: \(duration)s, \(finalState.state.operations.count) operations")
3950+
}
3951+
}
3952+
// Postbox commits the enclosing transaction even when a caller catches an
3953+
// error. Only reject before mutations; never abort a partially applied replay
3954+
// on a time budget or reset PTS to getState after such an abort.
39463955
if !skipVerification {
39473956
let verified = verifyTransaction(transaction, finalState: finalState.state)
39483957
if !verified {
@@ -4057,14 +4066,7 @@ func replayFinalState(
40574066

40584067
var liveTypingDraftUpdates: [PeerAndThreadId: [LiveTypingDraftUpdate]] = [:]
40594068

4060-
let watchdogStartTime = CFAbsoluteTimeGetCurrent()
4061-
40624069
for operation in finalState.state.operations {
4063-
if CFAbsoluteTimeGetCurrent() - watchdogStartTime > 3.0 {
4064-
Logger.shared.log("State", "Watchdog triggered: replayFinalState exceeded 3.0s budget")
4065-
throw ReplayFinalStateError.timeout
4066-
}
4067-
40684070
switch operation {
40694071
case let .AddMessages(messages, location):
40704072
if case .UpperHistoryBlock = location {

0 commit comments

Comments
 (0)