From 140ce1ee3a840abc136238b51bf427ccc7507c0e Mon Sep 17 00:00:00 2001 From: MarcoDotIO Date: Wed, 30 Sep 2026 15:45:48 -0400 Subject: [PATCH] fix: bound the stack used to decode and import channel config OpenClawLinuxRuntimeTests sometimes crashed with SIGBUS ("Thread stack size exceeded") on a 512 KB cooperative thread. The crashing test is CoreConfigIntegrationTests.importMapsChannelsCompatibilityAndGroupChat; run alone it crashed 5 of 5 times, because the metadata is always cold then and the one-time generic metadata instantiation needs a few more KB of stack. The depth came from large value types, not recursion. ChannelsConfig stored ten channel sections inline (6,378 bytes; OpenClawConfig 8,520), and debug builds give every temporary copy its own stack slot. Frames at the crash: test body 183 KB, OpenClawConfig.init(document:) 171 KB, ChannelsConfig.init(from:) 63 KB, importChannels 46 KB, the channelsConfig getter 45 KB. - Add ConfigBoxed, a non-optional ConfigIndirect, and store the ten typed channel sections with it: ChannelsConfig 170 bytes, OpenClawConfig 2,312. - Decode each section straight into its box through an @inline(never) ChannelConfigReader.section helper, so one section's temporaries are on the stack at a time. - Import secrets, gateway, auth and models in their own helpers in OpenClawConfig(document:), like channels and agents already were. Peak stack, measured by the new StackUsageProbe (debug / release): - full ChannelsConfig decode: 110 KB -> 38 KB / 57 KB -> 30 KB - OpenClawConfig(document:): 388 KB -> 104 KB / 224 KB -> 81 KB - SDK-native OpenClawConfig decode: 204 KB -> 70 KB / 131 KB -> 63 KB ConfigDecodeStackDepthTests runs both decodes on a thread with a small, pattern-filled stack and bounds the peak (64 KB and 192 KB); both bounds fail on the old code. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 18 ++ .../OpenClawCore/ChannelsConfig+Coding.swift | 9 + Sources/OpenClawCore/ChannelsConfig.swift | 41 ++-- .../ConfigDocument/ConfigCodingSupport.swift | 36 ++++ .../OpenClawConfig+Document.swift | 175 +++++++++++------- .../ConfigDecodeStackDepthTests.swift | 108 +++++++++++ .../StackUsageProbe.swift | 107 +++++++++++ 7 files changed, 410 insertions(+), 84 deletions(-) create mode 100644 Tests/OpenClawLinuxRuntimeTests/ConfigDecodeStackDepthTests.swift create mode 100644 Tests/OpenClawLinuxRuntimeTests/StackUsageProbe.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index e19ccbc..7bb22c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/Sources/OpenClawCore/ChannelsConfig+Coding.swift b/Sources/OpenClawCore/ChannelsConfig+Coding.swift index 6c02b1b..b36970b 100644 --- a/Sources/OpenClawCore/ChannelsConfig+Coding.swift +++ b/Sources/OpenClawCore/ChannelsConfig+Coding.swift @@ -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(_ keys: String..., fallback: @autoclosure () -> T) -> ConfigBoxed { + ConfigBoxed(wrappedValue: self.value(T.self, keys: keys) ?? fallback()) + } + /// Decodes a string list that may contain numbers (upstream `Array`). mutating func stringList(_ keys: String...) -> [String]? { self.consume(keys) diff --git a/Sources/OpenClawCore/ChannelsConfig.swift b/Sources/OpenClawCore/ChannelsConfig.swift index e8e5968..7c4c79a 100644 --- a/Sources/OpenClawCore/ChannelsConfig.swift +++ b/Sources/OpenClawCore/ChannelsConfig.swift @@ -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`). @@ -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") diff --git a/Sources/OpenClawCore/ConfigDocument/ConfigCodingSupport.swift b/Sources/OpenClawCore/ConfigDocument/ConfigCodingSupport.swift index 23f9651..2dce2d2 100644 --- a/Sources/OpenClawCore/ConfigDocument/ConfigCodingSupport.swift +++ b/Sources/OpenClawCore/ConfigDocument/ConfigCodingSupport.swift @@ -379,6 +379,42 @@ public struct ConfigIndirect: 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: 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. diff --git a/Sources/OpenClawCore/ConfigDocument/OpenClawConfig+Document.swift b/Sources/OpenClawCore/ConfigDocument/OpenClawConfig+Document.swift index 678456d..d8ac21e 100644 --- a/Sources/OpenClawCore/ConfigDocument/OpenClawConfig+Document.swift +++ b/Sources/OpenClawCore/ConfigDocument/OpenClawConfig+Document.swift @@ -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(_ 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) @@ -208,6 +150,111 @@ extension OpenClawConfig { // MARK: Import helpers + private static func decodeSection( + _ 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", ] diff --git a/Tests/OpenClawLinuxRuntimeTests/ConfigDecodeStackDepthTests.swift b/Tests/OpenClawLinuxRuntimeTests/ConfigDecodeStackDepthTests.swift new file mode 100644 index 0000000..76c5cab --- /dev/null +++ b/Tests/OpenClawLinuxRuntimeTests/ConfigDecodeStackDepthTests.swift @@ -0,0 +1,108 @@ +import Foundation +import OpenClawProtocol +import Testing +@testable import OpenClawCore + +/// Channel config decoding must stay well inside a cooperative-thread stack (512 KB on Darwin). +/// +/// `ChannelsConfig` used to store its ten channel sections inline (about 6 KB, and 8.5 KB for +/// `OpenClawConfig`), and debug builds give every temporary copy its own stack slot. Importing a +/// document with `OpenClawConfig(document:)` took about 390 KB of stack; with a test body above it, +/// this bundle crashed with SIGBUS ("Thread stack size exceeded") whenever the one-time generic +/// metadata instantiation needed a few more kilobytes. These tests run the decodes on a thread with +/// a small stack and bound the peak each one uses (debug builds: about 38 KB and 104 KB now). +@Suite("Config decode stack depth") +struct ConfigDecodeStackDepthTests { + /// Messaging policy keys shared by every section, including nested objects, accounts and + /// legacy keys, so each section takes its deepest decode path. + static let policyJSON = #""" + "dmPolicy": "allowlist", "allowFrom": [42, "+15550001111"], "groupPolicy": "open", + "groupAllowFrom": ["ops"], "defaultTo": 7, "textChunkLimit": 4000, + "streaming": {"mode": "partial", "chunkMode": "newline", + "block": {"enabled": true, "coalesce": {"minChars": 10, "maxChars": 100, "idleMs": 50}}}, + "chunkMode": "length", "blockStreaming": true, "blockStreamingCoalesce": {"minChars": 1}, + "mediaMaxMb": 5, "replyToMode": "first", "responsePrefix": "auto", "historyLimit": 20, + "dmHistoryLimit": 10, "contextVisibility": "all", "markdown": {"tables": "code"}, + "implicitMentions": {"replyToBot": true, "quotedBot": false}, + "botLoopProtection": {"enabled": true, "maxEventsPerWindow": 5}, "allowBots": "mentions", + "ackReaction": "eyes", "ackReactionScope": "group-mentions", "reactionNotifications": "own", + "reactionLevel": "minimal", "joinIntro": true, + "groups": {"*": {"requireMention": false, "groupPolicy": "open", "allowFrom": [1, 2], + "botLoopProtection": {"enabled": false}, "implicitMentions": {"threadParticipation": true}}}, + "mentionPatterns": ["@bot"], "heartbeatVisibility": {"showOk": true}, "typingMode": "thinking", + "typingIntervalMs": 6000, + "accounts": {"work": {"botToken": "${WORK_TOKEN}", "dmPolicy": "open", "streaming": {"mode": "off"}}}, + "defaultAccount": "work", "futureKey": {"nested": [1, {"deep": true}]} + """# + + /// Every typed section plus defaults, model overrides, compatibility, legacy plugin channels + /// and two extension channels. + static let channelsJSON: String = { + let sections = [ + #""discord": {"token": {"source": "env", "provider": "default", "id": "DISCORD_TOKEN"}, "presenceEnabled": false"#, + #""telegram": {"botToken": "${TELEGRAM_BOT_TOKEN}", "webhookSecret": {"source": "file", "provider": "vault", "id": "/tg"}, "# + + #""apiRoot": "https://t.example", "timeoutSeconds": 5"#, + #""whatsappCloud": {"enabled": true, "accessToken": "${WA_TOKEN}", "phoneNumberId": 15550001111, "appSecret": "s""#, + #""slack": {"botToken": "xoxb-1", "appToken": "xapp-1", "signingSecret": "${SLACK_SIGNING}", "# + + #""relay": {"url": "https://relay.example", "authToken": "${RELAY}", "gatewayId": "g"}"#, + #""googlechat": {"serviceAccount": {"type": "service_account"}, "audienceType": "app-url", "audience": 1234"#, + #""signal": {"account": "+15550001111", "transport": {"kind": "container", "url": "http://signal:8080"}"#, + #""bluebubbles": {"serverUrl": "http://bb.local", "password": "p", "actions": {"reactions": false}"#, + #""imessage": {"cliPath": "imsg", "actions": {"reactions": false}, "catchup": {"enabled": true}"#, + #""msteams": {"appId": "a", "appPassword": "${TEAMS}", "cloud": "usGov""#, + #""webchat": {"enabled": true, "sharedSecret": "w""#, + #""sms": {"authToken": "plain""#, + #""matrix": {"homeserver": "https://matrix.example.com""#, + ].map { "\($0), \(Self.policyJSON)}" } + let rest = [ + #""defaults": {"groupPolicy": "open", "implicitMentions": {"replyToBot": true}}"#, + #""modelByChannel": {"telegram": {"-100": "openai/gpt-5.4"}}"#, + #""compatibility": {"legacySessionAccountKeys": true}"#, + #""pluginChannels": {"legacy": {"enabled": true, "config": {"room": "lobby"}}}"#, + ] + return "{" + (sections + rest).joined(separator: ",\n") + "}" + }() + + @Test + func fixtureDecodesEverySection() throws { + let config = try JSONDecoder().decode(ChannelsConfig.self, from: Data(Self.channelsJSON.utf8)) + let sections: [any ChannelSectionConfig] = [ + config.discord, config.telegram, config.whatsappCloud, config.slack, config.googleChat, + config.signal, config.bluebubbles, config.imessage, config.msteams, config.webchat, + ] + for section in sections { + #expect(section.enabled) + #expect(section.policy.groups?["*"]?.requireMention == false) + #expect(section.accounts["work"] != nil) + } + #expect(config.slack.relay?.url == "https://relay.example") + #expect(config.signal.transport?.kind == .container) + #expect(Set(config.extensionChannels.keys) == ["sms", "matrix"]) + #expect(config.pluginChannels["legacy"]?.enabled == true) + #expect(config.compatibility.legacySessionAccountKeys) + } + + @Test + func channelsConfigDecodeStaysShallow() throws { + let data = Data(Self.channelsJSON.utf8) + let peak = try StackUsageProbe.peakBytes(stackSize: 128 * 1024) { + let decoder = JSONDecoder() + decoder.userInfo[.openClawConfigIssues] = ConfigDecodeIssueCollector() + _ = try? decoder.decode(ChannelsConfig.self, from: data) + } + // Was about 110 KB, mostly the ten sections' temporaries in `ChannelsConfig.init(from:)`. + #expect(peak < 64 * 1024, "Decoding a full ChannelsConfig used \(peak) bytes of stack") + } + + @Test + func documentImportStaysShallow() throws { + let json = #"{"session": {"legacyChannelAccountKeys": true}, "gateway": {"port": 18789}, "channels": "# + + Self.channelsJSON + "}" + let document = try OpenClawConfigDocument.decode(Data(json.utf8)) + let peak = try StackUsageProbe.peakBytes(stackSize: 512 * 1024) { + _ = OpenClawConfig(document: document, base: OpenClawConfig(), issues: ConfigDecodeIssueCollector()) + } + // Was about 390 KB of the 512 KB a cooperative thread has. + #expect(peak < 192 * 1024, "Importing a config document used \(peak) bytes of stack") + } +} diff --git a/Tests/OpenClawLinuxRuntimeTests/StackUsageProbe.swift b/Tests/OpenClawLinuxRuntimeTests/StackUsageProbe.swift new file mode 100644 index 0000000..f9e63bb --- /dev/null +++ b/Tests/OpenClawLinuxRuntimeTests/StackUsageProbe.swift @@ -0,0 +1,107 @@ +import Foundation +#if canImport(Glibc) +import Glibc +#elseif canImport(Musl) +import Musl +#elseif canImport(Darwin) +import Darwin +#endif + +/// Measures how much thread stack a synchronous closure uses. +/// +/// The closure runs on a new pthread whose stack this helper maps itself: a guard page at the +/// bottom (so an overflow faults instead of corrupting memory), and every other byte filled with a +/// pattern. After the thread exits, the lowest overwritten byte is the deepest point the stack +/// reached, measured from the closure's entry frame. +enum StackUsageProbe { + private static let pattern: UInt64 = 0xA5A5_A5A5_A5A5_A5A5 + + private final class Context { + let body: @Sendable () -> Void + var entryAddress: UInt = 0 + + init(body: @escaping @Sendable () -> Void) { + self.body = body + } + } + + /// Runs `body` on a thread with a `stackSize`-byte stack and returns the peak bytes it used. + /// - Parameters: + /// - stackSize: Thread stack size; rounded up to whole pages. + /// - body: Synchronous work to measure. + /// - Returns: Peak stack bytes below the thread's entry frame. + static func peakBytes(stackSize: Int, _ body: @escaping @Sendable () -> Void) throws -> Int { + let pageSize = Int(sysconf(Int32(_SC_PAGESIZE))) + let size = (stackSize + pageSize - 1) / pageSize * pageSize + #if os(Linux) + let anonymous = MAP_ANONYMOUS + #else + let anonymous = MAP_ANON + #endif + guard let mapping = mmap(nil, size + pageSize, PROT_READ | PROT_WRITE, MAP_PRIVATE | anonymous, -1, 0), + mapping != MAP_FAILED + else { + throw ProbeError.system("mmap", errno) + } + defer { munmap(mapping, size + pageSize) } + guard mprotect(mapping, pageSize, PROT_NONE) == 0 else { + throw ProbeError.system("mprotect", errno) + } + let stack = mapping + pageSize + let words = stack.bindMemory(to: UInt64.self, capacity: size / 8) + words.initialize(repeating: Self.pattern, count: size / 8) + + var attributes = pthread_attr_t() + pthread_attr_init(&attributes) + defer { pthread_attr_destroy(&attributes) } + var status = pthread_attr_setstack(&attributes, stack, size) + guard status == 0 else { throw ProbeError.system("pthread_attr_setstack", status) } + + let context = Context(body: body) + let unmanaged = Unmanaged.passRetained(context) + #if os(Linux) + var thread = pthread_t() + #else + var thread: pthread_t? + #endif + // The start routine's argument is optional on Linux and non-optional on Darwin. + status = pthread_create(&thread, &attributes, { StackUsageProbe.threadMain($0) }, unmanaged.toOpaque()) + guard status == 0 else { + unmanaged.release() + throw ProbeError.system("pthread_create", status) + } + #if os(Linux) + pthread_join(thread, nil) + #else + pthread_join(thread!, nil) + #endif + unmanaged.release() + + var lowest = 0 + while lowest < size / 8, words[lowest] == Self.pattern { + lowest += 1 + } + let deepest = UInt(bitPattern: stack) + UInt(lowest * 8) + return Int(context.entryAddress) - Int(deepest) + } + + private static func threadMain(_ argument: UnsafeMutableRawPointer?) -> UnsafeMutableRawPointer? { + guard let argument else { return nil } + let context = Unmanaged.fromOpaque(argument).takeUnretainedValue() + var marker: UInt8 = 0 + context.entryAddress = withUnsafeMutablePointer(to: &marker) { UInt(bitPattern: $0) } + context.body() + return nil + } + + enum ProbeError: Error, CustomStringConvertible { + case system(String, Int32) + + var description: String { + switch self { + case .system(let call, let code): + "\(call) failed: \(String(cString: strerror(code)))" + } + } + } +}