From f6d42e34484502ea1cf05f89f057caac930e4f2d Mon Sep 17 00:00:00 2001 From: ysicing Date: Mon, 28 Sep 2026 23:20:25 +0800 Subject: [PATCH] =?UTF-8?q?fix(cleaner):=20=E5=8F=96=E6=B6=88=E6=97=B6?= =?UTF-8?q?=E5=AE=89=E5=85=A8=E7=BB=93=E6=9D=9F=E7=AE=A1=E9=81=93=E8=AF=BB?= =?UTF-8?q?=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Cleaner/ToolExecution.swift | 35 ++++++++++++++++--- .../Tests/CleanerTests/CleanerTests.swift | 26 ++++++++++++++ 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/Packages/XStatsKit/Sources/Cleaner/ToolExecution.swift b/Packages/XStatsKit/Sources/Cleaner/ToolExecution.swift index d2b62f9..4ae011a 100644 --- a/Packages/XStatsKit/Sources/Cleaner/ToolExecution.swift +++ b/Packages/XStatsKit/Sources/Cleaner/ToolExecution.swift @@ -96,18 +96,22 @@ enum DeveloperToolRunner { } try? pipe.fileHandleForWriting.close() let reader = pipe.fileHandleForReading + // 读取任务自己在读完后关闭句柄:取消时外面不能在它还在 read 时关掉同一个 fd, + // 否则读取会报错,或读到 fd 编号被系统复用后的其他文件。 let outputTask = Task.detached(priority: .utility) { + defer { try? reader.close() } var tail = Data() let limit = max(0, outputLimit) - while let chunk = try reader.read(upToCount: 64 * 1024), !chunk.isEmpty { + while let chunk = try readChunk(reader) { tail.append(chunk) if tail.count > limit { tail.removeFirst(tail.count - limit) } } return String(decoding: tail, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) } + // 正常退出时读到 EOF;取消时通知读取任务自行退出并关闭句柄 defer { + outputTask.cancel() stop(process) - try? reader.close() } while process.isRunning { try Task.checkCancellation() @@ -174,12 +178,14 @@ enum DeveloperToolRunner { } try? pipe.fileHandleForWriting.close() let reader = pipe.fileHandleForReading + // 与 run 相同:句柄只由读取任务在读完后关闭,取消时不与读取竞争 let outputTask = Task.detached(priority: .utility) { - try readHomebrewCleanupOutput(reader, outputLimit: outputLimit) + defer { try? reader.close() } + return try readHomebrewCleanupOutput(reader, outputLimit: outputLimit) } defer { + outputTask.cancel() stop(process) - try? reader.close() } while process.isRunning { try Task.checkCancellation() @@ -207,7 +213,7 @@ enum DeveloperToolRunner { var lineTooLong = false var report = CleanReport() var hasInvalidSummary = false - while let chunk = try reader.read(upToCount: 64 * 1024), !chunk.isEmpty { + while let chunk = try readChunk(reader) { tail.append(chunk) if tail.count > outputLimit { tail.removeFirst(tail.count - max(0, outputLimit)) } for byte in chunk { @@ -230,6 +236,25 @@ enum DeveloperToolRunner { report: report, hasInvalidSummary: hasInvalidSummary) } + /// 限时轮询让取消能中断读取;孙进程继承管道写端时,阻塞式 read 可能一直等不到 EOF。 + private static func readChunk(_ reader: FileHandle) throws -> Data? { + var descriptor = pollfd(fd: reader.fileDescriptor, events: Int16(POLLIN | POLLHUP), revents: 0) + var bytes = [UInt8](repeating: 0, count: 64 * 1024) + while true { + try Task.checkCancellation() + let ready = Darwin.poll(&descriptor, 1, 100) + if ready == 0 || (ready < 0 && errno == EINTR) { continue } + guard ready > 0 else { throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) } + let count = bytes.withUnsafeMutableBytes { Darwin.read(descriptor.fd, $0.baseAddress, $0.count) } + if count == 0 { return nil } + if count < 0 { + if errno == EINTR || errno == EAGAIN { continue } + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + return Data(bytes.prefix(count)) + } + } + private static func recordHomebrewLine(_ bytes: [UInt8], report: inout CleanReport, hasInvalidSummary: inout Bool) { let line = String(decoding: bytes, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) do { try HomebrewCleanup.recordExecutionLine(line, report: &report) } diff --git a/Packages/XStatsKit/Tests/CleanerTests/CleanerTests.swift b/Packages/XStatsKit/Tests/CleanerTests/CleanerTests.swift index 724cf37..2589dfd 100644 --- a/Packages/XStatsKit/Tests/CleanerTests/CleanerTests.swift +++ b/Packages/XStatsKit/Tests/CleanerTests/CleanerTests.swift @@ -502,6 +502,32 @@ struct CleanEngineTests { #expect(kill(pid, 0) != 0) } + @Test func cancellationReturnsPromptlyWhileAGrandchildKeepsTheOutputOpen() async throws { + let home = try FakeHome() + defer { home.remove() } + // 后台孙进程继承输出管道并持续写入:结束 uv 后管道仍未到 EOF,读取任务不会立即结束。 + // 取消不能等待读取,也不能在读取途中关闭它的句柄。 + try home.executable("uv", script: "#!/bin/sh\n(while :; do echo tick; sleep 0.05; done) &\necho $! > \"$HOME/child\"\nexec /bin/sleep 30\n") + let task = Task { try await DeveloperToolRunner.run("uv", arguments: ["cache", "clean"], home: home.path) } + let childFile = home.root.appendingPathComponent("child") + let deadline = ContinuousClock.now.advanced(by: .seconds(3)) + while !FileManager.default.fileExists(atPath: childFile.path), ContinuousClock.now < deadline { + try await Task.sleep(for: .milliseconds(10)) + } + defer { + if let text = try? String(contentsOf: childFile, encoding: .utf8), + let pid = Int32(text.trimmingCharacters(in: .whitespacesAndNewlines)) { kill(pid, SIGKILL) } + } + try await Task.sleep(for: .milliseconds(200)) + let started = ContinuousClock.now + task.cancel() + do { + _ = try await task.value + Issue.record("取消命令不应成功") + } catch is CancellationError { } + #expect(ContinuousClock.now - started < .seconds(5), "cancellation waited on the still-open output pipe") + } + @Test func cleanRechecksRunningAppsBeforeDeleting() async throws { let home = try FakeHome() defer { home.remove() }