Files
swift-mirror/utils/swift-dev-utils/Sources/CrashReduce/Reproducer.swift
2026-01-29 22:26:51 +00:00

568 lines
17 KiB
Swift

//===--- Reproducer.swift -------------------------------------------------===//
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2026 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See https://swift.org/LICENSE.txt for license information
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
//
//===----------------------------------------------------------------------===//
import CryptoKit
import Foundation
import System
import Subprocess
import Synchronization
/// A crasher that has been reproduced.
public struct Reproducer: Sendable {
var signatures: KnownSignatures
var options: Options
var buffers: [Buffer]
var originalPath: AbsolutePath?
var originalID: String?
var crashInfo: CrashInfo?
var isStackOverflow: Bool
var issueID: Int?
var kind: Options.Kind { options.kind }
var primarySig: Signature {
signatures.primary
}
init(_ crasher: Crasher) {
let input = crasher.input
let crash = crasher.crashInfo
self.init(
signatures: crash.signatures, options: input.options, buffers: input.buffers,
originalID: input.id, originalPath: crasher.path, crashInfo: crash,
isStackOverflow: crash.primary.isStackOverflow, issueID: nil
)
}
init(
signatures: KnownSignatures, options: Options, buffers: [Buffer],
originalID: String?, originalPath: AbsolutePath?, crashInfo: CrashInfo?,
isStackOverflow: Bool, issueID: Int?
) {
self.signatures = signatures
self.options = options
self.buffers = buffers
self.crashInfo = crashInfo
self.isStackOverflow = isStackOverflow
self.issueID = issueID
self.originalID = originalID
self.originalPath = originalPath
}
static func decoding(_ data: Data) throws -> Self? {
let code = try Code(decoding: data)
guard let header = try code.reproHeader else { return nil }
let buffers = try Buffer.makeDefault(
code.cleanupTrivia(includingWhitespace: false)
.split(header.splits ?? []).map {
// fixme...
$0.cleanupTrivia(includingWhitespace: false)
}
)
var signatures = KnownSignatures(
Signature(symbol: header.signature, assertion: header.signatureAssert)
)
for alias in header.aliases ?? [] {
// FIXME: Dropping asserts...
if let assert = Assertion(from: alias) {
signatures.add(Signature(symbol: nil, assertion: assert))
} else {
signatures.add(Signature(symbol: alias, assertion: nil))
}
}
return Self(
signatures: signatures,
options: .init(
kind: header.kind ?? .typecheck,
isDeterministic: header.isDeterministic ?? true,
useGuardMalloc: header.useGuardMalloc ?? false,
useSourceOrderCompletion: header.useSourceOrderCompletion ?? false,
withSolverLimits: header.solverLimits ?? false,
noSDK: header.noSDK ?? false,
noObjCInterop: header.noObjCInterop ?? false,
languageMode: header.languageMode,
diagnosticStyle: header.diagnosticStyle,
frontendArgs: header.frontendArgs?.map { .value($0) } ?? []
),
buffers: buffers,
originalID: header.original,
originalPath: nil,
crashInfo: nil,
isStackOverflow: header.stackOverflow ?? false,
issueID: header.issueID
)
}
private func getTestCaseRequirements() -> String {
enum TestCaseRequirement: String, Hashable {
case macOS = "OS=macosx"
case targetSameAsHost = "target-same-as-host"
case noAsan = "no_asan"
case objcInterop = "objc_interop"
case swiftParser = "swift_swift_parser"
}
var requirements: Set<TestCaseRequirement> = []
if options.useGuardMalloc {
requirements.formUnion([.macOS, .targetSameAsHost, .noAsan])
}
if buffers.contains(where: \.code.hasImport) {
requirements.insert(.macOS)
}
if buffers.contains(where: \.code.hasObjC) && !options.noObjCInterop {
requirements.insert(.objcInterop)
}
if signatures.sigs.contains(where: { $0.symbol?.hasPrefix("$s") ?? false }) {
requirements.insert(.swiftParser)
}
var result = requirements.map(\.rawValue).sorted().map {
"// REQUIRES: \($0)"
}
if !options.isDeterministic {
// This isn't a real lit feature, this effectively just disables the test
// until it can be investigated.
result.append("// REQUIRES: non_deterministic_crasher")
}
if result.isEmpty {
return ""
}
return "\n" + result.joined(separator: "\n")
}
private func getTestCaseInvocation(_ inputs: [String]) -> String {
// FIXME: We ought to factor out common logic with `getCommandInvocation`.
var extraOpts: [String] = []
if options.noSDK {
extraOpts.append("-sdk %t")
}
if options.noObjCInterop {
extraOpts.append("-disable-objc-interop")
}
if options.withSolverLimits {
extraOpts.append(options.solverLimitArgs.joined(separator: " "))
}
if let languageMode = options.languageMode {
extraOpts.append("-swift-version \(languageMode)")
}
if let diagnosticStyle = options.diagnosticStyle {
extraOpts.append("-diagnostic-style=\(diagnosticStyle)")
}
let extraOptsStr = (extraOpts.isEmpty ? "" : " ") + extraOpts.joined(separator: " ")
let sourceOrderCompletion = options.useSourceOrderCompletion ? " -source-order-completion" : ""
var inv = switch kind {
case .typecheck:
"%target-swift-frontend -typecheck\(extraOptsStr)"
case .emitSILGen:
"%target-swift-frontend -emit-silgen\(extraOptsStr)"
case .emitSIL:
"%target-swift-frontend -emit-sil\(extraOptsStr)"
case .emitIR:
"%target-swift-frontend -emit-ir\(extraOptsStr)"
case .complete:
"%target-swift-ide-test -code-completion -batch-code-completion -skip-filecheck -code-completion-diagnostics\(extraOptsStr)\(sourceOrderCompletion) -source-filename"
case .custom:
"%target-swift-frontend \(options.frontendArgs.map(\.printed).joined(separator: " "))"
}
inv += " "
inv += options.reorderInputs(inputs).joined(separator: " ")
return inv
}
var allBuffers: String {
if buffers.count > 1 {
buffers.map { buffer in
"""
//--- \(buffer.name)
\(buffer.code.text)
"""
}.joined(separator: "\n")
} else {
buffers[0].code.text
}
}
var splits: [Int] {
var result: [Int] = []
for buffer in buffers.dropLast() {
result.append(buffer.code.text.utf8.count + (result.last ?? 0))
}
return result
}
func serialize() throws -> Data {
let splits = self.splits
let aliases = signatures.secondaries.sorted()
let header = Header(
kind: options.kind,
isDeterministic: options.isDeterministic ? nil : false,
signature: signatures.primary.description,
signatureAssert: signatures.primary.assertion,
stackOverflow: isStackOverflow ? true : nil,
aliases: aliases.isEmpty ? nil : aliases.map(\.description),
useGuardMalloc: options.useGuardMalloc ? true : nil,
useSourceOrderCompletion: options.useSourceOrderCompletion ? true : nil,
solverLimits: options.withSolverLimits ? true : nil,
noSDK: options.noSDK ? true : nil,
noObjCInterop: options.noObjCInterop ? true : nil,
languageMode: options.languageMode,
diagnosticStyle: options.diagnosticStyle,
issueID: issueID,
original: originalID,
splits: splits.isEmpty ? nil : splits,
frontendArgs: options.frontendArgs.isEmpty ? nil
: options.frontendArgs.flatMap(\.rawArgs)
)
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
let headerJSON = String(
decoding: try encoder.encode(header), as: UTF8.self
)
let multiBuffer = buffers.count > 1
let needsEmptyDir = options.noSDK || multiBuffer
let emptyDir = needsEmptyDir ? "// RUN: %empty-directory(%t)\n" : ""
let splitFile = multiBuffer ? "// RUN: split-file %s %t\n" : ""
let env = options.useGuardMalloc ? " env DYLD_INSERT_LIBRARIES=/usr/lib/libgmalloc.dylib" : ""
let inputs = if multiBuffer { buffers.map { "%t/\($0.name)" } } else { ["%s"] }
let reqs = getTestCaseRequirements()
let inv = getTestCaseInvocation(inputs)
let issueURL = if let issueID {
"\n// https://github.com/swiftlang/swift/issues/\(issueID)"
} else {
""
}
let run = "// RUN:\(env) not --crash \(inv)\(reqs)"
let contents = """
// \(headerJSON)
\(emptyDir)\(splitFile)\(run)\(issueURL)
\(allBuffers)
"""
return Data(contents.utf8)
}
}
extension Reproducer {
struct Options: Codable, Hashable {
enum Kind: String, Codable, Hashable {
case typecheck
case emitSILGen = "emit-silgen"
case emitSIL = "emit-sil"
case emitIR = "emit-ir"
case complete
case custom
}
var kind: Kind
var isDeterministic: Bool = true
var useGuardMalloc: Bool = false
var useSourceOrderCompletion: Bool = false
var withSolverLimits: Bool = false
var noSDK: Bool = false
var noObjCInterop: Bool = false
var languageMode: Int?
var isJoined: Bool = false
var diagnosticStyle: String?
var primaryIdx: Int?
var frontendArgs: [Command.Argument] = []
static var typecheck: Self { Self(kind: .typecheck) }
static var emitSILGen: Self { Self(kind: .emitSILGen) }
static var emitSIL: Self { Self(kind: .emitSIL) }
static var emitIR: Self { Self(kind: .emitIR) }
static var complete: Self { Self(kind: .complete) }
static func custom(frontendArgs: [Command.Argument]) -> Self {
Self(kind: .custom, frontendArgs: frontendArgs)
}
}
struct Header: Codable {
var kind: Options.Kind?
var isDeterministic: Bool?
var signature: String
var signatureAssert: Assertion?
var stackOverflow: Bool?
var aliases: [String]?
var useGuardMalloc: Bool?
var useSourceOrderCompletion: Bool?
var solverLimits: Bool?
var noSDK: Bool?
var noObjCInterop: Bool?
var languageMode: Int?
var diagnosticStyle: String?
var issueID: Int?
var original: String?
var splits: [Int]?
var frontendArgs: [String]?
}
}
extension Reproducer.Options {
func reorderInputs(_ inputs: [String]) -> [String] {
var result: [String] = []
if let primaryIdx {
result.append(inputs[primaryIdx])
}
for (idx, input) in inputs.enumerated() where idx != primaryIdx {
result.append(input)
}
return result
}
func executablePath(for toolchain: Toolchain) -> AbsolutePath {
switch kind {
case .typecheck, .emitSILGen, .emitSIL, .emitIR, .custom:
toolchain.swiftPath
case .complete:
toolchain.swiftIDETestPath
}
}
fileprivate var solverLimitArgs: [String] {
// FIXME: This is really something that should be included in the fuzzer
// JSON blob.
[
"-solver-trail-threshold=100000",
"-solver-scope-threshold=100000",
"-solver-memory-threshold=10000000",
]
}
func getCommandInvocation(
for inputs: [AbsolutePath], with toolchain: Toolchain
) -> Subprocess.Configuration {
var env: [Environment.Key: String] = [:]
if useGuardMalloc {
env["DYLD_INSERT_LIBRARIES"] = "/usr/lib/libgmalloc.dylib"
}
let exec = executablePath(for: toolchain)
var args: [String] = []
switch kind {
case .typecheck:
args += ["-frontend", "-typecheck"]
case .emitSILGen:
args += ["-frontend", "-emit-silgen"]
case .emitSIL:
args += ["-frontend", "-emit-sil"]
case .emitIR:
args += ["-frontend", "-emit-ir"]
case .complete:
args += [
"--code-completion", "-batch-code-completion", "-skip-filecheck",
"-code-completion-diagnostics", "-source-filename"
]
case .custom:
args.append("-frontend")
args += frontendArgs.flatMap(\.rawArgs)
}
args += reorderInputs(inputs.map(\.rawPath))
if useSourceOrderCompletion {
args.append("-source-order-completion")
}
if withSolverLimits {
args += solverLimitArgs
}
if !noSDK {
args += ["-sdk", toolchain.sdkPath.rawPath]
}
if noObjCInterop {
args.append("-disable-objc-interop")
}
if let languageMode {
args += ["-swift-version", "\(languageMode)"]
}
if let diagnosticStyle {
args.append("-diagnostic-style=\(diagnosticStyle)")
}
return Subprocess.Configuration(
.path(exec.storage), arguments: .init(args),
environment: .custom(env)
)
}
}
extension Code {
// Find the JSON header in leading comments.
fileprivate var reproHeader: Reproducer.Header? {
get throws {
var text = text
return try text.withUTF8 { bytes in
var scanner = ByteScanner(bytes)
func takeCommentLine() -> UnsafeRawBufferPointer? {
scanner.skip(while: \.isNewline)
guard scanner.tryEat(utf8: "//") else { return nil }
scanner.skip(while: \.isSpaceOrTab)
return scanner.eat(while: { !$0.isNewline }) ??
.init(start: nil, count: 0)
}
while let headerBytes = takeCommentLine() {
if headerBytes.first == UInt8(ascii: "{") {
return try JSONDecoder().decode(
Reproducer.Header.self, from: Data(headerBytes)
)
}
}
return nil
}
}
}
}
/// A wrapper around Reproducer for a specific reproducer test case on disk.
final class ReproducerFile {
var path: AbsolutePath {
didSet {
guard path != oldValue else { return }
do {
if oldValue.exists {
if path.exists {
try FileManager.default.removeItem(atPath: path.rawPath)
}
try FileManager.default.moveItem(
at: URL(filePath: oldValue.storage)!,
to: URL(filePath: path.storage)!
)
}
} catch {
log.error("\(error)")
}
}
}
var reproducer: Reproducer
private static func computeFileHashPrefix(
from repro: Reproducer, length: Int
) -> String {
var data = Data()
for buffer in repro.buffers {
data.append(Data(buffer.code.text.utf8))
data.append(UInt8(ascii: "\n"))
}
let hash = SHA256.hash(data: data)
var str = ""
hash.withUnsafeBytes { bytes in
for byte in bytes.prefix(length) {
str += String(format: "%02hhx", byte)
}
}
return str
}
private static func symbolNameForFile(for repro: Reproducer) -> String? {
guard let sym = repro.primarySig.short?.symbol,
sym.allSatisfy({ $0.isLetter || $0.isNumber || $0 == ":" }) else {
return nil
}
let components = sym.split(separator: "::")
return components.suffix(2).joined(separator: "-")
}
private static func computeDefaultFileName(from repro: Reproducer) -> String {
let prefix = if let prefix = Self.symbolNameForFile(for: repro) {
"\(prefix)-"
} else {
""
}
let suffix = computeFileHashPrefix(
from: repro, length: prefix.isEmpty ? 8 : 3
)
return "\(prefix)\(suffix).swift"
}
init(in directory: AbsolutePath, reproducer: Reproducer) {
self.path = directory.appending(Self.computeDefaultFileName(from: reproducer))
self.reproducer = reproducer
}
init(path: AbsolutePath, reproducer: Reproducer) {
self.path = path
self.reproducer = reproducer
}
init?(from path: AbsolutePath) throws {
guard let reproducer = try Reproducer.decoding(path.read()) else {
return nil
}
self.path = path
self.reproducer = reproducer
self.checkFilename(warn: true)
}
func checkFilename(warn: Bool) {
guard !path.fileName.contains("issue-"),
case let defaultFileName = Self.computeDefaultFileName(from: reproducer),
defaultFileName != path.fileName else { return }
if warn {
log.warning("""
'\(path.fileName)' does not have expected name, \
expected '\(defaultFileName)'
""")
}
path = path.parentDir!.appending(defaultFileName)
try? write()
}
func write() throws {
log.info("writing \(path)")
try path.write(reproducer.serialize())
}
}
extension ReproducerFile: CustomStringConvertible {
var description: String {
"\(reproducer.signatures) | \(path.fileName)"
}
}
struct ReproducerError: Error, CustomStringConvertible {
var message: String
init(_ message: String) {
self.message = message
}
var description: String {
message
}
}
extension Reproducer.Options: CustomStringConvertible {
var description: String {
var components = [kind.rawValue]
if !isDeterministic {
components.append("non-determ")
}
if useGuardMalloc {
components.append("guard-malloc")
}
if useSourceOrderCompletion {
components.append("source-order")
}
if noSDK {
components.append("no-sdk")
}
if noObjCInterop {
components.append("no-objc")
}
if isJoined {
components.append("joined")
}
if withSolverLimits {
components.append("solver-limits")
}
if let languageMode {
components.append("lang-mode=\(languageMode)")
}
if let primaryIdx {
components.append("primary=\(primaryIdx)")
}
return components.joined(separator: ", ")
}
}