Skip to main content

hyperactor/
config.rs

1/*
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 * All rights reserved.
4 *
5 * This source code is licensed under the BSD-style license found in the
6 * LICENSE file in the root directory of this source tree.
7 */
8
9//! Configuration keys and I/O for hyperactor.
10//!
11//! This module declares hyperactor-specific config keys.
12
13use std::io::Cursor;
14use std::io::Read;
15use std::path::PathBuf;
16use std::time::Duration;
17
18use hyperactor_config::AttrValue;
19use hyperactor_config::CONFIG;
20use hyperactor_config::ConfigAttr;
21use hyperactor_config::NonZeroUsize;
22use hyperactor_config::attrs::declare_attrs;
23use serde::Deserialize;
24use serde::Serialize;
25use typeuri::Named;
26
27/// Stores a PEM-encoded value, either specified directly or read from a file.
28#[derive(Clone, Debug, Serialize, Named)]
29#[named("hyperactor::config::Pem")]
30pub enum Pem {
31    /// Raw PEM value stored directly.
32    Value(Vec<u8>),
33    /// Path to a file containing the PEM data.
34    File(PathBuf),
35    /// Static path string (for use in const/static contexts).
36    StaticPath(&'static str),
37}
38
39// Custom Deserialize implementation because StaticPath contains &'static str
40impl<'de> Deserialize<'de> for Pem {
41    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
42    where
43        D: serde::Deserializer<'de>,
44    {
45        #[derive(Deserialize)]
46        enum PemDeserialize {
47            Value(Vec<u8>),
48            File(PathBuf),
49            // StaticPath deserializes as a File since we can't deserialize &'static str
50            StaticPath(String),
51        }
52
53        match PemDeserialize::deserialize(deserializer)? {
54            PemDeserialize::Value(v) => Ok(Pem::Value(v)),
55            PemDeserialize::File(p) => Ok(Pem::File(p)),
56            // Convert StaticPath string to File during deserialization
57            PemDeserialize::StaticPath(s) => Ok(Pem::File(PathBuf::from(s))),
58        }
59    }
60}
61
62impl AttrValue for Pem {
63    fn display(&self) -> String {
64        match self {
65            Pem::Value(data) => String::from_utf8_lossy(data).to_string(),
66            Pem::File(path) => path.display().to_string(),
67            Pem::StaticPath(path) => path.to_string(),
68        }
69    }
70
71    fn parse(value: &str) -> Result<Self, anyhow::Error> {
72        // PEM data starts with "-----BEGIN"
73        if value.trim_start().starts_with("-----BEGIN") {
74            Ok(Pem::Value(value.as_bytes().to_vec()))
75        } else {
76            Ok(Pem::File(PathBuf::from(value)))
77        }
78    }
79}
80
81impl Pem {
82    /// Returns a reader for the PEM data.
83    pub fn reader(&self) -> std::io::Result<Box<dyn Read + '_>> {
84        match self {
85            Pem::Value(data) => Ok(Box::new(Cursor::new(data))),
86            Pem::File(path) => Ok(Box::new(std::fs::File::open(path)?)),
87            Pem::StaticPath(path) => Ok(Box::new(std::fs::File::open(path)?)),
88        }
89    }
90}
91
92/// A bundle of PEM files for TLS configuration: CA certificate, server/client certificate, and private key.
93#[derive(Clone, Debug)]
94pub struct PemBundle {
95    /// The CA certificate used to verify peer certificates.
96    pub ca: Pem,
97    /// The certificate to present to peers.
98    pub cert: Pem,
99    /// The private key for the certificate.
100    pub key: Pem,
101}
102
103// Declare hyperactor-specific configuration keys
104declare_attrs! {
105    /// Maximum frame length for codec
106    @meta(CONFIG = ConfigAttr::new(
107        Some("HYPERACTOR_CODEC_MAX_FRAME_LENGTH".to_string()),
108        Some("codec_max_frame_length".to_string()),
109    ))
110    pub attr CODEC_MAX_FRAME_LENGTH: usize = 10 * 1024 * 1024 * 1024; // 10 GiB
111
112    /// Message delivery timeout
113    @meta(CONFIG = ConfigAttr::new(
114        Some("HYPERACTOR_MESSAGE_DELIVERY_TIMEOUT".to_string()),
115        Some("message_delivery_timeout".to_string()),
116    ))
117    pub attr MESSAGE_DELIVERY_TIMEOUT: Duration = Duration::from_secs(30);
118
119    /// How long a proc buffers messages for an actor that has not yet
120    /// bound its mailbox before returning them as undeliverable. Zero
121    /// returns them immediately.
122    @meta(CONFIG = ConfigAttr::new(
123        Some("HYPERACTOR_PENDING_ACTOR_DELIVERY_TIMEOUT".to_string()),
124        Some("pending_actor_delivery_timeout".to_string()),
125    ))
126    pub attr PENDING_ACTOR_DELIVERY_TIMEOUT: Duration = Duration::from_secs(30);
127
128    /// Maximum number of terminated actor snapshots retained per
129    /// proc for post-mortem introspection. When the limit is
130    /// exceeded, the oldest entries are evicted.
131    @meta(CONFIG = ConfigAttr::new(
132        Some("HYPERACTOR_TERMINATED_SNAPSHOT_RETENTION".to_string()),
133        Some("terminated_snapshot_retention".to_string()),
134    ))
135    pub attr TERMINATED_SNAPSHOT_RETENTION: usize = 100;
136
137    /// Timeout to stop a proc.
138    @meta(CONFIG = ConfigAttr::new(
139        Some("HYPERACTOR_PROCESS_EXIT_TIMEOUT".to_string()),
140        Some("process_exit_timeout".to_string()),
141    ))
142    pub attr PROCESS_EXIT_TIMEOUT: Duration = Duration::from_secs(10);
143
144    /// Message acknowledgment interval
145    @meta(CONFIG = ConfigAttr::new(
146        Some("HYPERACTOR_MESSAGE_ACK_TIME_INTERVAL".to_string()),
147        Some("message_ack_time_interval".to_string()),
148    ))
149    pub attr MESSAGE_ACK_TIME_INTERVAL: Duration = Duration::from_millis(500);
150
151    /// Number of messages after which to send an acknowledgment
152    @meta(CONFIG = ConfigAttr::new(
153        Some("HYPERACTOR_MESSAGE_ACK_EVERY_N_MESSAGES".to_string()),
154        Some("message_ack_every_n_messages".to_string()),
155    ))
156    pub attr MESSAGE_ACK_EVERY_N_MESSAGES: u64 = 1000;
157
158    /// Default hop Time-To-Live for message envelopes.
159    @meta(CONFIG = ConfigAttr::new(
160        Some("HYPERACTOR_MESSAGE_TTL_DEFAULT".to_string()),
161        Some("message_ttl_default".to_string()),
162    ))
163    pub attr MESSAGE_TTL_DEFAULT : u8 = 64;
164
165    /// Maximum buffer size for split port messages
166    @meta(CONFIG = ConfigAttr::new(
167        Some("HYPERACTOR_SPLIT_MAX_BUFFER_SIZE".to_string()),
168        Some("split_max_buffer_size".to_string()),
169    ))
170    pub attr SPLIT_MAX_BUFFER_SIZE: usize = 5;
171
172    /// The maximum time an update can be buffered before being reduced.
173    @meta(CONFIG = ConfigAttr::new(
174        Some("HYPERACTOR_SPLIT_MAX_BUFFER_AGE".to_string()),
175        Some("split_max_buffer_age".to_string()),
176    ))
177    pub attr SPLIT_MAX_BUFFER_AGE: Duration = Duration::from_millis(50);
178
179    /// Timeout used by proc mesh for stopping an actor.
180    @meta(CONFIG = ConfigAttr::new(
181        Some("HYPERACTOR_STOP_ACTOR_TIMEOUT".to_string()),
182        Some("stop_actor_timeout".to_string()),
183    ))
184    pub attr STOP_ACTOR_TIMEOUT: Duration = Duration::from_secs(10);
185
186    /// Timeout used by proc for running the cleanup callback on an actor.
187    /// Should be less than the timeout for STOP_ACTOR_TIMEOUT.
188    @meta(CONFIG = ConfigAttr::new(
189        Some("HYPERACTOR_CLEANUP_TIMEOUT".to_string()),
190        Some("cleanup_timeout".to_string()),
191    ))
192    pub attr CLEANUP_TIMEOUT: Duration = Duration::from_secs(3);
193
194    /// How often to check for full MPSC channel on NetRx.
195    @meta(CONFIG = ConfigAttr::new(
196        Some("HYPERACTOR_CHANNEL_NET_RX_BUFFER_FULL_CHECK_INTERVAL".to_string()),
197        Some("channel_net_rx_buffer_full_check_interval".to_string()),
198    ))
199    pub attr CHANNEL_NET_RX_BUFFER_FULL_CHECK_INTERVAL: Duration = Duration::from_secs(5);
200
201    /// Kernel TCP keepalive idle period: the gap from last activity
202    /// until the kernel sends its first probe on connections created
203    /// by hyperactor's channel layer. On a healthy idle connection
204    /// this is also the probe cadence (each ACK resets the timer).
205    /// Total peer-death detection time is `idle + probe_budget` where
206    /// `probe_budget = TCP_KEEPALIVE_INTERVAL * TCP_KEEPALIVE_RETRIES`
207    /// (private constants in `channel::net`, currently 15s). Larger
208    /// values reduce keepalive ACK traffic at the cost of slower
209    /// peer-death detection.
210    @meta(CONFIG = ConfigAttr::new(
211        Some("HYPERACTOR_CHANNEL_TCP_KEEPALIVE_IDLE".to_string()),
212        Some("channel_tcp_keepalive_idle".to_string()),
213    ))
214    pub attr CHANNEL_TCP_KEEPALIVE_IDLE: Duration = Duration::from_secs(60);
215
216    /// TCP congestion-control algorithm applied to channel sockets after
217    /// connect/accept (e.g. `bbr`). Empty (the default) leaves the host
218    /// default (`cubic`) in place, so this is a no-op for the overwhelming
219    /// majority of channels; only bandwidth-bound long-haul paths (e.g.
220    /// remotemount's client->leader block ship) opt in. `cubic` underfills a
221    /// link with a high bandwidth-delay product; `bbr` recovers that
222    /// bandwidth. Set via env `HYPERACTOR_CHANNEL_TCP_CONGESTION` or
223    /// `configure(channel_tcp_congestion="bbr")`.
224    ///
225    /// PROCESS-LOCAL, deliberately: the right controller is a property of the
226    /// LINK, not of the mesh, and the procs in one mesh do not share a link
227    /// profile. A client shipping across a DC has a high bandwidth-delay
228    /// product and wants `bbr`; the intra-cluster hops between workers do not,
229    /// and `bbr` measurably hurts them. Propagating one value to every proc
230    /// forces a single answer on links that want different ones -- measured on
231    /// MAST at 256 hosts, cold `import torch`:
232    ///
233    /// ```text
234    ///     bbr on client + relays    39.0s
235    ///     cubic everywhere          31.2s
236    ///     bbr on client only        19.0s
237    /// ```
238    ///
239    /// A proc that genuinely wants a non-default controller sets the env var,
240    /// which is per-proc and still resolves above everything else.
241
242    @meta(CONFIG = ConfigAttr::new(
243        Some("HYPERACTOR_CHANNEL_TCP_CONGESTION".to_string()),
244        Some("channel_tcp_congestion".to_string()),
245    ).process_local())
246    pub attr CHANNEL_TCP_CONGESTION: String = String::new();
247
248    /// Maximum time `Link::next()` spends retrying a failed connect
249    /// before giving up. Pairs with TCP keepalive: keepalive surfaces
250    /// peer death as an I/O error, then the connect-retry loop quits
251    /// after this bound and the outer NetTx loop terminates the link
252    /// with `TxStatus::Closed`.
253    @meta(CONFIG = ConfigAttr::new(
254        Some("HYPERACTOR_CHANNEL_RECONNECT_TIMEOUT".to_string()),
255        Some("channel_reconnect_timeout".to_string()),
256    ))
257    pub attr CHANNEL_RECONNECT_TIMEOUT: Duration = Duration::from_secs(60);
258
259    /// Sampling rate for logging message latency
260    /// Set to 0.01 for 1% sampling, 0.1 for 10% sampling, 0.90 for 90% sampling, etc.
261    @meta(CONFIG = ConfigAttr::new(
262        Some("HYPERACTOR_MESSAGE_LATENCY_SAMPLING_RATE".to_string()),
263        Some("message_latency_sampling_rate".to_string()),
264    ))
265    pub attr MESSAGE_LATENCY_SAMPLING_RATE: f32 = 0.01;
266
267    /// Whether to enable dest actor reordering buffer.
268    @meta(CONFIG = ConfigAttr::new(
269        Some("HYPERACTOR_ENABLE_DEST_ACTOR_REORDERING_BUFFER".to_string()),
270        Some("enable_dest_actor_reordering_buffer".to_string()),
271    ))
272    pub attr ENABLE_DEST_ACTOR_REORDERING_BUFFER: bool = true;
273
274    /// Maximum number of destination keys assigned while holding the
275    /// sequencer lock.
276    @meta(CONFIG = ConfigAttr::new(
277        Some("HYPERACTOR_SEQUENCER_MAX_LOCKED_KEYS".to_string()),
278        Some("sequencer_max_locked_keys".to_string()),
279    ))
280    pub attr SEQUENCER_MAX_LOCKED_KEYS: NonZeroUsize =
281        NonZeroUsize::new(128).expect("128 is non-zero");
282
283    /// Timeout for [`Host::spawn`] to await proc readiness.
284    ///
285    /// Default: 30 seconds. If set to zero, disables the timeout and
286    /// waits indefinitely.
287    @meta(CONFIG = ConfigAttr::new(
288        Some("HYPERACTOR_HOST_SPAWN_READY_TIMEOUT".to_string()),
289        Some("host_spawn_ready_timeout".to_string()),
290    ))
291    pub attr HOST_SPAWN_READY_TIMEOUT: Duration = Duration::from_secs(30);
292
293    /// Heartbeat interval for server health metrics. The server emits a
294    /// heartbeat metric at this interval to indicate it is alive.
295    @meta(CONFIG = ConfigAttr::new(
296        Some("HYPERACTOR_SERVER_HEARTBEAT_INTERVAL".to_string()),
297        Some("server_heartbeat_interval".to_string()),
298    ))
299    pub attr SERVER_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(1);
300
301    /// Timeout for best-effort forwarder flush during proc/actor
302    /// teardown. If the remote side has already torn down its
303    /// networking, acks may never arrive; this timeout prevents the
304    /// flush from hanging indefinitely.
305    @meta(CONFIG = ConfigAttr::new(
306        Some("HYPERACTOR_FORWARDER_FLUSH_TIMEOUT".to_string()),
307        Some("forwarder_flush_timeout".to_string()),
308    ))
309    pub attr FORWARDER_FLUSH_TIMEOUT: Duration = Duration::from_secs(5);
310
311    /// Path to TLS certificate file for the 'tls' transport.
312    @meta(CONFIG = ConfigAttr::new(
313        Some("HYPERACTOR_TLS_CERT".to_string()),
314        Some("hyperactor_tls_cert".to_string()),
315    ).process_local())
316    pub attr TLS_CERT: Pem = Pem::StaticPath("/etc/hyperactor/tls/tls.crt");
317
318    /// Path to TLS private key file for the 'tls' transport.
319    @meta(CONFIG = ConfigAttr::new(
320        Some("HYPERACTOR_TLS_KEY".to_string()),
321        Some("hyperactor_tls_key".to_string()),
322    ).process_local())
323    pub attr TLS_KEY: Pem = Pem::StaticPath("/etc/hyperactor/tls/tls.key");
324
325    /// Path to TLS CA certificate file for the 'tls' transport.
326    @meta(CONFIG = ConfigAttr::new(
327        Some("HYPERACTOR_TLS_CA".to_string()),
328        Some("hyperactor_tls_ca".to_string()),
329    ).process_local())
330    pub attr TLS_CA: Pem = Pem::StaticPath("/etc/hyperactor/tls/ca.crt");
331}
332
333#[cfg(test)]
334mod tests {
335    #[test]
336    fn channel_tcp_congestion_is_process_local() {
337        let meta = CHANNEL_TCP_CONGESTION
338            .attrs()
339            .get(CONFIG)
340            .expect("CHANNEL_TCP_CONGESTION should carry CONFIG meta");
341        assert!(
342            !meta.propagate,
343            "CHANNEL_TCP_CONGESTION must not propagate to child procs; \
344             see the attr docs for the measurement"
345        );
346    }
347
348    use std::collections::HashSet;
349
350    use hyperactor_config::Attrs;
351    use hyperactor_config::from_env;
352    use indoc::indoc;
353
354    use super::*;
355
356    /// Like the `logs_assert` injected by `#[traced_test]`, but without scope
357    /// filtering. Use when asserting on events emitted outside the test's span
358    /// (e.g. from spawned tasks or panic hooks).
359    fn logs_assert_unscoped(f: impl Fn(&[&str]) -> Result<(), String>) {
360        let buf = tracing_test::internal::global_buf().lock().unwrap();
361        let logs_str = std::str::from_utf8(&buf).expect("Logs contain invalid UTF8");
362        let lines: Vec<&str> = logs_str.lines().collect();
363        match f(&lines) {
364            Ok(()) => {}
365            Err(msg) => panic!("{}", msg),
366        }
367    }
368
369    const CODEC_MAX_FRAME_LENGTH_DEFAULT: usize = 10 * 1024 * 1024 * 1024;
370
371    #[test]
372    fn test_default_config() {
373        let config = Attrs::new();
374        assert_eq!(
375            config[CODEC_MAX_FRAME_LENGTH],
376            CODEC_MAX_FRAME_LENGTH_DEFAULT
377        );
378        assert_eq!(config[MESSAGE_DELIVERY_TIMEOUT], Duration::from_secs(30));
379        assert_eq!(
380            config[MESSAGE_ACK_TIME_INTERVAL],
381            Duration::from_millis(500)
382        );
383        assert_eq!(config[MESSAGE_ACK_EVERY_N_MESSAGES], 1000);
384        assert_eq!(config[SPLIT_MAX_BUFFER_SIZE], 5);
385        assert_eq!(config[SEQUENCER_MAX_LOCKED_KEYS].get(), 128);
386    }
387
388    #[tracing_test::traced_test]
389    #[test]
390    // TODO: OSS: The logs_assert function returned an error: missing log lines: {"# export HYPERACTOR_DEFAULT_ENCODING=serde_multipart", ...}
391    #[cfg_attr(not(fbcode_build), ignore)]
392    fn test_from_env() {
393        // Set environment variables
394        // SAFETY: TODO: Audit that the environment access only happens in single-threaded code.
395        unsafe { std::env::set_var("HYPERACTOR_CODEC_MAX_FRAME_LENGTH", "1024") };
396        // SAFETY: TODO: Audit that the environment access only happens in single-threaded code.
397        unsafe { std::env::set_var("HYPERACTOR_MESSAGE_DELIVERY_TIMEOUT", "60s") };
398
399        let config = from_env();
400
401        assert_eq!(config[CODEC_MAX_FRAME_LENGTH], 1024);
402        assert_eq!(config[MESSAGE_DELIVERY_TIMEOUT], Duration::from_mins(1));
403        assert_eq!(
404            config[MESSAGE_ACK_TIME_INTERVAL],
405            Duration::from_millis(500)
406        ); // Default value
407
408        let expected_lines: HashSet<&str> = indoc! {"
409            # export HYPERACTOR_MESSAGE_LATENCY_SAMPLING_RATE=0.01
410            # export HYPERACTOR_CHANNEL_NET_RX_BUFFER_FULL_CHECK_INTERVAL=5s
411            # export HYPERACTOR_STOP_ACTOR_TIMEOUT=10s
412            # export HYPERACTOR_SPLIT_MAX_BUFFER_SIZE=5
413            # export HYPERACTOR_MESSAGE_TTL_DEFAULT=64
414            # export HYPERACTOR_MESSAGE_ACK_EVERY_N_MESSAGES=1000
415            # export HYPERACTOR_MESSAGE_ACK_TIME_INTERVAL=500ms
416            # export HYPERACTOR_PROCESS_EXIT_TIMEOUT=10s
417            # export HYPERACTOR_MESSAGE_DELIVERY_TIMEOUT=30s
418            export HYPERACTOR_MESSAGE_DELIVERY_TIMEOUT=1m
419            # export HYPERACTOR_CODEC_MAX_FRAME_LENGTH=10737418240
420            export HYPERACTOR_CODEC_MAX_FRAME_LENGTH=1024
421            # export HYPERACTOR_CLEANUP_TIMEOUT=3s
422            # export HYPERACTOR_SPLIT_MAX_BUFFER_AGE=50ms
423            # export HYPERACTOR_DEFAULT_ENCODING=serde_multipart
424            # export HYPERACTOR_HOST_SPAWN_READY_TIMEOUT=30s
425        "}
426        .trim_end()
427        .lines()
428        .collect();
429
430        // For some reason, logs_contaqin fails to find these lines individually
431        // (possibly to do with the fact that we have newlines in our log entries);
432        // instead, we test it manually.
433        logs_assert_unscoped(|logged_lines: &[&str]| {
434            let mut expected_lines = expected_lines.clone(); // this is an `Fn` closure
435            for logged in logged_lines {
436                expected_lines.remove(logged);
437            }
438
439            if expected_lines.is_empty() {
440                Ok(())
441            } else {
442                Err(format!("missing log lines: {:?}", expected_lines))
443            }
444        });
445
446        // Clean up
447        // SAFETY: TODO: Audit that the environment access only happens in single-threaded code.
448        unsafe { std::env::remove_var("HYPERACTOR_CODEC_MAX_FRAME_LENGTH") };
449        // SAFETY: TODO: Audit that the environment access only happens in single-threaded code.
450        unsafe { std::env::remove_var("HYPERACTOR_MESSAGE_DELIVERY_TIMEOUT_SECS") };
451    }
452
453    #[test]
454    fn test_defaults() {
455        // Test that empty config now returns defaults via get_or_default
456        let config = Attrs::new();
457
458        // Verify that the config is empty (no values explicitly set)
459        assert!(config.is_empty());
460
461        // But getters should still return the defaults from the keys
462        assert_eq!(
463            config[CODEC_MAX_FRAME_LENGTH],
464            CODEC_MAX_FRAME_LENGTH_DEFAULT
465        );
466        assert_eq!(config[MESSAGE_DELIVERY_TIMEOUT], Duration::from_secs(30));
467        assert_eq!(
468            config[MESSAGE_ACK_TIME_INTERVAL],
469            Duration::from_millis(500)
470        );
471        assert_eq!(config[MESSAGE_ACK_EVERY_N_MESSAGES], 1000);
472        assert_eq!(config[SPLIT_MAX_BUFFER_SIZE], 5);
473        assert_eq!(config[SEQUENCER_MAX_LOCKED_KEYS].get(), 128);
474
475        // Verify the keys have defaults
476        assert!(CODEC_MAX_FRAME_LENGTH.has_default());
477        assert!(MESSAGE_DELIVERY_TIMEOUT.has_default());
478        assert!(MESSAGE_ACK_TIME_INTERVAL.has_default());
479        assert!(MESSAGE_ACK_EVERY_N_MESSAGES.has_default());
480        assert!(SPLIT_MAX_BUFFER_SIZE.has_default());
481        assert!(SEQUENCER_MAX_LOCKED_KEYS.has_default());
482
483        // Verify we can get defaults directly from keys
484        assert_eq!(
485            CODEC_MAX_FRAME_LENGTH.default(),
486            Some(&(CODEC_MAX_FRAME_LENGTH_DEFAULT))
487        );
488        assert_eq!(
489            MESSAGE_DELIVERY_TIMEOUT.default(),
490            Some(&Duration::from_secs(30))
491        );
492        assert_eq!(
493            MESSAGE_ACK_TIME_INTERVAL.default(),
494            Some(&Duration::from_millis(500))
495        );
496        assert_eq!(MESSAGE_ACK_EVERY_N_MESSAGES.default(), Some(&1000));
497        assert_eq!(SPLIT_MAX_BUFFER_SIZE.default(), Some(&5));
498        assert_eq!(
499            SEQUENCER_MAX_LOCKED_KEYS.default().map(|v| v.get()),
500            Some(128)
501        );
502    }
503
504    #[test]
505    fn test_serialization_only_includes_set_values() {
506        let mut config = Attrs::new();
507
508        // Initially empty, serialization should be empty
509        let serialized = serde_json::to_string(&config).unwrap();
510        assert_eq!(serialized, "{}");
511
512        config[CODEC_MAX_FRAME_LENGTH] = 1024;
513
514        let serialized = serde_json::to_string(&config).unwrap();
515        assert!(serialized.contains("codec_max_frame_length"));
516        assert!(!serialized.contains("message_delivery_timeout")); // Default not serialized
517
518        // Deserialize back
519        let restored_config: Attrs = serde_json::from_str(&serialized).unwrap();
520
521        // Custom value should be preserved
522        assert_eq!(restored_config[CODEC_MAX_FRAME_LENGTH], 1024);
523
524        // Defaults should still work for other values
525        assert_eq!(
526            restored_config[MESSAGE_DELIVERY_TIMEOUT],
527            Duration::from_secs(30)
528        );
529    }
530}