Fix Race Condition in Container Removal (#130) (#218)

This PR resolves a race condition when removing a container immediately
after stopping it, caused by the `stop` command returning before the
container fully transitions to the stopped state (#130).

**Changes:**
- Enhanced `TestCLIRmRace.swift` with robust test logic and helper
methods (`containerExists`, `safeRemove`).
- Improved error handling to distinguish race conditions from successful
removals.
- Added exponential backoff retry logic for cleanup operations.
- Updated `CLITest.swift` with missing `doRemove` method.
- Fixed `BuilderStart.swift` to handle `.stopping` case.
- Improved error messages with container ID for better debugging.

**Testing:**
-  All tests pass (`make test`, `make integration`).
-  Verified on macOS 26.
-  Race condition test validates success and failure scenarios.
-  Code formatted (`make fmt`).

Hopefully, this will pass the integration tests on GitHub.

Signed-off-by: ramsyana <47033578+ramsyana@users.noreply.github.com>
This commit is contained in:
Ramsyana
2025-06-16 23:19:46 -07:00
committed by GitHub
parent 37c9732c4c
commit 21cfebb475
7 changed files with 169 additions and 8 deletions
@@ -209,10 +209,10 @@ actor ContainersService {
switch item.state {
case .alive(let client):
let state = try await client.state()
if state.status == .running {
if state.status == .running || state.status == .stopping {
throw ContainerizationError(
.invalidState,
message: "container with ID \(id) is running"
message: "container \(id) is not yet stopped and can not be deleted"
)
}
try self._cleanup(id: id, item: item)
+5
View File
@@ -125,6 +125,11 @@ extension Application {
return
}
try await existingContainer.delete()
case .stopping:
throw ContainerizationError(
.invalidState,
message: "builder is stopping, please wait until it is fully stopped before proceeding"
)
case .unknown:
break
}
@@ -24,4 +24,6 @@ public enum RuntimeStatus: String, CaseIterable, Sendable, Codable {
case stopped
/// The object is currently running.
case running
/// The object is currently stopping.
case stopping
}
@@ -321,8 +321,10 @@ public actor SandboxService {
var cs: ContainerSnapshot?
switch state {
case .created, .stopped(_), .starting, .booted, .stopping:
case .created, .stopped(_), .starting, .booted:
status = .stopped
case .stopping:
status = .stopping
case .running:
let ctr = try getContainer()
@@ -24,14 +24,14 @@ extension TestCLIBuildBase {
override init() throws {}
@Test func testBuilderStartStopCommand() throws {
#expect(throws: Never.self) {
try builderStart()
try waitForBuilderRunning()
let status = try getContainerStatus("buildkit")
try self.builderStart()
try self.waitForBuilderRunning()
let status = try self.getContainerStatus("buildkit")
#expect(status == "running", "BuildKit container is not running")
}
#expect(throws: Never.self) {
try builderStop()
let status = try getContainerStatus("buildkit")
try self.builderStop()
let status = try self.getContainerStatus("buildkit")
#expect(status == "stopped", "BuildKit container is not stopped")
}
}
@@ -0,0 +1,139 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
//
import Foundation
import Testing
class TestCLIRmRaceCondition: CLITest {
/// Helper method to check if a container exists
private func containerExists(_ name: String) -> Bool {
do {
_ = try getContainerStatus(name)
return true
} catch {
return false
}
}
/// Safe container removal that handles already-removed containers gracefully
private func safeRemove(name: String, force: Bool = false) throws {
guard containerExists(name) else {
// Container already removed, nothing to do
return
}
try doRemove(name: name, force: force)
}
@Test func testStopRmRace() async throws {
let name: String! = Test.current?.name.trimmingCharacters(in: ["(", ")"])
do {
// Create and start a container in detached mode that runs indefinitely
try doCreate(name: name, args: ["sleep", "infinity"])
try doStart(name: name)
// Wait for container to be running
try waitForContainerRunning(name)
// Call doStop - this should return immediately without waiting
try doStop(name: name)
// Immediately call doRemove and handle both possible outcomes:
// 1. Container removal succeeds immediately (race condition fixed)
// 2. Container removal fails because it's still stopping (race condition detected)
var raceConditionPrevented = false
var raceConditionDetected = false
do {
try doRemove(name: name)
// Success: The race condition prevention is working perfectly!
// Container was removed cleanly without any race condition
raceConditionPrevented = true
} catch CLITest.CLIError.executionFailed(let message) {
if message.contains("is not yet stopped and can not be deleted") {
// Expected behavior: Race condition detected and prevented
raceConditionDetected = true
} else if message.contains("not found") || message.contains("failed to delete one or more containers") {
// Container was already removed by background cleanup - this is also success!
raceConditionPrevented = true
} else {
Issue.record("Unexpected error message: \(message)")
return
}
} catch {
Issue.record("Unexpected error type: \(error)")
return
}
// Either outcome is acceptable - both indicate the race condition fix is working
#expect(
raceConditionPrevented || raceConditionDetected,
"Expected either immediate success (race prevented) or controlled failure (race detected)")
// If the container was already removed, we're done
if raceConditionPrevented {
return
}
// If we detected a race condition, wait for cleanup and retry removal
#expect(raceConditionDetected, "Should have detected race condition if we reach this point")
// Give the background cleanup a moment to finish
try await Task.sleep(for: .seconds(2))
// Retry removal with exponential backoff for cleanup
var removeAttempts = 0
let maxRemoveAttempts = 5
let baseDelay = 1.0 // seconds
while removeAttempts < maxRemoveAttempts {
do {
try safeRemove(name: name)
break
} catch CLITest.CLIError.executionFailed(let message) {
// If container doesn't exist, we're done
if message.contains("not found") {
break
}
guard removeAttempts < maxRemoveAttempts - 1 else {
throw CLITest.CLIError.executionFailed("Failed to remove container after \(maxRemoveAttempts) attempts: \(message)")
}
let delay = baseDelay * pow(2.0, Double(removeAttempts))
try await Task.sleep(for: .seconds(delay))
removeAttempts += 1
} catch {
guard removeAttempts < maxRemoveAttempts - 1 else {
throw error
}
let delay = baseDelay * pow(2.0, Double(removeAttempts))
try await Task.sleep(for: .seconds(delay))
removeAttempts += 1
}
}
} catch {
Issue.record("failed to test stop-rm race condition: \(error)")
// Safe cleanup - only try to remove if container actually exists
try? safeRemove(name: name, force: true)
return
}
}
}
+13
View File
@@ -367,4 +367,17 @@ class CLITest {
throw CLIError.executionFailed("command failed: \(error)")
}
}
func doRemove(name: String, force: Bool = false) throws {
var args = ["delete"]
if force {
args.append("--force")
}
args.append(name)
let (_, error, status) = try run(arguments: args)
if status != 0 {
throw CLIError.executionFailed("command failed: \(error)")
}
}
}