Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -390,15 +390,21 @@ extension SuggestionCoordinator {
return
}

// Streaming half of the seam guard: the pure junk-run rule only. The spell-lookup half
// is an XPC and partials drain at token cadence, so it stays on the final apply, which
// authoritatively replaces or suppresses whatever streamed.
// Junk checks remain cheap enough for every partial. The first generated word is buffered
// until its boundary arrives, then its spelling decision is cached for the generation so
// the AppKit/XPC lookup never runs at token cadence.
guard CompletionSeamGuard.allowsStreamedPartial(
precedingText: liveContext.precedingText,
completion: partial.text
) else {
return
}
guard passesStreamedLeadingWordGate(
precedingText: liveContext.precedingText,
completion: partial.text
) else {
return
}

_ = interactionState.startSession(
fullText: partial.text,
Expand All @@ -414,6 +420,35 @@ extension SuggestionCoordinator {
)
}

/// Resolves the generation-scoped leading-word gate for one streamed partial and returns whether
/// the partial may render. A pending gate consults the seam guard, which either keeps buffering
/// (`wait`) or settles the gate for the rest of this generation; a settled gate answers without
/// touching the spell checker again. Kept separate so `applyStreamedPartial` stays within the
/// project's cyclomatic-complexity budget.
private func passesStreamedLeadingWordGate(precedingText: String, completion: String) -> Bool {
switch suggestionStreamingState.leadingWordGateState {
case .allowed:
return true
case .suppressed:
return false
case .pending:
switch CompletionSeamGuard.streamedLeadingWordVerdict(
precedingText: precedingText,
completion: completion,
spellingAssessment: { self.completionSpellingAssessment(for: $0) }
) {
case .wait:
return false
case .allow:
suggestionStreamingState.resolveLeadingWordGate(.allowed)
return true
case .suppress:
suggestionStreamingState.resolveLeadingWordGate(.suppressed)
return false
}
}
}

/// Runs the typo gate for the current word. Returns `true` when it handled the cycle by suppressing,
/// offering, or applying a correction; `false` proceeds with a normal continuation. Kept separate
/// so `generateFromCurrentFocus` stays within the project's cyclomatic-complexity budget.
Expand Down Expand Up @@ -484,6 +519,20 @@ extension SuggestionCoordinator {
?? spellChecker.bestCorrection(for: word)
}

/// Collapses native typo detection and correction availability into the seam guard's single
/// spelling contract. Keeping this adapter at the orchestration boundary lets the pure guard
/// express its policy without knowing about `NSSpellChecker` or accepting contradictory hooks.
private func completionSpellingAssessment(
for word: String
) -> CompletionSeamGuard.SpellingAssessment {
guard spellChecker.isTypo(word) else {
return .known
}
return spellChecker.bestCorrection(for: word) == nil
? .uncorrectableTypo
: .correctableTypo
}

/// Replaces a completed typo after Space without creating a visible correction session.
///
/// Automatic mutation is intentionally limited to a committed word boundary. The shared planner
Expand Down Expand Up @@ -608,10 +657,16 @@ extension SuggestionCoordinator {
}

private static func seamSuppressionReason(for verdict: CompletionSeamGuard.Verdict) -> String {
if case .seamMisspelling = verdict {
switch verdict {
case .seamMisspelling:
return "seamMisspelling"
case .leadingWordMisspelling:
return "leadingWordMisspelling"
case .junkPunctuationRun:
return "seamJunkPunctuationRun"
case .allow:
return "unknownSeamGuardSuppression"
}
return "seamJunkPunctuationRun"
}

/// Promotes a generated result to `ready` only when it is still fresh for the current field.
Expand Down Expand Up @@ -730,13 +785,14 @@ extension SuggestionCoordinator {
return
}

// Last line of defense before display: junk punctuation runs and mid-word splices that
// misspell the word being typed read as glitches, so showing nothing beats showing them.
// The spell lookup runs at most once per generation and only in the mid-word case.
// Last line of defense before display: junk punctuation runs, mid-word splices, and newly
// started words that the native checker can actually correct read as glitches, so showing
// nothing beats showing them. The leading-word check is intentionally fail-open for names
// and jargon with no correction candidate.
let seamVerdict = CompletionSeamGuard.verdict(
precedingText: liveContext.precedingText,
completion: result.text,
isKnownWord: { !spellChecker.isTypo($0) }
spellingAssessment: { self.completionSpellingAssessment(for: $0) }
)
if seamVerdict != .allow {
clearSuggestion()
Expand Down
4 changes: 3 additions & 1 deletion Cotabby/App/Core/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {

let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "?"
let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "?"
CotabbyLogger.app.info("Cotabby \(version) (build \(build)) launching on macOS \(ProcessInfo.processInfo.operatingSystemVersionString)")
CotabbyLogger.app.info(
"Cotabby \(version) (build \(build)) launching on macOS \(ProcessInfo.processInfo.operatingSystemVersionString)"
)
applyLaunchAtLoginDefaultIfNeeded()
startRuntimeIfPreferredEngineRequiresIt()
focusModel.start()
Expand Down
39 changes: 33 additions & 6 deletions Cotabby/Services/ModelManagement/Aria2DownloadService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,9 @@ nonisolated final class Aria2DownloadService: @unchecked Sendable {
process.standardOutput = outputPipe
process.standardError = errorPipe

outputPipe.fileHandleForReading.readabilityHandler = { [progressHandler] handle in
let data = handle.availableData
// Shared by the readability handlers and the termination drain below, so bytes that arrive
// either way are parsed identically.
let consumeOutput: @Sendable (Data) -> Void = { [progressHandler] data in
guard !data.isEmpty, let text = String(data: data, encoding: .utf8) else {
return
}
Expand All @@ -101,25 +102,51 @@ nonisolated final class Aria2DownloadService: @unchecked Sendable {
}
}
}

errorPipe.fileHandleForReading.readabilityHandler = { handle in
let data = handle.availableData
let consumeError: @Sendable (Data) -> Void = { data in
guard !data.isEmpty, let text = String(data: data, encoding: .utf8) else {
return
}
errorBuffer.append(text)
}

// Every read from either pipe, whether a readability callback or the termination drain
// below, runs on this one serial queue. That is what makes the drain complete: a callback
// that already pulled bytes with `availableData` finishes appending them before the drain
// starts, and a callback that lands after the drain finds the pipe at EOF. Detaching a
// handler alone does not wait for an in-flight callback, so without the queue the final
// stderr write could still be lost on a slow machine.
let pipeReadQueue = DispatchQueue(label: "com.cotabby.aria2.pipe-read")

outputPipe.fileHandleForReading.readabilityHandler = { handle in
pipeReadQueue.sync { consumeOutput(handle.availableData) }
}

errorPipe.fileHandleForReading.readabilityHandler = { handle in
pipeReadQueue.sync { consumeError(handle.availableData) }
}

return try await withCheckedThrowingContinuation { continuation in
process.terminationHandler = { [processState] terminatedProcess in
outputPipe.fileHandleForReading.readabilityHandler = nil
errorPipe.fileHandleForReading.readabilityHandler = nil

// A process that exits right after its last write can terminate before the
// readability callbacks consumed those bytes; on a slow machine that turned
// "simulated aria failure" into a bare exit code. Drain both pipes to EOF on the
// read queue, then snapshot the message on the same queue so nothing appends after
// the snapshot. The drain cannot block: the child's write ends closed when it
// exited, and the parent's copies were closed at launch.
let errorMessage = pipeReadQueue.sync {
consumeOutput(outputPipe.fileHandleForReading.readDataToEndOfFile())
consumeError(errorPipe.fileHandleForReading.readDataToEndOfFile())
return errorBuffer.value
}

continuation.resume(
with: Self.completionResult(
status: terminatedProcess.terminationStatus,
requestedOutcome: processState.finish(),
errorMessage: errorBuffer.value,
errorMessage: errorMessage,
targetURL: targetURL
)
)
Expand Down
Loading
Loading