mirror of
https://github.com/apple/container.git
synced 2026-08-24 10:05:43 -05:00
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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user