@@ -40,6 +40,76 @@ fn env_bool(key: &str) -> Option<bool> {
4040 } )
4141}
4242
43+ /// Parse a human-readable memory size string into bytes.
44+ ///
45+ /// Accepts integers (bytes) or values with `k`/`m`/`g`/`t` suffixes
46+ /// (case-insensitive, with or without a trailing `b`). Binary units
47+ /// (`ki`/`mi`/`gi`/`ti`) are also accepted. Examples: `80g`, `4096m`,
48+ /// `0.5g`, `1073741824`.
49+ ///
50+ /// Returns an error if the value is empty, uses an unknown suffix, overflows
51+ /// `i64`, or is below the 4 MiB minimum required by Docker.
52+ pub fn parse_memory_limit ( s : & str ) -> Result < i64 > {
53+ let s = s. trim ( ) . to_ascii_lowercase ( ) ;
54+ if s. is_empty ( ) {
55+ miette:: bail!( "empty memory limit string" ) ;
56+ }
57+
58+ // Split into numeric part and optional suffix.
59+ let ( num_str, suffix) = match s. find ( |c : char | !c. is_ascii_digit ( ) && c != '.' ) {
60+ Some ( idx) => ( & s[ ..idx] , s[ idx..] . trim_end_matches ( 'b' ) ) ,
61+ None => ( s. as_str ( ) , "" ) ,
62+ } ;
63+
64+ let value: f64 = num_str
65+ . parse ( )
66+ . into_diagnostic ( )
67+ . wrap_err_with ( || format ! ( "invalid numeric part in memory limit: {num_str}" ) ) ?;
68+
69+ let multiplier: f64 = match suffix {
70+ "" => 1.0 ,
71+ "k" | "ki" => 1024.0 ,
72+ "m" | "mi" => 1024.0 * 1024.0 ,
73+ "g" | "gi" => 1024.0 * 1024.0 * 1024.0 ,
74+ "t" | "ti" => 1024.0 * 1024.0 * 1024.0 * 1024.0 ,
75+ other => miette:: bail!( "unknown memory suffix: {other}" ) ,
76+ } ;
77+
78+ let raw = value * multiplier;
79+ if raw > i64:: MAX as f64 {
80+ miette:: bail!( "memory limit too large (exceeds i64::MAX): {s}" ) ;
81+ }
82+ #[ allow( clippy:: cast_possible_truncation) ]
83+ let bytes = raw as i64 ;
84+
85+ // Docker requires at least ~6 MiB; enforce a 4 MiB floor so users get a
86+ // clear error instead of an opaque Docker API rejection.
87+ const MIN_MEMORY_BYTES : i64 = 4 * 1024 * 1024 ;
88+ if bytes < MIN_MEMORY_BYTES {
89+ miette:: bail!( "memory limit must be at least 4 MiB, got: {s} ({bytes} bytes)" ) ;
90+ }
91+ Ok ( bytes)
92+ }
93+
94+ /// Detect a safe memory limit for the gateway container.
95+ ///
96+ /// Queries the Docker daemon for `MemTotal` (via `docker info`) and returns
97+ /// 80% of that value. On macOS and Windows the daemon runs inside a Linux VM
98+ /// (Docker Desktop, colima, WSL2), so the reported total reflects the VM's
99+ /// allocated memory rather than the full host RAM.
100+ ///
101+ /// Returns `None` if the daemon does not report memory information.
102+ pub async fn detect_memory_limit ( docker : & Docker ) -> Option < i64 > {
103+ let info = docker. info ( ) . await . ok ( ) ?;
104+ let total_bytes = info. mem_total ?;
105+ if total_bytes <= 0 {
106+ return None ;
107+ }
108+ #[ allow( clippy:: cast_possible_truncation) ]
109+ let limit = ( total_bytes as f64 * 0.8 ) as i64 ;
110+ Some ( limit)
111+ }
112+
43113/// Platform information for a Docker daemon host.
44114#[ derive( Debug , Clone ) ]
45115pub struct HostPlatform {
@@ -512,6 +582,7 @@ pub async fn ensure_container(
512582 registry_token : Option < & str > ,
513583 gpu : bool ,
514584 is_remote : bool ,
585+ memory_limit : Option < i64 > ,
515586) -> Result < ( ) > {
516587 let container_name = container_name ( name) ;
517588
@@ -616,6 +687,15 @@ pub async fn ensure_container(
616687 } ] ) ;
617688 }
618689
690+ // Apply memory limit. When set, Docker OOM-kills the container instead of
691+ // letting unchecked sandbox growth trigger the host kernel OOM killer.
692+ // Setting memory_swap equal to memory disables swap inside the container.
693+ if let Some ( mem) = memory_limit {
694+ host_config. memory = Some ( mem) ;
695+ host_config. memory_swap = Some ( mem) ;
696+ tracing:: info!( "Container memory limit: {} MiB" , mem / ( 1024 * 1024 ) , ) ;
697+ }
698+
619699 let mut cmd = vec ! [
620700 "server" . to_string( ) ,
621701 "--disable=traefik" . to_string( ) ,
@@ -1352,4 +1432,77 @@ mod tests {
13521432 let input = "nameserver 8.8.8.8\r \n nameserver 1.1.1.1\r \n " ;
13531433 assert_eq ! ( parse_resolv_conf( input) , vec![ "8.8.8.8" , "1.1.1.1" ] ) ;
13541434 }
1435+
1436+ #[ test]
1437+ fn parse_memory_limit_gigabytes ( ) {
1438+ assert_eq ! ( parse_memory_limit( "80g" ) . unwrap( ) , 80 * 1024 * 1024 * 1024 ) ;
1439+ assert_eq ! ( parse_memory_limit( "80G" ) . unwrap( ) , 80 * 1024 * 1024 * 1024 ) ;
1440+ assert_eq ! ( parse_memory_limit( "80gb" ) . unwrap( ) , 80 * 1024 * 1024 * 1024 ) ;
1441+ }
1442+
1443+ #[ test]
1444+ fn parse_memory_limit_megabytes ( ) {
1445+ assert_eq ! ( parse_memory_limit( "4096m" ) . unwrap( ) , 4096 * 1024 * 1024 ) ;
1446+ assert_eq ! ( parse_memory_limit( "4096M" ) . unwrap( ) , 4096 * 1024 * 1024 ) ;
1447+ }
1448+
1449+ #[ test]
1450+ fn parse_memory_limit_bare_bytes ( ) {
1451+ assert_eq ! ( parse_memory_limit( "1073741824" ) . unwrap( ) , 1073741824 ) ;
1452+ }
1453+
1454+ #[ test]
1455+ fn parse_memory_limit_binary_suffixes ( ) {
1456+ assert_eq ! ( parse_memory_limit( "1gi" ) . unwrap( ) , 1024 * 1024 * 1024 ) ;
1457+ assert_eq ! ( parse_memory_limit( "1gib" ) . unwrap( ) , 1024 * 1024 * 1024 ) ;
1458+ }
1459+
1460+ #[ test]
1461+ fn parse_memory_limit_rejects_empty ( ) {
1462+ assert ! ( parse_memory_limit( "" ) . is_err( ) ) ;
1463+ }
1464+
1465+ #[ test]
1466+ fn parse_memory_limit_rejects_unknown_suffix ( ) {
1467+ assert ! ( parse_memory_limit( "10x" ) . is_err( ) ) ;
1468+ }
1469+
1470+ #[ test]
1471+ fn parse_memory_limit_fractional ( ) {
1472+ // 0.5g = 512 MiB
1473+ assert_eq ! ( parse_memory_limit( "0.5g" ) . unwrap( ) , 512 * 1024 * 1024 ) ;
1474+ }
1475+
1476+ #[ test]
1477+ fn parse_memory_limit_rejects_zero ( ) {
1478+ assert ! ( parse_memory_limit( "0g" ) . is_err( ) ) ;
1479+ }
1480+
1481+ #[ test]
1482+ fn parse_memory_limit_rejects_negative ( ) {
1483+ assert ! ( parse_memory_limit( "-1g" ) . is_err( ) ) ;
1484+ }
1485+
1486+ #[ test]
1487+ fn parse_memory_limit_rejects_below_minimum ( ) {
1488+ // 1 KiB is well below the 4 MiB floor
1489+ assert ! ( parse_memory_limit( "1k" ) . is_err( ) ) ;
1490+ }
1491+
1492+ #[ test]
1493+ fn parse_memory_limit_rejects_overflow ( ) {
1494+ // 99999999t exceeds i64::MAX (~9.2 exabytes)
1495+ assert ! ( parse_memory_limit( "99999999t" ) . is_err( ) ) ;
1496+ }
1497+
1498+ #[ test]
1499+ fn parse_memory_limit_whitespace ( ) {
1500+ assert_eq ! (
1501+ parse_memory_limit( " 80g " ) . unwrap( ) ,
1502+ 80 * 1024 * 1024 * 1024
1503+ ) ;
1504+ }
1505+
1506+ // detect_memory_limit is async and requires a Docker daemon connection,
1507+ // so it is tested via integration / e2e tests rather than unit tests.
13551508}
0 commit comments