Files
container/Sources/CLI/DefaultCommand.swift
T
mazdakandGitHub bd2c228b57 DefaultCommand signal behavior improvements for plugins (#570)
## Type of Change
- [x] Bug fix
- [ ] New feature  
- [ ] Breaking change
- [ ] Documentation update

## Description
Correct signal semantics for plugins: Container binary currently execs
into plugin binaries. If the parent CLI keeps SIGINT/SIGTERM handlers
installed, it can intercept/alter signal behavior intended for the
plugin (e.g., preventing graceful shutdown in foreground workflows).

## Motivation and Context
During the development of a plugin (docker compose compatibility
plugin), I encountered a major issues where CTRL-C (SIGTERM) was not
being sent to my plugin. CLI plugins, especially those that have long
running tasks need a way to handle signals from the OS. Current, we exec
into plugin binaries. If the parent CLI keeps SIGINT/SIGTERM handlers
installed, it can intercept/alter signal behavior intended for the
plugin (e.g., preventing graceful shutdown in foreground workflows).

### What we changed:
- Signals handed back to plugins: 
- DefaultCommand resets SIGINT/SIGTERM to defaults immediately before
exec’ing the plugin.
- Rationale: since exec replaces the process image, signals should be
delivered to (and handled by) the plugin without parent interference.
  - Non‑plugin commands remain unaffected by this change.
  - Compatibility: No change to plugin ABI or exec flow.

### Alternatives considered:
- Supervising child instead of exec: central forwarding of signals from
parent to plugin. Rejected for now to avoid changing process tree/stdio
semantics; resetting to defaults before exec preserves current model
while fixing signal interference.

## Testing
- [X] Tested locally
- [ ] Added/updated tests
- [ ] Added/updated docs
2025-09-05 20:49:54 -07:00

66 lines
2.4 KiB
Swift

//===----------------------------------------------------------------------===//
// 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 ArgumentParser
import ContainerClient
import ContainerPlugin
import Darwin
struct DefaultCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: nil,
shouldDisplay: false
)
@OptionGroup(visibility: .hidden)
var global: Flags.Global
@Argument(parsing: .captureForPassthrough)
var remaining: [String] = []
func run() async throws {
// See if we have a possible plugin command.
let pluginLoader = try? await Application.createPluginLoader()
guard let command = remaining.first else {
await Application.printModifiedHelpText(pluginLoader: pluginLoader)
return
}
// Check for edge cases and unknown options to match the behavior in the absence of plugins.
if command.isEmpty {
throw ValidationError("Unknown argument '\(command)'")
} else if command.starts(with: "-") {
throw ValidationError("Unknown option '\(command)'")
}
guard let plugin = pluginLoader?.findPlugin(name: command), plugin.config.isCLI else {
throw ValidationError("failed to find plugin named container-\(command)")
}
// Before execing into the plugin, restore default SIGINT/SIGTERM so the plugin can manage signals.
Self.resetSignalsForPluginExec()
// Exec performs execvp (with no fork).
try plugin.exec(args: remaining)
}
}
extension DefaultCommand {
// Exposed for tests to verify signal reset semantics.
static func resetSignalsForPluginExec() {
signal(SIGINT, SIG_DFL)
signal(SIGTERM, SIG_DFL)
}
}