Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
15ff7a0
Fixes coreCount on Linux when using cgroup v2 with CFS throttling dis…
mitchellallison Dec 9, 2025
556574e
Make cpuSetPath, cpuSetPathV2 & cgroupV2MountPoint static lets.
mitchellallison Dec 15, 2025
264c9cf
Use lazily computed closures, as let variables are incompatible with …
mitchellallison Dec 15, 2025
91f100f
Remove redundant lazy.
mitchellallison Dec 15, 2025
a0e3fd9
Remove circular dependency between NIOCore & NIOFileSystem.
mitchellallison Dec 15, 2025
68af05c
Merge branch 'main' into fix-cgroups-v2-core-count
weissi Dec 15, 2025
c7e224a
Apply formatting fixes.
mitchellallison Dec 16, 2025
cb633ae
Fix Android/Static Linux compliation issues.
mitchellallison Dec 17, 2025
3109e63
Use correctly prefixed cgroup v2 mount point path, fix parsing of dou…
mitchellallison Dec 17, 2025
be58179
Switch cgroupVersion to be a stored property to align with other conv…
mitchellallison Dec 17, 2025
ef4d96c
Add back removed 'extern', fix up types for static/android SDKs, and …
mitchellallison Dec 17, 2025
1a2f263
Fix up second declaration.
mitchellallison Dec 17, 2025
35bb6a4
Fix up more build failures.
mitchellallison Dec 17, 2025
4d3bc93
Fix remaining build failures across Android/Static SDK, and fix up fo…
mitchellallison Dec 17, 2025
6ddfbb9
Merge branch 'main' into fix-cgroups-v2-core-count
mitchellallison Dec 17, 2025
20e9453
Workaround https://github.com/swiftlang/swift/issues/86149.
mitchellallison Dec 19, 2025
19ce396
Merge branch 'main' into fix-cgroups-v2-core-count
mitchellallison Dec 22, 2025
f415ee5
Merge branch 'main' into fix-cgroups-v2-core-count
Lukasa Jan 2, 2026
aeba647
Add back missing 'extern' for CNIOLinux_TMPFS_MAGIC declaration.
mitchellallison Jan 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Sources/CNIOLinux/include/CNIOLinux.h
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
#include <sys/xattr.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/vfs.h>
#include <linux/magic.h>
#include <sched.h>
#include <stdbool.h>
#include <errno.h>
Expand Down Expand Up @@ -149,6 +151,10 @@ extern const unsigned long CNIOLinux_UTIME_NOW;

extern const long CNIOLinux_UDP_MAX_SEGMENTS;

// Filesystem magic constants for cgroup detection
extern const unsigned long CNIOLinux_TMPFS_MAGIC;
extern const unsigned long CNIOLinux_CGROUP2_SUPER_MAGIC;

// A workaround for incorrect nullability annotations in the Android SDK.
FTS *CNIOLinux_fts_open(char * const *path_argv, int options, int (*compar)(const FTSENT **, const FTSENT **));

Expand Down
2 changes: 2 additions & 0 deletions Sources/CNIOLinux/shim.c
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,8 @@ const int CNIOLinux_AT_EMPTY_PATH = AT_EMPTY_PATH;
const unsigned long CNIOLinux_UTIME_OMIT = UTIME_OMIT;
const unsigned long CNIOLinux_UTIME_NOW = UTIME_NOW;

const unsigned long CNIOLinux_TMPFS_MAGIC = TMPFS_MAGIC;
const unsigned long CNIOLinux_CGROUP2_SUPER_MAGIC = CGROUP2_SUPER_MAGIC;

#ifdef UDP_MAX_SEGMENTS
const long CNIOLinux_UDP_MAX_SEGMENTS = UDP_MAX_SEGMENTS;
Expand Down
87 changes: 80 additions & 7 deletions Sources/NIOCore/Linux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,79 @@ import CNIOLinux
enum Linux {
static let cfsQuotaPath = "/sys/fs/cgroup/cpu/cpu.cfs_quota_us"
static let cfsPeriodPath = "/sys/fs/cgroup/cpu/cpu.cfs_period_us"
static let cpuSetPath = "/sys/fs/cgroup/cpuset/cpuset.cpus"
static let cfsCpuMaxPath = "/sys/fs/cgroup/cpu.max"

private static func firstLineOfFile(path: String) throws -> Substring {
let fh = try NIOFileHandle(_deprecatedPath: path)
static let cpuSetPathV1 = "/sys/fs/cgroup/cpuset/cpuset.cpus"
static lazy let cpuSetPathV2: String? = {
if let cgroupV2MountPoint = Self.cgroupV2MountPoint {
return NIOFilePath(cgroupV2MountPoint).appending(["cpuset.cpus"]).description
}
return nil
}()

static lazy let cgroupV2MountPoint: String? = {
guard let fh = try? NIOFileHandle(_deprecatedPath: "/proc/self/cgroup") else { return nil }
defer { try! fh.close() }
guard let lines = try? Self.readLines(fh: fh) else { return nil }

// Parse each line looking for cgroup v2 format: "0::/path"
for line in lines {
if let cgroupPath = Self.parseV2CgroupLine(line) {
return cgroupPath
}
}

return nil
}()

/// Returns the appropriate cpuset path based on the detected cgroup version
static lazy let cpuSetPath: String? = {
guard let version = Self.cgroupVersion() else { return nil }

switch version {
case .v1:
return cpuSetPathV1
case .v2:
return cpuSetPathV2
}
}()

/// Detects whether we're using cgroup v1 or v2
static func cgroupVersion() -> CgroupVersion? {
var fs = statfs()
guard let result = try? SystemCalls.statfs("/sys/fs/cgroup", &fs), result == 0 else { return nil }

switch fs.f_type {
case Int(CNIOLinux.TMPFS_MAGIC):
return .v1
case Int(CNIOLinux.CGROUP2_SUPER_MAGIC):
return .v2
default:
return nil
}
}

enum CgroupVersion {
case v1
case v2
}

/// Parses a single line from /proc/self/cgroup to extract cgroup v2 path
private static func parseV2CgroupLine(_ line: Substring) -> String? {
// Expected format is "0::/path"
let parts = line.split(separator: ":", maxSplits: 2)

guard parts.count == 3,
parts[0] == "0",
parts[1] == "" else {
return nil
}

// Extract the path from parts[2]
return String(parts[2])
}

private static func readLines(fh: NIOFileHandle) throws -> [Substring] {
// linux doesn't properly report /sys/fs/cgroup/* files lengths so we use a reasonable limit
var buf = ByteBufferAllocator().buffer(capacity: 1024)
try buf.writeWithUnsafeMutableBytes(minimumWritableBytes: buf.capacity) { ptr in
Expand All @@ -39,7 +106,13 @@ enum Linux {
preconditionFailure("read returned EWOULDBLOCK despite a blocking fd")
}
}
return String(buffer: buf).prefix(while: { $0 != "\n" })
return String(buffer: buf).split(separator: "\n")
}

private static func firstLineOfFile(path: String) throws -> Substring? {
let fh = try NIOFileHandle(_deprecatedPath: path)
defer { try! fh.close() }
return try? Self.readLines(fh: fh).first
}

private static func countCoreIds(cores: Substring) -> Int {
Expand All @@ -54,7 +127,7 @@ enum Linux {

static func coreCount(cpuset cpusetPath: String) -> Int? {
guard
let cpuset = try? firstLineOfFile(path: cpusetPath).split(separator: ","),
let cpuset = try? firstLineOfFile(path: cpusetPath).flatMap({ $0.split(separator: ",") }),
!cpuset.isEmpty
else { return nil }
return cpuset.map(countCoreIds).reduce(0, +)
Expand All @@ -67,11 +140,11 @@ enum Linux {
period periodPath: String = Linux.cfsPeriodPath
) -> Int? {
guard
let quota = try? Int(firstLineOfFile(path: quotaPath)),
let quota = try? firstLineOfFile(path: quotaPath).flatMap({ Int($0) }),
quota > 0
else { return nil }
guard
let period = try? Int(firstLineOfFile(path: periodPath)),
let period = try? firstLineOfFile(path: periodPath).flatMap({ Int($0) }),
period > 0
else { return nil }
return (quota - 1 + period) / period // always round up if fractional CPU quota requested
Expand Down
19 changes: 19 additions & 0 deletions Sources/NIOCore/SystemCallHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ import CNIOWindows
#error("The system call helpers module was unable to identify your C library.")
#endif

#if os(Linux) || os(Android)
import CNIOLinux
private let sysStatfs: @convention(c) (UnsafePointer<CChar>, UnsafeMutablePointer<statfs>) -> CInt = statfs
#endif

#if os(Windows)
private let sysDup: @convention(c) (CInt) -> CInt = _dup
private let sysClose: @convention(c) (CInt) -> CInt = _close
Expand Down Expand Up @@ -232,5 +237,19 @@ enum SystemCalls {
}
}
#endif

#if os(Linux) || os(Android)
@inline(never)
@usableFromInline
internal static func statfs(
_ path: UnsafePointer<CChar>,
_ buf: inout statfs
) throws -> CInt {
return try syscall(blocking: false) {
sysStatfs(path, &buf)
}.result
}
#endif

#endif // !os(WASI)
}
24 changes: 19 additions & 5 deletions Sources/NIOCore/Utilities.swift
Original file line number Diff line number Diff line change
Expand Up @@ -121,11 +121,25 @@ public enum System: Sendable {
.map { $0.ProcessorMask.nonzeroBitCount }
.reduce(0, +)
#elseif os(Linux) || os(Android)
if let quota2 = Linux.coreCountCgroup2Restriction() {
return quota2
} else if let quota = Linux.coreCountCgroup1Restriction() {
return quota
} else if let cpusetCount = Linux.coreCount(cpuset: Linux.cpuSetPath) {
var cpuSetPath: String?

switch Linux.cgroupVersion() {
case .v1:
if let quota = Linux.coreCountCgroup1Restriction() {
return quota
}
cpuSetPath = Linux.cpuSetPathV1
case .v2:
if let quota = Linux.coreCountCgroup2Restriction() {
return quota
}
cpuSetPath = Linux.cpuSetPathV2
case .none:
break
}

if let cpuSetPath,
let cpusetCount = Linux.coreCount(cpuset: cpuSetPath) {
return cpusetCount
} else {
return sysconf(CInt(_SC_NPROCESSORS_ONLN))
Expand Down
111 changes: 109 additions & 2 deletions Tests/NIOCoreTests/LinuxTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
//
// This source file is part of the SwiftNIO open source project
//
// Copyright (c) 2017-2023 Apple Inc. and the SwiftNIO project authors
// Copyright (c) 2017-2025 Apple Inc. and the SwiftNIO project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
Expand All @@ -17,6 +17,113 @@ 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 = [
Expand Down Expand Up @@ -61,7 +168,7 @@ class LinuxTest: XCTestCase {
#endif
}

func testCoreCountCgoup2() throws {
func testCoreCountCgroup2() throws {
#if os(Linux) || os(Android)
let contents = [
("max 100000", nil),
Expand Down
Loading