mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 20:05:46 -06:00
8391ac59ae
Quick Chat model changes now settle in target order, the remote probe uses one deadline, and node-owned Codex child shutdown is acknowledged and bounded. Related: #123420
80 lines
3.2 KiB
Swift
80 lines
3.2 KiB
Swift
import Darwin
|
|
import Foundation
|
|
import Testing
|
|
@testable import OpenClawDiscovery
|
|
|
|
struct BoundedCommandTests {
|
|
@Test func `force kills and reaps a command that ignores termination`() async throws {
|
|
let pidFile = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("openclaw-bounded-command-\(UUID().uuidString).pid")
|
|
defer { try? FileManager.default.removeItem(at: pidFile) }
|
|
|
|
// BoundedCommand starts its deadline concurrently with the spawn, so the
|
|
// timeout also bounds `/bin/sh` starting up and publishing its pid. A
|
|
// deadline near spawn latency turns that into a coin flip: under load the
|
|
// child is killed before it ever writes the file. Keep it well clear of
|
|
// spawn cost; what this test asserts is the force-kill, not spawn speed.
|
|
let runTask = Task {
|
|
await BoundedCommand.run(
|
|
path: "/bin/sh",
|
|
arguments: ["-c", "echo $$ > \"$PID_FILE\"; trap '' TERM; exec /bin/sleep 30"],
|
|
environment: ["PID_FILE": pidFile.path],
|
|
timeout: 2.0)
|
|
}
|
|
let watchdog = Task {
|
|
try? await Task.sleep(for: .seconds(10))
|
|
guard !Task.isCancelled else { return }
|
|
Issue.record("timed out waiting for TERM-resistant command cleanup")
|
|
runTask.cancel()
|
|
}
|
|
defer {
|
|
watchdog.cancel()
|
|
runTask.cancel()
|
|
}
|
|
|
|
let pid = try await Self.waitForPID(in: pidFile)
|
|
let output = await runTask.value
|
|
watchdog.cancel()
|
|
|
|
#expect(output == nil)
|
|
#expect(Self.waitUntilGone(pid))
|
|
}
|
|
|
|
/// Non-recording parse for polling. `#require` records an issue even when the
|
|
/// error it throws is swallowed by `try?`, so a retry loop must not use it or
|
|
/// the first not-yet-written read fails the test outright.
|
|
private static func pollPID(in file: URL) -> pid_t? {
|
|
guard let text = try? String(contentsOf: file, encoding: .utf8) else { return nil }
|
|
return pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines))
|
|
}
|
|
|
|
/// The child creates the pid file and writes to it in two steps, so a single
|
|
/// read can observe a missing *or* empty file. Poll until it parses.
|
|
private static func waitForPID(in file: URL) async throws -> pid_t {
|
|
let deadline = ContinuousClock.now + .seconds(10)
|
|
while ContinuousClock.now < deadline {
|
|
if let pid = self.pollPID(in: file) {
|
|
return pid
|
|
}
|
|
try await Task.sleep(for: .milliseconds(10))
|
|
}
|
|
let text = try String(contentsOf: file, encoding: .utf8)
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return try #require(pid_t(text))
|
|
}
|
|
|
|
/// Reaping is asynchronous, so the process can still be visible for a moment
|
|
/// after `run` returns.
|
|
private static func waitUntilGone(_ pid: pid_t) -> Bool {
|
|
let deadline = Date().addingTimeInterval(5)
|
|
while Date() < deadline {
|
|
errno = 0
|
|
if kill(pid, 0) == -1, errno == ESRCH {
|
|
return true
|
|
}
|
|
usleep(10000)
|
|
}
|
|
return false
|
|
}
|
|
}
|