Files
swift-nio/Tests/NIOCoreTests/LinuxTest.swift
e3d5c560e0 Fix coreCount on Linux when using cgroup v2 with CFS throttling disabled (#3462)
Fix coreCount on Linux when using cgroup v2 with CFS throttling disabled

### Motivation:

When using `swift-nio` on Linux with cgroup v2 enabled, but with CFS
throttling disabled, it falls back to attempting to read the cpuset file
at the cgroup v1 path. This does not exist, which in turns falls back to
returning `_SC_NPROCESSORS_ONLN`, which will return the total number of
cores available (ignoring cgroup assignments).

This has unexpected effects, including the default behaviour of starting
the `MultiThreadedEventLoopGroup.singleton` with significantly more
event loops than cores available to the workload.

### Modifications:

- Adds `SystemCalls.statfs`, and associated constants, to determine the
cgroup version.
- Adds `Linux.cgroupVersion()` API to expose cgroup version.
- Adds `Linux.cgroupV2MountPoint` variable to determine the cgroup v2
mount point.
- Adds `Linux.cpuSetPathV1` & `Linux.cpuSetPathV2` (and
`Linux.cpuSetPath` convenience) variables to determine the correct cpu
set path.
- Alters `System.coreCount` to use the appropriate logic from above to
ensure that `cpuset.cpus` is parsed from the correct location.

### Result:

`Linux.coreCount` should correctly parse and return the core count on
Linux cgroup v2 enabled systems (when CFS throttling is disabled), while
maintaining correctness for other configurations.

---------

Co-authored-by: Johannes Weiss <johannesweiss@apple.com>
Co-authored-by: Cory Benfield <lukasa@apple.com>
2026-01-05 14:26:21 +00:00

220 lines
7.9 KiB
Swift

//===----------------------------------------------------------------------===//
//
// This source file is part of the SwiftNIO open source project
//
// Copyright (c) 2017-2025 Apple Inc. and the SwiftNIO project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of SwiftNIO project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//
import XCTest
@testable import NIOCore
class LinuxTest: XCTestCase {
func testCoreCountCgroup1RestrictionWithVariousIntegerFormats() throws {
#if os(Linux) || os(Android)
// Test various integer formats in cgroup files
let testCases = [
("42", "100000", 1), // Simple integer
("0", "100000", nil), // Zero should be rejected
]
for (quotaContent, periodContent, expectedResult) in testCases {
try withTemporaryFile(content: quotaContent) { (_, quotaPath) -> Void in
try withTemporaryFile(content: periodContent) { (_, periodPath) -> Void in
let result = Linux.coreCountCgroup1Restriction(quota: quotaPath, period: periodPath)
XCTAssertEqual(result, expectedResult, "Failed for quota '\(quotaContent)'")
}
}
}
// Test invalid integer cases
let invalidCases = ["abc", "12abc", ""]
for invalidContent in invalidCases {
try withTemporaryFile(content: invalidContent) { (_, quotaPath) -> Void in
try withTemporaryFile(content: "100000") { (_, periodPath) -> Void in
let result = Linux.coreCountCgroup1Restriction(quota: quotaPath, period: periodPath)
XCTAssertNil(result, "Should return nil for invalid quota '\(invalidContent)'")
}
}
}
#endif
}
func testCoreCountWithMultipleRanges() throws {
#if os(Linux) || os(Android)
// Test coreCount function with multiple CPU ranges
let content = "0,2,4-6" // Should count as 5 cores: 0,2,4,5,6
try withTemporaryFile(content: content) { (_, path) -> Void in
let result = Linux.coreCount(cpuset: path)
XCTAssertEqual(result, 5)
}
#endif
}
func testCoreCountWithSingleRange() throws {
#if os(Linux) || os(Android)
let content = "0-3" // Should count as 4 cores
try withTemporaryFile(content: content) { (_, path) -> Void in
let result = Linux.coreCount(cpuset: path)
XCTAssertEqual(result, 4)
}
#endif
}
func testCoreCountWithEmptyFile() throws {
#if os(Linux) || os(Android)
try withTemporaryFile(content: "") { (_, path) -> Void in
let result = Linux.coreCount(cpuset: path)
XCTAssertNil(result) // Empty file should return nil
}
#endif
}
func testCoreCountReadsOnlyFirstLine() throws {
#if os(Linux) || os(Android)
// Test that coreCount only processes the first line of the file
let content = "0-1\n2-3\n4-5" // First line should be "0-1" = 2 cores
try withTemporaryFile(content: content) { (_, path) -> Void in
let result = Linux.coreCount(cpuset: path)
XCTAssertEqual(result, 2) // Should only process first line
}
#endif
}
func testCoreCountWithSimpleCpuset() throws {
#if os(Linux) || os(Android)
// Test coreCount function with simple cpuset formats
let testCases = [
("0", 1), // Single core
("0-3", 4), // Range 0,1,2,3
("5-7", 3), // Range 5,6,7
("10-10", 1), // Single core as range
]
for (input, expected) in testCases {
try withTemporaryFile(content: input) { (_, path) -> Void in
let result = Linux.coreCount(cpuset: path)
XCTAssertEqual(result, expected, "Failed for input '\(input)'")
}
}
#endif
}
func testCoreCountWithComplexCpuset() throws {
#if os(Linux) || os(Android)
// Test more complex cpuset formats
let cpusets = [
("0", 1),
("0,2", 2),
("0-1,4-5", 4), // Two ranges: 0,1 and 4,5
("0,2-4,7,9-10", 7), // Mixed: 0, 2,3,4, 7, 9,10
]
for (cpuset, count) in cpusets {
try withTemporaryFile(content: cpuset) { (_, path) -> Void in
XCTAssertEqual(Linux.coreCount(cpuset: path), count)
}
}
#endif
}
func testCoreCountQuota() throws {
#if os(Linux) || os(Android)
let coreCountQuoats = [
("50000", "100000", 1),
("100000", "100000", 1),
("100000\n", "100000", 1),
("100000", "100000\n", 1),
("150000", "100000", 2),
("200000", "100000", 2),
("-1", "100000", nil),
("100000", "-1", nil),
("", "100000", nil),
("100000", "", nil),
("100000", "0", nil),
]
for (quota, period, count) in coreCountQuoats {
try withTemporaryFile(content: quota) { (_, quotaPath) -> Void in
try withTemporaryFile(content: period) { (_, periodPath) -> Void in
XCTAssertEqual(Linux.coreCountCgroup1Restriction(quota: quotaPath, period: periodPath), count)
}
}
}
#endif
}
func testCoreCountCpuset() throws {
#if os(Linux) || os(Android)
let cpusets = [
("0", 1),
("0,3", 2),
("0-3", 4),
("0-3,7", 5),
("0-3,7\n", 5),
("0,2-4,6,7,9-11", 9),
("", nil),
]
for (cpuset, count) in cpusets {
try withTemporaryFile(content: cpuset) { (_, path) -> Void in
XCTAssertEqual(Linux.coreCount(cpuset: path), count)
}
}
#endif
}
func testCoreCountCgroup2() throws {
#if os(Linux) || os(Android)
let contents = [
("max 100000", nil),
("75000 100000", 1),
("200000 100000", 2),
]
for (content, count) in contents {
try withTemporaryFile(content: content) { (_, path) in
XCTAssertEqual(Linux.coreCountCgroup2Restriction(cpuMaxPath: path), count)
}
}
#endif
}
func testParseV2CgroupLine() throws {
#if os(Linux) || os(Android)
let testCases: [(String, String?)] = [
// Valid cgroup v2 formats
("0::/", "/"), // Root cgroup
("0::/user.slice", "/user.slice"), // User slice
("0::/docker/container123", "/docker/container123"), // Docker container
("0::/", "/"), // This should work with omittingEmptySubsequences: false
("0::///", "///"), // Multiple slashes should be preserved
("0::/a/b/c", "/a/b/c"), // Normal nested path
("0::/.hidden", "/.hidden"), // Hidden directory
("0::/path:extra", "/path:extra"), // Test we're limiting to 2 splits maximum
// Invalid formats that should return nil
("1::/", nil), // Not hierarchy 0
("0:name:/", nil), // Has cgroup name (v1 format)
("0", nil), // Missing colons
("0:", nil), // Missing second colon
(":", nil), // Only one colon
("::/", nil), // Missing hierarchy number
("", nil), // Empty string
]
for (input, expected) in testCases {
let result = Linux.parseV2CgroupLine(Substring(input))
XCTAssertEqual(
result,
expected,
"Failed parsing '\(input)' - expected '\(expected ?? "nil")' but got '\(result ?? "nil")'"
)
}
#endif
}
}