diff --git a/Sources/Services/ContainerAPIService/Client/ClientProcess.swift b/Sources/Services/ContainerAPIService/Client/ClientProcess.swift index 5a5f8543e..a6b8410cf 100644 --- a/Sources/Services/ContainerAPIService/Client/ClientProcess.swift +++ b/Sources/Services/ContainerAPIService/Client/ClientProcess.swift @@ -80,7 +80,14 @@ struct ClientProcessImpl: ClientProcess, Sendable { let request = XPCMessage(route: .containerKill) request.set(key: .id, value: containerId) request.set(key: .processIdentifier, value: id) - request.set(key: .signal, value: Int64(signal)) + // The server reads `.signal` as a string and resolves it against the Linux + // signal table, so send the canonical signal name rather than a raw integer. + // A bare Int64 is read back as an empty string and rejected with + // "missing signal in xpc message". Translating the host signal number to its + // name (instead of sending the number) also keeps signals whose numbers differ + // between macOS and Linux (e.g. SIGUSR1) correct. Fall back to the numeric + // string for signals with no platform name (e.g. real-time signals). + request.set(key: .signal, value: Signal.platformName(signal) ?? "\(signal)") try await xpcClient.send(request) } diff --git a/Tests/ContainerAPIServiceTests/SignalWireFormatTests.swift b/Tests/ContainerAPIServiceTests/SignalWireFormatTests.swift new file mode 100644 index 000000000..0779bee69 --- /dev/null +++ b/Tests/ContainerAPIServiceTests/SignalWireFormatTests.swift @@ -0,0 +1,64 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// 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 Containerization +import Darwin +import Testing + +/// Guards the client -> server signal contract that `ClientProcess.kill(_:)` +/// depends on. The client holds a raw host (macOS) signal number and must put a +/// value on the wire that the server resolves back to the intended signal via +/// `Signal(_:)` (default Linux table), matching `RuntimeService.kill`. +/// +/// Regression coverage for the "missing signal in xpc message" bug where the +/// client sent a bare integer that the server read as a string (issues #1941, +/// #1876, #1747). +struct SignalWireFormatTests { + /// Exactly the expression `ClientProcess.kill(_:)` puts on the wire. + private func wireValue(_ signal: Int32) -> String { + Signal.platformName(signal) ?? "\(signal)" + } + + /// Exactly how the server resolves it (see `RuntimeService.kill`). + private func resolved(_ signal: Int32) throws -> Int32 { + try Signal(wireValue(signal)).rawValue + } + + @Test func commonSignalsRoundTrip() throws { + // Signals that share a number across macOS and Linux. + #expect(try resolved(SIGINT) == 2) // ^C (#1876) + #expect(try resolved(SIGWINCH) == 28) // terminal resize (#1747) + #expect(try resolved(SIGKILL) == 9) + #expect(try resolved(SIGTERM) == 15) + #expect(try resolved(SIGHUP) == 1) + } + + @Test func platformDivergentSignalTranslatesByName() throws { + // macOS SIGUSR1 == 30, Linux SIGUSR1 == 10. Sending the name (not the + // raw number) is what keeps this correct: a raw "30" would land on the + // container as Linux signal 30 (SIGPWR). + #expect(SIGUSR1 == 30) + #expect(wireValue(SIGUSR1) == "USR1") + #expect(try resolved(SIGUSR1) == 10) + } + + @Test func nonEmptyWireValue() { + // The original bug delivered an empty/absent string. Whatever we send + // must be a non-empty string the server can parse. + #expect(!wireValue(SIGINT).isEmpty) + #expect((try? Signal(wireValue(SIGINT))) != nil) + } +}