Skip to content

Commit 4b22dda

Browse files
committed
Fix top Sentry crashes and patcher noise
- Patcher: only Sentry-report FCP launch termination on real failure signals (crash report, dylib never loaded, sub-5s exit) instead of every user-quit within 90s. Fixes APPLE-MACOS-M. - Patcher: add PatchError.userPrereq for Xcode CLT prereqs and skip Sentry capture for those. Fixes APPLE-MACOS-A. - Patcher: belt-and-suspenders FileManager.createDirectory before framework Info.plist write so a silent shell mkdir failure surfaces a clear error instead of NSCocoaError 4. Fixes APPLE-MACOS-18. - Patcher: deploy whisper-transcriber alongside parakeet in both patch and repatch paths (Swift app + CLI). Whisper engine option in social captions panel actually works now. - Transcript/Captions: guard NSTask.terminationStatus access with isRunning + @try/@catch; was throwing NSException when waitUntilExit returned with the task still in inconsistent state (e.g. arm64 binary on x86 host). Fixes APPLE-MACOS-17, -1D. - PlayheadOverlay: skip display-link tick when paused (rate=0) or on stale (>5s) observation; cached view was UAF'd during teardown. Fixes APPLE-MACOS-P. - Server: validate request params is a dict — was crashing on clients sending params:[] with __NSArray0 objectForKeyedSubscript:. Fixes APPLE-MACOS-X. - Server: callMethodWithArgs now branches on the selector signature for "string" args, passing UTF8 char* when the selector wants `*` instead of &NSString* (was crashing in _platform_strlen). Fixes APPLE-MACOS-K.
1 parent a4c8e9a commit 4b22dda

6 files changed

Lines changed: 174 additions & 42 deletions

File tree

Sources/SpliceKitCaptionPanel.m

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2493,11 +2493,25 @@ - (void)performCaptionTranscription {
24932493
@synchronized (stderrAccum) {
24942494
stderrData = [stderrAccum copy];
24952495
}
2496-
SpliceKitTranscriptDiag_logProcessExit(task.terminationStatus, stdoutData, stderrData, taskElapsed);
2496+
// -[NSTask terminationStatus] throws if task is still running. Defensively
2497+
// ensure exit before reading. See APPLE-MACOS-1D / APPLE-MACOS-17.
2498+
int exitCode = -1;
2499+
@try {
2500+
if (task.isRunning) {
2501+
SpliceKit_log(@"[Captions] WARNING: task still running after waitUntilExit; terminating");
2502+
[task terminate];
2503+
[task waitUntilExit];
2504+
}
2505+
exitCode = task.terminationStatus;
2506+
} @catch (NSException *e) {
2507+
SpliceKit_log(@"[Captions] ERROR: failed to read terminationStatus: %@", e.reason);
2508+
exitCode = -1;
2509+
}
2510+
SpliceKitTranscriptDiag_logProcessExit(exitCode, stdoutData, stderrData, taskElapsed);
24972511

2498-
if (task.terminationStatus != 0) {
2499-
SpliceKit_log(@"[Captions] %@ failed (exit code %d)", engineLabel, task.terminationStatus);
2500-
[self transcriptionFailedWithError:[NSString stringWithFormat:@"%@ transcription failed (exit code %d). Check log for details.", engineLabel, task.terminationStatus]];
2512+
if (exitCode != 0) {
2513+
SpliceKit_log(@"[Captions] %@ failed (exit code %d)", engineLabel, exitCode);
2514+
[self transcriptionFailedWithError:[NSString stringWithFormat:@"%@ transcription failed (exit code %d). Check log for details.", engineLabel, exitCode]];
25012515
return;
25022516
}
25032517

Sources/SpliceKitServer.m

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -917,7 +917,19 @@ static BOOL SpliceKit_rejectScalarForObjectArgument(const char *sigType,
917917

918918
if ([type isEqualToString:@"string"]) {
919919
NSString *val = [arg[@"value"] description];
920-
[inv setArgument:&val atIndex:argIdx];
920+
// setArgument: blindly memcpy's sigof(arg) bytes from the
921+
// source pointer, so the layout of `val` must match the
922+
// selector's expected ObjC type encoding. If the selector
923+
// wants a C-string (`*` / `r*`) and we pass &NSString*, the
924+
// callee later strlen()s the NSString's isa as if it were
925+
// a char* and crashes deep inside _platform_strlen.
926+
// See APPLE-MACOS-K.
927+
if (sigType[0] == '*' || (sigType[0] == 'r' && sigType[1] == '*')) {
928+
const char *cstr = [val UTF8String] ?: "";
929+
[inv setArgument:&cstr atIndex:argIdx];
930+
} else {
931+
[inv setArgument:&val atIndex:argIdx];
932+
}
921933
} else if ([type isEqualToString:@"int"]) {
922934
if (SpliceKit_rejectScalarForObjectArgument(sigType, selectorName, argIdx, type, &result)) return;
923935
long long val = [arg[@"value"] longLongValue];
@@ -27604,9 +27616,13 @@ static void SpliceKit_breakpointHit(NSString *key, id self_obj, SEL _cmd,
2760427616

2760527617
NSDictionary *SpliceKit_handleRequest(NSDictionary *request) {
2760627618
NSString *method = request[@"method"];
27607-
NSDictionary *params = request[@"params"] ?: @{};
27619+
id rawParams = request[@"params"];
27620+
// Clients sometimes send `"params": []` instead of `{}`. Without this guard,
27621+
// every later `params[@"key"]` crashes with "unrecognized selector sent to
27622+
// __NSArray0". See APPLE-MACOS-X. Coerce non-dict params to empty dict.
27623+
NSDictionary *params = [rawParams isKindOfClass:[NSDictionary class]] ? (NSDictionary *)rawParams : @{};
2760827624

27609-
if (!method) {
27625+
if (![method isKindOfClass:[NSString class]]) {
2761027626
return @{@"error": @{@"code": @(-32600), @"message": @"Invalid Request: method required"}};
2761127627
}
2761227628

Sources/SpliceKitTimelinePlayheadOverlay.m

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,8 +285,20 @@ - (void)tick:(CADisplayLink *)link {
285285
// If nothing has been observed yet, we have no reference — skip this tick.
286286
if (base.timescale <= 0 || baseWall <= 0.0) return;
287287

288-
// Extrapolate forward: t_now = base + (now - baseWall) * rate
288+
// Skip when paused: tick only needs to extrapolate during active playback,
289+
// and if we've gone idle the cached view may be mid-teardown. APPLE-MACOS-P
290+
// shows EXC_BAD_ACCESS deep inside locationRangeForTime: when this fires
291+
// against a view whose internals were freed.
292+
if (rate == 0.0) return;
293+
294+
// Stale observation: if we haven't seen a setPlayheadTime in a while, the
295+
// cached view may have been replaced (sequence change, dual-timeline
296+
// toggle). Bail out and wait for a fresh observation rather than calling
297+
// into a possibly-dead view.
289298
CFTimeInterval elapsed = CACurrentMediaTime() - baseWall;
299+
if (elapsed > 5.0) return;
300+
301+
// Extrapolate forward: t_now = base + (now - baseWall) * rate
290302
double extraSecs = elapsed * rate;
291303
PO_CMTime extrapolated = base;
292304
int64_t addValue = (int64_t)llround(extraSecs * (double)base.timescale);

Sources/SpliceKitTranscriptPanel.m

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2925,6 +2925,24 @@ - (void)performParakeetTranscription {
29252925
// Clean up manifest
29262926
[[NSFileManager defaultManager] removeItemAtPath:manifestPath error:nil];
29272927

2928+
// -[NSTask terminationStatus] throws NSInvalidArgumentException if the task
2929+
// is still running. waitUntilExit normally guarantees termination, but in
2930+
// edge cases (e.g. arm64-only binary launched on x86, signal interruption,
2931+
// pipe failures) the task can be in an inconsistent state. Read it once,
2932+
// defensively, and treat a throw as a non-zero exit. See APPLE-MACOS-17.
2933+
int exitCode = -1;
2934+
@try {
2935+
if (task.isRunning) {
2936+
SpliceKit_log(@"[Transcript] WARNING: task still running after waitUntilExit; terminating");
2937+
[task terminate];
2938+
[task waitUntilExit];
2939+
}
2940+
exitCode = task.terminationStatus;
2941+
} @catch (NSException *e) {
2942+
SpliceKit_log(@"[Transcript] ERROR: failed to read terminationStatus: %@", e.reason);
2943+
exitCode = -1;
2944+
}
2945+
29282946
// Diagnostic: process exit details
29292947
{
29302948
NSTimeInterval processElapsed = -[processStartTime timeIntervalSinceNow];
@@ -2933,13 +2951,13 @@ - (void)performParakeetTranscription {
29332951
@synchronized (stdoutAccum) {
29342952
stdoutDiagData = [stdoutAccum copy];
29352953
}
2936-
SpliceKitTranscriptDiag_logProcessExit(task.terminationStatus,
2954+
SpliceKitTranscriptDiag_logProcessExit(exitCode,
29372955
stdoutDiagData, stderrDiagData, processElapsed);
29382956
SpliceKitTranscriptDiag_inspectRawOutput(stdoutDiagData);
29392957
}
29402958

2941-
if (task.terminationStatus != 0) {
2942-
SpliceKit_log(@"[Transcript] ─── Parakeet failed (exit code %d) ───", task.terminationStatus);
2959+
if (exitCode != 0) {
2960+
SpliceKit_log(@"[Transcript] ─── Parakeet failed (exit code %d) ───", exitCode);
29432961

29442962
// Collect all stderr output for diagnostics
29452963
NSData *stderrRemaining = [stderrPipe.fileHandleForReading readDataToEndOfFile];
@@ -2992,9 +3010,9 @@ - (void)performParakeetTranscription {
29923010
userError = @"Permission denied reading media file. Check that FCP has Full Disk Access in System Settings > Privacy.";
29933011
} else if ([allLower containsString:@"corrupt"] || [allLower containsString:@"invalid data"]) {
29943012
userError = @"Media file appears to be corrupted or in an unsupported format.";
2995-
} else if (task.terminationStatus == 9) {
3013+
} else if (exitCode == 9) {
29963014
userError = @"Parakeet was killed (likely out of memory). Close other apps and try again with fewer clips.";
2997-
} else if (task.terminationStatus == 6) {
3015+
} else if (exitCode == 6) {
29983016
userError = @"Parakeet crashed (SIGABRT). This may be a compatibility issue. Try switching to Apple Speech engine.";
29993017
} else {
30003018
// Generic fallback with the actual output
@@ -3010,7 +3028,7 @@ - (void)performParakeetTranscription {
30103028
userError = [NSString stringWithFormat:@"Parakeet transcription failed: %@", lastLine];
30113029
} else {
30123030
userError = [NSString stringWithFormat:@"Parakeet transcription failed (exit code %d). "
3013-
@"Try switching to \"Apple Speech\" engine.", task.terminationStatus];
3031+
@"Try switching to \"Apple Speech\" engine.", exitCode];
30143032
}
30153033
}
30163034

patcher/SpliceKit/Models/PatcherModel.swift

Lines changed: 81 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,19 @@ enum WizardPanel: Int {
3737

3838
enum PatchError: LocalizedError {
3939
case msg(String)
40+
/// User-prerequisite failure (e.g. Xcode CLT not installed, license not accepted).
41+
/// Surfaces to the user normally but does NOT report to Sentry — these are
42+
/// expected setup steps, not SpliceKit bugs.
43+
case userPrereq(String)
4044
var errorDescription: String? {
41-
switch self { case .msg(let s): return s }
45+
switch self {
46+
case .msg(let s): return s
47+
case .userPrereq(let s): return s
48+
}
49+
}
50+
var isUserPrereq: Bool {
51+
if case .userPrereq = self { return true }
52+
return false
4253
}
4354
}
4455

@@ -325,7 +336,7 @@ class PatcherModel: ObservableObject {
325336
guard !selectedPath.isEmpty else {
326337
await logAsync("Xcode Command Line Tools not found. Installing...")
327338
shell("xcode-select --install 2>/dev/null")
328-
throw PatchError.msg("""
339+
throw PatchError.userPrereq("""
329340
Xcode Command Line Tools are required.
330341
331342
An installer window should have appeared. Please complete the installation, then click Retry.
@@ -336,15 +347,15 @@ class PatcherModel: ObservableObject {
336347
guard otoolCheck.status == 0 else {
337348
let output = otoolCheck.output.trimmingCharacters(in: .whitespacesAndNewlines)
338349
if output.localizedCaseInsensitiveContains("license") {
339-
throw PatchError.msg("""
350+
throw PatchError.userPrereq("""
340351
Xcode Command Line Tools are installed, but the Xcode license has not been accepted.
341352
342353
Please run this once in Terminal, then retry:
343354
sudo xcodebuild -license
344355
""")
345356
}
346357
shell("xcode-select --install 2>/dev/null")
347-
throw PatchError.msg("""
358+
throw PatchError.userPrereq("""
348359
Xcode Command Line Tools are installed, but required developer tools are not available.
349360
350361
Please complete or repair the Command Line Tools installation, then click Retry.
@@ -360,7 +371,7 @@ class PatcherModel: ObservableObject {
360371
let clangCheck = shellResult("xcrun clang --version 2>&1")
361372
guard clangCheck.status == 0 else {
362373
if clangCheck.output.localizedCaseInsensitiveContains("license") {
363-
throw PatchError.msg("""
374+
throw PatchError.userPrereq("""
364375
Xcode Command Line Tools are installed, but the Xcode license has not been accepted.
365376
366377
Please run this once in Terminal, then retry:
@@ -604,9 +615,15 @@ class PatcherModel: ObservableObject {
604615
await MainActor.run {
605616
self.errorMessage = error.localizedDescription
606617
self.appendLog("ERROR: \(error.localizedDescription)")
607-
PatcherSentry.capture(error: error,
608-
context: "patch.run",
609-
extras: ["current_step": self.currentStep?.rawValue ?? "unknown"])
618+
if (error as? PatchError)?.isUserPrereq == true {
619+
// User prerequisite (e.g. Xcode CLT) — surfaced to the user
620+
// but not a SpliceKit bug, so don't pollute Sentry.
621+
self.appendLog("Skipping Sentry report: user-prerequisite failure.")
622+
} else {
623+
PatcherSentry.capture(error: error,
624+
context: "patch.run",
625+
extras: ["current_step": self.currentStep?.rawValue ?? "unknown"])
626+
}
610627
}
611628
}
612629
await MainActor.run {
@@ -877,14 +894,24 @@ class PatcherModel: ObservableObject {
877894
// Step 4: Create macOS framework bundle (Versions/A + symlinks)
878895
await setStepAsync(.installFramework)
879896
let fwDir = moddedApp + "/Contents/Frameworks/SpliceKit.framework"
897+
let resourcesDir = fwDir + "/Versions/A/Resources"
880898
shell("""
881899
rm -rf '\(fwDir)'
882-
mkdir -p '\(fwDir)/Versions/A/Resources'
900+
mkdir -p '\(resourcesDir)'
883901
cp '\(buildDir)/SpliceKit' '\(fwDir)/Versions/A/SpliceKit'
884902
cd '\(fwDir)/Versions' && ln -sfn A Current
885903
cd '\(fwDir)' && ln -sfn Versions/Current/SpliceKit SpliceKit
886904
cd '\(fwDir)' && ln -sfn Versions/Current/Resources Resources
887905
""")
906+
// Belt-and-suspenders: shell mkdir occasionally fails silently (sandbox,
907+
// permissions, weird path), and the plist.write below then fails with
908+
// NSCocoaErrorDomain Code 4 (no such file or directory) — see APPLE-MACOS-18.
909+
// Re-create with FileManager so we get a clear, throwable error if it
910+
// genuinely can't be created.
911+
if !FileManager.default.fileExists(atPath: resourcesDir) {
912+
try FileManager.default.createDirectory(atPath: resourcesDir,
913+
withIntermediateDirectories: true)
914+
}
888915
let currentVersion = currentSpliceKitVersion()
889916
let patcherVersion = currentVersion.isEmpty ? "0.0.0" : currentVersion
890917
let plist = """
@@ -899,9 +926,9 @@ class PatcherModel: ObservableObject {
899926
<key>CFBundleExecutable</key><string>SpliceKit</string>
900927
</dict></plist>
901928
"""
902-
try plist.write(toFile: fwDir + "/Versions/A/Resources/Info.plist", atomically: true, encoding: .utf8)
929+
try plist.write(toFile: resourcesDir + "/Info.plist", atomically: true, encoding: .utf8)
903930
if let sentryConfig = Bundle.main.url(forResource: "SpliceKitSentryConfig", withExtension: "plist") {
904-
shell("cp '\(sentryConfig.path)' '\(fwDir)/Versions/A/Resources/SpliceKitSentryConfig.plist'")
931+
shell("cp '\(sentryConfig.path)' '\(resourcesDir)/SpliceKitSentryConfig.plist'")
905932
}
906933

907934
// Install BRAW plugin bundles into FCP.app/Contents/PlugIns/
@@ -1145,14 +1172,19 @@ class PatcherModel: ObservableObject {
11451172
// Install framework (overwrite existing binary)
11461173
await setStepAsync(.installFramework)
11471174
let fwDir = moddedApp + "/Contents/Frameworks/SpliceKit.framework"
1175+
let resourcesDir = fwDir + "/Versions/A/Resources"
11481176
shell("""
11491177
rm -rf '\(fwDir)'
1150-
mkdir -p '\(fwDir)/Versions/A/Resources'
1178+
mkdir -p '\(resourcesDir)'
11511179
cp '\(buildDir)/SpliceKit' '\(fwDir)/Versions/A/SpliceKit'
11521180
cd '\(fwDir)/Versions' && ln -sfn A Current
11531181
cd '\(fwDir)' && ln -sfn Versions/Current/SpliceKit SpliceKit
11541182
cd '\(fwDir)' && ln -sfn Versions/Current/Resources Resources
11551183
""")
1184+
if !FileManager.default.fileExists(atPath: resourcesDir) {
1185+
try FileManager.default.createDirectory(atPath: resourcesDir,
1186+
withIntermediateDirectories: true)
1187+
}
11561188

11571189
let currentVersion = currentSpliceKitVersion()
11581190
let patcherVersion = currentVersion.isEmpty ? "0.0.0" : currentVersion
@@ -1168,9 +1200,9 @@ class PatcherModel: ObservableObject {
11681200
<key>CFBundleExecutable</key><string>SpliceKit</string>
11691201
</dict></plist>
11701202
"""
1171-
try plist.write(toFile: fwDir + "/Versions/A/Resources/Info.plist", atomically: true, encoding: .utf8)
1203+
try plist.write(toFile: resourcesDir + "/Info.plist", atomically: true, encoding: .utf8)
11721204
if let sentryConfig = Bundle.main.url(forResource: "SpliceKitSentryConfig", withExtension: "plist") {
1173-
shell("cp '\(sentryConfig.path)' '\(fwDir)/Versions/A/Resources/SpliceKitSentryConfig.plist'")
1205+
shell("cp '\(sentryConfig.path)' '\(resourcesDir)/SpliceKitSentryConfig.plist'")
11741206
}
11751207

11761208
// Refresh BRAW plugin bundles on upgrade. Same reasoning as fresh
@@ -1530,24 +1562,45 @@ class PatcherModel: ObservableObject {
15301562
appendLog(String(format: "Final Cut Pro process %d terminated after %.1fs",
15311563
app.processIdentifier, runtime))
15321564
if runtime < 90 {
1533-
for line in launchDiagnostics(
1565+
let diagnostics = launchDiagnostics(
15341566
launchTime: launchTime,
15351567
spliceKitLogDateBeforeLaunch: spliceKitLogDateBeforeLaunch
1536-
) {
1568+
)
1569+
for line in diagnostics {
15371570
appendLog(line)
15381571
}
1539-
PatcherSentry.captureMessage(
1540-
"Final Cut Pro terminated shortly after launch",
1541-
level: .error,
1542-
context: "patcher.launch_termination",
1543-
extras: [
1544-
"runtime_seconds": runtime,
1545-
"diagnostics": launchDiagnostics(
1546-
launchTime: launchTime,
1547-
spliceKitLogDateBeforeLaunch: spliceKitLogDateBeforeLaunch
1548-
)
1549-
]
1550-
)
1572+
1573+
// Only report to Sentry if there's an actual failure signal. A user
1574+
// quitting FCP within 90s after launching it from the patcher is
1575+
// normal and should not produce a crash event. Real signals:
1576+
// 1. A FCP crash report newer than launchTime exists.
1577+
// 2. The injected dylib never initialized (splicekit.log unchanged).
1578+
// 3. Runtime was extremely short (< 5s) — likely instant exit.
1579+
let hasCrashReport = latestCrashReportURL(after: launchTime) != nil
1580+
let dylibNeverLoaded = diagnostics.contains {
1581+
$0.contains("dylib likely never initialized")
1582+
}
1583+
let cleanShutdown = diagnostics.contains {
1584+
$0.contains("App terminating") || $0.contains("Server socket closed")
1585+
}
1586+
let abnormal = hasCrashReport || dylibNeverLoaded || (runtime < 5 && !cleanShutdown)
1587+
1588+
if abnormal {
1589+
PatcherSentry.captureMessage(
1590+
"Final Cut Pro terminated shortly after launch",
1591+
level: .error,
1592+
context: "patcher.launch_termination",
1593+
extras: [
1594+
"runtime_seconds": runtime,
1595+
"has_crash_report": hasCrashReport,
1596+
"dylib_never_loaded": dylibNeverLoaded,
1597+
"clean_shutdown": cleanShutdown,
1598+
"diagnostics": diagnostics
1599+
]
1600+
)
1601+
} else {
1602+
appendLog("Skipping Sentry report: no crash report and dylib initialized cleanly (likely user-initiated quit).")
1603+
}
15511604
}
15521605
if launchedFCPRunningApp?.processIdentifier == app.processIdentifier {
15531606
launchedFCPRunningApp = nil

0 commit comments

Comments
 (0)