@@ -154,6 +154,8 @@ var allTests = []func(t *testing.T, sb integration.Sandbox){
154154 testShmSize ,
155155 testUlimit ,
156156 testCgroupParent ,
157+ testLinuxResources ,
158+ testLinuxResourcesMergeOnDedup ,
157159 testNetworkMode ,
158160 testFrontendMetadataReturn ,
159161 testFrontendUseSolveResults ,
@@ -1242,6 +1244,131 @@ func testCgroupParent(t *testing.T, sb integration.Sandbox) {
12421244 require .Equal (t , "" , strings .TrimSpace (string (dt )))
12431245}
12441246
1247+ func testLinuxResources (t * testing.T , sb integration.Sandbox ) {
1248+ integration .SkipOnPlatform (t , "windows" )
1249+ if sb .Rootless () {
1250+ t .SkipNow ()
1251+ }
1252+
1253+ if _ , err := os .Lstat ("/sys/fs/cgroup/cgroup.subtree_control" ); os .IsNotExist (err ) {
1254+ t .Skipf ("test requires cgroup v2" )
1255+ }
1256+
1257+ c , err := New (sb .Context (), sb .Address ())
1258+ require .NoError (t , err )
1259+ defer c .Close ()
1260+
1261+ img := llb .Image ("alpine:latest" )
1262+ st := llb .Scratch ()
1263+
1264+ run := func (cmd string , ro ... llb.RunOption ) {
1265+ st = img .Run (append (ro , llb .Shlex (cmd ), llb .Dir ("/wd" ))... ).AddMount ("/wd" , st )
1266+ }
1267+
1268+ // Test memory limit: set 64MiB and verify via cgroup
1269+ run (`sh -c "cat /sys/fs/cgroup/memory.max > mem_limited"` , llb .MemoryLimit (64 * 1024 * 1024 ))
1270+ run (`sh -c "cat /sys/fs/cgroup/memory.max > mem_default"` )
1271+
1272+ // Test CPU quota: set quota=50000 period=100000 (50% CPU) and verify
1273+ run (`sh -c "cat /sys/fs/cgroup/cpu.max > cpu_limited"` , llb .CPUQuota (50000 ), llb .CPUPeriod (100000 ))
1274+
1275+ def , err := st .Marshal (sb .Context ())
1276+ require .NoError (t , err )
1277+
1278+ destDir := t .TempDir ()
1279+
1280+ _ , err = c .Solve (sb .Context (), def , SolveOpt {
1281+ Exports : []ExportEntry {
1282+ {
1283+ Type : ExporterLocal ,
1284+ OutputDir : destDir ,
1285+ },
1286+ },
1287+ }, nil )
1288+ require .NoError (t , err )
1289+
1290+ dt , err := os .ReadFile (filepath .Join (destDir , "mem_limited" ))
1291+ require .NoError (t , err )
1292+ require .Equal (t , "67108864" , strings .TrimSpace (string (dt )))
1293+
1294+ dt2 , err := os .ReadFile (filepath .Join (destDir , "mem_default" ))
1295+ require .NoError (t , err )
1296+ require .Equal (t , "max" , strings .TrimSpace (string (dt2 )))
1297+
1298+ dt3 , err := os .ReadFile (filepath .Join (destDir , "cpu_limited" ))
1299+ require .NoError (t , err )
1300+ require .Equal (t , "50000 100000" , strings .TrimSpace (string (dt3 )))
1301+ }
1302+
1303+ // testLinuxResourcesMergeOnDedup verifies that when two concurrent builds share
1304+ // the same RUN step but specify different resource limits, the most relaxed
1305+ // (least restrictive) limit is applied.
1306+ func testLinuxResourcesMergeOnDedup (t * testing.T , sb integration.Sandbox ) {
1307+ integration .SkipOnPlatform (t , "windows" )
1308+ if sb .Rootless () {
1309+ t .SkipNow ()
1310+ }
1311+
1312+ if _ , err := os .Lstat ("/sys/fs/cgroup/cgroup.subtree_control" ); os .IsNotExist (err ) {
1313+ t .Skipf ("test requires cgroup v2" )
1314+ }
1315+
1316+ c , err := New (sb .Context (), sb .Address ())
1317+ require .NoError (t , err )
1318+ defer c .Close ()
1319+
1320+ // Both builds share the exact same RUN command so the solver deduplicates
1321+ // them into one vertex. They differ only in memory limits (OpMetadata).
1322+ // Dedup is guaranteed because loadUnlocked (which merges resources) loads
1323+ // the full vertex graph in microseconds, while image resolution that must
1324+ // complete before the RUN vertex can execute takes orders of magnitude longer.
1325+ // Both goroutines will have loaded and merged before the RUN step starts.
1326+ sharedCmd := `sh -c "cat /sys/fs/cgroup/memory.max > /wd/mem_limit"`
1327+
1328+ // Build 1: 64 MiB memory limit
1329+ st1 := llb .Image ("alpine:latest" ).
1330+ Run (llb .Shlex (sharedCmd ), llb .MemoryLimit (64 * 1024 * 1024 ), llb .Dir ("/wd" )).
1331+ AddMount ("/wd" , llb .Scratch ())
1332+ def1 , err := st1 .Marshal (sb .Context ())
1333+ require .NoError (t , err )
1334+
1335+ // Build 2: 128 MiB memory limit (more relaxed — should win)
1336+ st2 := llb .Image ("alpine:latest" ).
1337+ Run (llb .Shlex (sharedCmd ), llb .MemoryLimit (128 * 1024 * 1024 ), llb .Dir ("/wd" )).
1338+ AddMount ("/wd" , llb .Scratch ())
1339+ def2 , err := st2 .Marshal (sb .Context ())
1340+ require .NoError (t , err )
1341+
1342+ destDir1 := t .TempDir ()
1343+ destDir2 := t .TempDir ()
1344+
1345+ eg , egCtx := errgroup .WithContext (sb .Context ())
1346+ eg .Go (func () error {
1347+ _ , err := c .Solve (egCtx , def1 , SolveOpt {
1348+ Exports : []ExportEntry {{Type : ExporterLocal , OutputDir : destDir1 }},
1349+ }, nil )
1350+ return err
1351+ })
1352+ eg .Go (func () error {
1353+ _ , err := c .Solve (egCtx , def2 , SolveOpt {
1354+ Exports : []ExportEntry {{Type : ExporterLocal , OutputDir : destDir2 }},
1355+ }, nil )
1356+ return err
1357+ })
1358+ err = eg .Wait ()
1359+ require .NoError (t , err )
1360+
1361+ // Both builds share the deduplicated vertex, so both outputs come from
1362+ // the same container. The memory limit should be 128 MiB (most relaxed).
1363+ for _ , dir := range []string {destDir1 , destDir2 } {
1364+ dt , err := os .ReadFile (filepath .Join (dir , "mem_limit" ))
1365+ require .NoError (t , err )
1366+ memLimit := strings .TrimSpace (string (dt ))
1367+ require .Equal (t , "134217728" , memLimit ,
1368+ "expected 128 MiB (most relaxed limit) but got %s in %s" , memLimit , dir )
1369+ }
1370+ }
1371+
12451372func testNetworkMode (t * testing.T , sb integration.Sandbox ) {
12461373 integration .SkipOnPlatform (t , "windows" )
12471374 c , err := New (sb .Context (), sb .Address ())
0 commit comments