Skip to content

[Bug]: ClientProcess.kill always fails with "missing signal in xpc message" - client sends the signal as an integer, server reads it as a string #1941

Description

@JeronimoColon

I have done the following

  • I have searched the existing issues
  • If possible, I've reproduced the issue using the 'main' branch of this project

Steps to reproduce

The failure is in the client library (ContainerAPIClient), so the reproduction is a small Swift program rather than a CLI command.

  1. Start the engine and create a throwaway container to exec into:
container system start
container run --detach --name kill-repro alpine sleep 600
  1. Build and run this program in a package that depends on github.com/apple/container (product ContainerAPIClient):
import ContainerAPIClient
import Foundation

let client = ContainerClient()
let container = try await client.get(id: "kill-repro")

// Start an exec'd process (a long sleep) so there is a live process to signal.
var processConfig = container.configuration.initProcess
processConfig.executable = "/bin/sleep"
processConfig.arguments = ["300"]
processConfig.terminal = false

let process = try await client.createProcess(
    containerId: container.id,
    processId: UUID().uuidString.lowercased(),
    configuration: processConfig,
    stdio: [nil, nil, nil]
)
try await process.start()

do {
    try await process.kill(SIGKILL)
    print("kill succeeded")
} catch {
    print("kill failed: \(error)")
}
  1. Observe that kill throws and the sleep keeps running:
kill failed: invalidArgument: "missing signal in xpc message"

The same happens with every signal value, not only SIGKILL.

  1. Clean up: container delete --force kill-repro

Problem description

ClientProcess.kill(_: Int32) never delivers a signal. Every call fails with invalidArgument: "missing signal in xpc message".

The cause is a type mismatch in the XPC message between the client and the API server. The client writes the signal as an integer (Sources/Services/ContainerAPIService/Client/ClientProcess.swift):

request.set(key: .signal, value: Int64(signal))

but the server's handler for the .containerKill route reads that key as a string, and rejects the request when the string is absent (Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift, the signal() helper):

guard let signal = self.string(key: .signal) else {
    throw ContainerizationError(.invalidArgument, message: "missing signal in xpc message")
}

The container-level kill shows the intended contract: ContainerClient.kill(id:signal:) takes the signal as a String, writes it as a string, and works. ClientProcess.kill is the odd one out. Since it is also the only API that targets a specific process, there is currently no working way to signal an exec'd process through the client library at all. In practice that means a timeout or cancel feature built on createProcess can stop waiting for a command, but cannot terminate it; the command keeps running until the container stops. The failure is also easy to miss, because kill is the kind of call that often gets wrapped in try?.

What I would expect: the client and server agree on the wire type, and the signal is delivered.

I observed the failure at runtime on container 1.0.0. The client write and the server read are both unchanged at tag 1.1.0 and on current main (checked at 5f277a9), so I expect it reproduces there as well.

Related work I found while searching: #1747 reports this same root cause surfacing through terminal-resize signal forwarding, and #1778 fixes the client-side encoding. The approach in #1778 (send the signal name rather than a number) looks right to me: Darwin and Linux disagree on some signal numbers (USR1 is 30 on Darwin and 10 on Linux), so a numeric passthrough could deliver the wrong signal even if the server accepted numbers.

Environment

  • OS: macOS 26.5.2 (25F84)
  • Xcode: 26.6 (17F113)
  • Container: container CLI 1.0.0 (build: release, commit: ee848e3)

Code of Conduct

  • I agree to follow this project's Code of Conduct

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions