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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,24 @@

## Unreleased

### Added

- `ConfigBoxed`, the non-optional counterpart of `ConfigIndirect`: a property
wrapper that keeps a large config value on the heap with value semantics.

### Fixed

- Decoding and importing channel config uses much less stack, so it no longer
risks overflowing a 512 KB cooperative thread (the OpenClawLinuxRuntimeTests
bundle crashed with SIGBUS this way in debug builds). `ChannelsConfig` stores
its ten typed channel sections with `@ConfigBoxed` (about 6 KB → 170 bytes;
`OpenClawConfig` 8.5 KB → 2.3 KB), each section decodes in its own frame, and
`OpenClawConfig(document:)` imports secrets, gateway, auth and models in
separate helpers. Peak stack for `OpenClawConfig(document:)` dropped from
about 390 KB to 104 KB in debug builds (224 KB to 81 KB in release), and for
a full `ChannelsConfig` decode from 110 KB to 38 KB. The public properties
are unchanged.

## 2026.3.1 - 2026-09-30

OpenClawKit 2026.3.1 adds [Sign in with ChatGPT](https://developers.openai.com/siwc)
Expand Down
9 changes: 9 additions & 0 deletions Sources/OpenClawCore/ChannelsConfig+Coding.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,15 @@ struct ChannelConfigReader {
return nil
}

/// Decodes a typed channel section into heap storage; absent or invalid sections use `fallback`.
///
/// Out of line on purpose: a section's temporaries live in this frame only while that section
/// decodes, instead of every section's temporaries sharing the caller's frame.
@inline(never)
mutating func section<T: ChannelSectionConfig>(_ keys: String..., fallback: @autoclosure () -> T) -> ConfigBoxed<T> {
ConfigBoxed(wrappedValue: self.value(T.self, keys: keys) ?? fallback())
}

/// Decodes a string list that may contain numbers (upstream `Array<string | number>`).
mutating func stringList(_ keys: String...) -> [String]? {
self.consume(keys)
Expand Down
41 changes: 21 additions & 20 deletions Sources/OpenClawCore/ChannelsConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,25 +17,25 @@ import OpenClawProtocol
/// `enabled: false`); memberwise initializers keep defaulting to disabled.
public struct ChannelsConfig: Codable, Sendable, Equatable {
/// Discord settings.
public var discord: DiscordChannelConfig
@ConfigBoxed public var discord: DiscordChannelConfig
/// Telegram settings.
public var telegram: TelegramChannelConfig
@ConfigBoxed public var telegram: TelegramChannelConfig
/// WhatsApp Cloud API settings (SDK-only; upstream `whatsapp` is WhatsApp Web).
public var whatsappCloud: WhatsAppCloudChannelConfig
@ConfigBoxed public var whatsappCloud: WhatsAppCloudChannelConfig
/// Slack settings.
public var slack: SlackChannelConfig
@ConfigBoxed public var slack: SlackChannelConfig
/// Google Chat settings (encoded as `googlechat`).
public var googleChat: GoogleChatChannelConfig
@ConfigBoxed public var googleChat: GoogleChatChannelConfig
/// Signal settings.
public var signal: SignalChannelConfig
@ConfigBoxed public var signal: SignalChannelConfig
/// BlueBubbles settings. Removed upstream; migrate with ``migrateBlueBubblesToIMessage()``.
public var bluebubbles: LegacyBlueBubblesChannelConfig
@ConfigBoxed public var bluebubbles: LegacyBlueBubblesChannelConfig
/// iMessage settings.
public var imessage: IMessageChannelConfig
@ConfigBoxed public var imessage: IMessageChannelConfig
/// Microsoft Teams settings.
public var msteams: MicrosoftTeamsChannelConfig
@ConfigBoxed public var msteams: MicrosoftTeamsChannelConfig
/// SDK WebChat settings (upstream retired `channels.webchat`; never emitted upstream).
public var webchat: WebChatChannelConfig
@ConfigBoxed public var webchat: WebChatChannelConfig
/// Legacy SDK wrapper for plugin-only channels (kept for back-compat).
public var pluginChannels: [String: PluginChannelConfig]
/// Shared channel defaults (upstream `channels.defaults`).
Expand Down Expand Up @@ -108,16 +108,17 @@ public struct ChannelsConfig: Codable, Sendable, Equatable {
/// - Parameter decoder: Source decoder.
public init(from decoder: Decoder) throws {
var reader = try ChannelConfigReader(decoder: decoder)
self.discord = reader.value(DiscordChannelConfig.self, "discord") ?? DiscordChannelConfig()
self.telegram = reader.value(TelegramChannelConfig.self, "telegram") ?? TelegramChannelConfig()
self.whatsappCloud = reader.value(WhatsAppCloudChannelConfig.self, "whatsappCloud") ?? WhatsAppCloudChannelConfig()
self.slack = reader.value(SlackChannelConfig.self, "slack") ?? SlackChannelConfig()
self.googleChat = reader.value(GoogleChatChannelConfig.self, "googlechat", "googleChat") ?? GoogleChatChannelConfig()
self.signal = reader.value(SignalChannelConfig.self, "signal") ?? SignalChannelConfig()
self.bluebubbles = reader.value(LegacyBlueBubblesChannelConfig.self, "bluebubbles") ?? LegacyBlueBubblesChannelConfig()
self.imessage = reader.value(IMessageChannelConfig.self, "imessage") ?? IMessageChannelConfig()
self.msteams = reader.value(MicrosoftTeamsChannelConfig.self, "msteams") ?? MicrosoftTeamsChannelConfig()
self.webchat = reader.value(WebChatChannelConfig.self, "webchat") ?? WebChatChannelConfig()
// Each section decodes straight into its box in its own frame (see `ChannelConfigReader.section`).
self._discord = reader.section("discord", fallback: DiscordChannelConfig())
self._telegram = reader.section("telegram", fallback: TelegramChannelConfig())
self._whatsappCloud = reader.section("whatsappCloud", fallback: WhatsAppCloudChannelConfig())
self._slack = reader.section("slack", fallback: SlackChannelConfig())
self._googleChat = reader.section("googlechat", "googleChat", fallback: GoogleChatChannelConfig())
self._signal = reader.section("signal", fallback: SignalChannelConfig())
self._bluebubbles = reader.section("bluebubbles", fallback: LegacyBlueBubblesChannelConfig())
self._imessage = reader.section("imessage", fallback: IMessageChannelConfig())
self._msteams = reader.section("msteams", fallback: MicrosoftTeamsChannelConfig())
self._webchat = reader.section("webchat", fallback: WebChatChannelConfig())
self.pluginChannels = reader.container.decodeLossyDictionaryIfPresent(
PluginChannelConfig.self,
forKey: ChannelConfigKey("pluginChannels")
Expand Down
36 changes: 36 additions & 0 deletions Sources/OpenClawCore/ConfigDocument/ConfigCodingSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,42 @@ public struct ConfigIndirect<Wrapped: Sendable & Equatable>: Sendable, Equatable
}
}

/// Heap-backed storage for a large non-optional config value (see ``ConfigIndirect``).
///
/// ``ChannelsConfig`` holds ten channel sections of 500–800 bytes each. Stored inline they made it
/// about 6 KB and ``OpenClawConfig`` about 8.5 KB, and debug builds give every temporary copy its
/// own stack slot, so importing a config document took about 390 KB of a 512 KB cooperative-thread
/// stack. Like ``ConfigIndirect``, every write stores a new immutable box.
@propertyWrapper
public struct ConfigBoxed<Wrapped: Sendable & Equatable>: Sendable, Equatable {
private final class Storage: Sendable {
let value: Wrapped

init(_ value: Wrapped) {
self.value = value
}
}

private var storage: Storage

/// Creates storage holding `wrappedValue`.
/// - Parameter wrappedValue: Initial value.
public init(wrappedValue: Wrapped) {
self.storage = Storage(wrappedValue)
}

/// The stored value.
public var wrappedValue: Wrapped {
get { self.storage.value }
set { self.storage = Storage(newValue) }
}

/// Compares the stored values.
public static func == (lhs: ConfigBoxed, rhs: ConfigBoxed) -> Bool {
lhs.storage === rhs.storage || lhs.wrappedValue == rhs.wrappedValue
}
}

/// Stored hint that never affects equality (for example the authored order of map keys).
public struct ConfigOrderHint: Sendable, Equatable, Hashable {
/// Keys in authored order.
Expand Down
175 changes: 111 additions & 64 deletions Sources/OpenClawCore/ConfigDocument/OpenClawConfig+Document.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,85 +26,27 @@ extension OpenClawConfig {
/// - base: SDK-native values used where the document has no equivalent.
/// - issues: Receives values that could not be mapped.
public init(document: OpenClawConfigDocument, base: OpenClawConfig = OpenClawConfig(), issues: ConfigDecodeIssueCollector? = nil) {
// Each section imports in its own helper, so this frame stays small while the channel import
// (the deepest decode) runs below it. With the sections inline it was about 170 KB in debug builds.
var config = base
func record(_ path: String, _ message: String) {
issues?.record(ConfigDecodeIssue(path: path, message: message, kind: .invalidValue))
}
func decodeSection<T: Decodable>(_ type: T.Type, _ path: String, _ object: [String: AnyCodable]?) -> T? {
guard let object else { return nil }
do {
return try ConfigTreeCoding.decode(type, from: AnyCodable(.object(object)), issues: issues)
} catch {
record(path, "The \(path) section could not be imported; kept the base value: \(error)")
return nil
}
}

// Secrets (resolution stays SDK-local).
if let secrets = decodeSection(SecretsConfig.self, "secrets", document.secrets?.jsonObject) {
config.secrets = SecretsConfig(
providers: secrets.providers,
defaults: secrets.defaults,
resolution: base.secrets.resolution,
egressProxy: secrets.egressProxy
)
}
config.secrets = Self.importSecrets(document.secrets?.jsonObject, base: base.secrets, issues: issues, record: record)

// Gateway (host, health interval and handshake timeout stay SDK-local).
if let gatewayObject = document.gateway?.jsonObject, var gateway = decodeSection(GatewayConfig.self, "gateway", gatewayObject) {
gateway.host = base.gateway.host
gateway.channelHealthCheckMinutes = base.gateway.channelHealthCheckMinutes
gateway.handshakeTimeoutMs = base.gateway.handshakeTimeoutMs
if gatewayObject["bind"] == nil {
gateway.bind = base.gateway.bind
}
if var remote = gateway.remote {
remote.enabled = remote.enabled ?? base.gateway.remote?.enabled
gateway.remote = remote
}
config.gateway = gateway
}
config.gateway = Self.importGateway(document.gateway?.jsonObject, base: base.gateway, issues: issues, record: record)

// Auth metadata (cooldowns stay SDK-local).
if let auth = document.auth {
var profiles: [String: AuthProfileConfig] = [:]
for (id, profile) in auth.profiles ?? [:] {
guard let provider = ConfigValueSupport.nonEmpty(profile.provider),
let rawMode = ConfigValueSupport.nonEmpty(profile.mode?.rawValue)
else {
record("auth.profiles.\(id)", "Auth profile needs a provider and a mode; skipped.")
continue
}
let imported = AuthProfileConfig(provider: provider, rawMode: rawMode, email: profile.email, displayName: profile.displayName)
if let unknown = imported.unrecognizedMode {
record("auth.profiles.\(id)", "Unknown auth profile mode \"\(unknown)\" is kept but the profile is never selected.")
}
profiles[id] = imported
}
config.auth = AuthConfig(profiles: profiles, order: auth.order ?? base.auth.order, cooldowns: base.auth.cooldowns)
config.auth = Self.importAuth(auth, base: base.auth, record: record)
}

// Models (SDK provider sections stay as they are).
if let models = document.models {
if let mode = models.mode.flatMap(ModelsConfigMode.init(rawValue:)) {
config.models.mode = mode
}
if models.catalogRefresh != nil {
if let refresh = models.catalogRefreshConfig {
config.models.catalogRefresh = refresh
} else {
record("models.catalogRefresh", "catalogRefresh could not be represented as ModelCatalogRefreshConfig; kept the base value.")
}
}
if let providers = models.providers {
var imported: [String: ModelProviderConfig] = [:]
for (id, provider) in providers {
if let converted = Self.importProvider(provider, id: id, record: record) {
imported[id] = converted
}
}
config.models.providers = imported
}
config.models = Self.importModels(models, base: base.models, record: record)
}

config.agents = Self.importAgents(document: document, base: base.agents, record: record)
Expand Down Expand Up @@ -208,6 +150,111 @@ extension OpenClawConfig {

// MARK: Import helpers

private static func decodeSection<T: Decodable>(
_ type: T.Type,
_ path: String,
_ object: [String: AnyCodable]?,
issues: ConfigDecodeIssueCollector?,
record: (String, String) -> Void
) -> T? {
guard let object else { return nil }
do {
return try ConfigTreeCoding.decode(type, from: AnyCodable(.object(object)), issues: issues)
} catch {
record(path, "The \(path) section could not be imported; kept the base value: \(error)")
return nil
}
}

private static func importSecrets(
_ object: [String: AnyCodable]?,
base: SecretsConfig,
issues: ConfigDecodeIssueCollector?,
record: (String, String) -> Void
) -> SecretsConfig {
guard let secrets = Self.decodeSection(SecretsConfig.self, "secrets", object, issues: issues, record: record) else {
return base
}
return SecretsConfig(
providers: secrets.providers,
defaults: secrets.defaults,
resolution: base.resolution,
egressProxy: secrets.egressProxy
)
}

private static func importGateway(
_ object: [String: AnyCodable]?,
base: GatewayConfig,
issues: ConfigDecodeIssueCollector?,
record: (String, String) -> Void
) -> GatewayConfig {
guard let object, var gateway = Self.decodeSection(GatewayConfig.self, "gateway", object, issues: issues, record: record) else {
return base
}
gateway.host = base.host
gateway.channelHealthCheckMinutes = base.channelHealthCheckMinutes
gateway.handshakeTimeoutMs = base.handshakeTimeoutMs
if object["bind"] == nil {
gateway.bind = base.bind
}
if var remote = gateway.remote {
remote.enabled = remote.enabled ?? base.remote?.enabled
gateway.remote = remote
}
return gateway
}

private static func importAuth(
_ auth: OpenClawConfigDocument.Auth,
base: AuthConfig,
record: (String, String) -> Void
) -> AuthConfig {
var profiles: [String: AuthProfileConfig] = [:]
for (id, profile) in auth.profiles ?? [:] {
guard let provider = ConfigValueSupport.nonEmpty(profile.provider),
let rawMode = ConfigValueSupport.nonEmpty(profile.mode?.rawValue)
else {
record("auth.profiles.\(id)", "Auth profile needs a provider and a mode; skipped.")
continue
}
let imported = AuthProfileConfig(provider: provider, rawMode: rawMode, email: profile.email, displayName: profile.displayName)
if let unknown = imported.unrecognizedMode {
record("auth.profiles.\(id)", "Unknown auth profile mode \"\(unknown)\" is kept but the profile is never selected.")
}
profiles[id] = imported
}
return AuthConfig(profiles: profiles, order: auth.order ?? base.order, cooldowns: base.cooldowns)
}

private static func importModels(
_ models: OpenClawConfigDocument.Models,
base: ModelsConfig,
record: (String, String) -> Void
) -> ModelsConfig {
var result = base
if let mode = models.mode.flatMap(ModelsConfigMode.init(rawValue:)) {
result.mode = mode
}
if models.catalogRefresh != nil {
if let refresh = models.catalogRefreshConfig {
result.catalogRefresh = refresh
} else {
record("models.catalogRefresh", "catalogRefresh could not be represented as ModelCatalogRefreshConfig; kept the base value.")
}
}
if let providers = models.providers {
var imported: [String: ModelProviderConfig] = [:]
for (id, provider) in providers {
if let converted = Self.importProvider(provider, id: id, record: record) {
imported[id] = converted
}
}
result.providers = imported
}
return result
}

private static let sdkOnlyProviderKeys = [
"enabled", "chatCompletionsPath", "messagesPath", "apiVersion", "organizationID", "profile", "tenantID", "scope", "metadata",
]
Expand Down
Loading
Loading