Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,12 @@ actor SQLiteVoiceHistoryRetentionStore {
self.audioDirectory = audioDirectory
self.artifactSize = artifactSize
self.availableCapacity = availableCapacity
guard sqlite3_busy_timeout(opened, 2_000) == SQLITE_OK else {
guard
sqlite3_busy_timeout(
opened,
sqliteVoiceHistoryCoordinationTimeoutMilliseconds
) == SQLITE_OK
else {
throw VoiceSessionHistoryError.storageUnavailable(
"Voice History could not coordinate retention storage."
)
Expand Down
15 changes: 12 additions & 3 deletions Sources/HardwareControllerMac/sqlite_voice_session_store.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import Foundation
import HardwareControllerCore
import SQLite3

/// Bounds transient writer contention outside the input-to-action hot path.
let sqliteVoiceHistoryCoordinationTimeoutMilliseconds: Int32 = 5_000

private final class SQLiteDatabaseHandle: @unchecked Sendable {
let pointer: OpaquePointer

Expand Down Expand Up @@ -57,7 +60,12 @@ actor SQLiteVoiceSessionStore {
}
handle = SQLiteDatabaseHandle(opened)
self.audioDirectory = audioDirectory
guard sqlite3_busy_timeout(opened, 2_000) == SQLITE_OK else {
guard
sqlite3_busy_timeout(
opened,
sqliteVoiceHistoryCoordinationTimeoutMilliseconds
) == SQLITE_OK
else {
throw VoiceSessionHistoryError.storageUnavailable(
"Voice History could not configure database coordination."
)
Expand Down Expand Up @@ -1400,8 +1408,9 @@ actor SQLiteVoiceSessionStore {
}

private func storageFailure() -> VoiceSessionHistoryError {
.storageUnavailable(
"Voice History could not update its local database."
let code = sqlite3_extended_errcode(database)
return .storageUnavailable(
"Voice History could not update its local database (SQLite \(code))."
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,19 +88,8 @@ struct SQLiteVoiceHistoryRetentionStoreTest {
)
defer { try? FileManager.default.removeItem(at: root) }
let sessionID = UUID()
var history: SQLiteVoiceSessionHistory? = try SQLiteVoiceSessionHistory(
rootDirectory: root,
retentionSettings: .unlimited
)
let activeHistory = try #require(history)
let document = retentionDocument(sessionID: sessionID)
activeHistory.begin(
sessionID: sessionID,
startedAt: document.startedAt
)
activeHistory.append(try retentionAudioFixture())
try await activeHistory.complete(document)
history = nil
_ = try await seedAudio(document, in: root)
let store = try SQLiteVoiceHistoryRetentionStore(
databaseURL: root.appending(path: "history.sqlite3"),
audioDirectory: root.appending(path: "audio"),
Expand Down Expand Up @@ -137,10 +126,6 @@ struct SQLiteVoiceHistoryRetentionStoreTest {
)
defer { try? FileManager.default.removeItem(at: root) }
let sessionID = UUID()
var history: SQLiteVoiceSessionHistory? = try SQLiteVoiceSessionHistory(
rootDirectory: root,
retentionSettings: .unlimited
)
let document = VoiceSessionDocument(
id: sessionID,
startedAt: Date().addingTimeInterval(-1),
Expand All @@ -152,18 +137,8 @@ struct SQLiteVoiceHistoryRetentionStoreTest {
targetApplicationName: "Notes",
deliveryOutcome: .inserted
)
let activeHistory = try #require(history)
activeHistory.begin(
sessionID: sessionID,
startedAt: document.startedAt
)
activeHistory.append(try retentionAudioFixture())
try await activeHistory.complete(document)
let audioURL = try #require(
try await activeHistory.session(id: sessionID)?.audioArtifactURL
)
let audioURL = try await seedAudio(document, in: root)
try FileManager.default.removeItem(at: audioURL)
history = nil
let store = try SQLiteVoiceHistoryRetentionStore(
databaseURL: root.appending(path: "history.sqlite3"),
audioDirectory: root.appending(path: "audio")
Expand Down Expand Up @@ -207,6 +182,35 @@ struct SQLiteVoiceHistoryRetentionStoreTest {
return try CapturedAudioBuffer(copying: buffer)
}

private func seedAudio(
_ document: VoiceSessionDocument,
in root: URL
) async throws -> URL {
let fileManager = FileManager.default
let audioDirectory = root.appending(
path: "audio",
directoryHint: .isDirectory
)
try fileManager.createDirectory(
at: audioDirectory,
withIntermediateDirectories: true
)
let recorder = VoiceAudioArtifactRecorder(
sessionID: document.id,
audioDirectory: audioDirectory
)
recorder.append(try retentionAudioFixture())
let audioURL = try #require(
try await recorder.finishRetainingAudio()
)
let store = try SQLiteVoiceSessionStore(
databaseURL: root.appending(path: "history.sqlite3"),
audioDirectory: audioDirectory
)
try await store.insert(document, audioURL: audioURL)
return audioURL
}

private func retentionDocument(sessionID: UUID) -> VoiceSessionDocument {
let endedAt = Date()
return VoiceSessionDocument(
Expand Down
103 changes: 87 additions & 16 deletions Tests/HardwareControllerMacTests/sqlite_voice_session_store_test.swift
Original file line number Diff line number Diff line change
Expand Up @@ -900,19 +900,15 @@ struct SQLiteVoiceSessionStoreTest {
}

@Test
func concurrentFinalizationConvergesOnOneArtifact() async throws {
func rapidFinalizationConvergesOnOneArtifact() async throws {
let rootDirectory = temporaryRoot("retention_concurrent")
defer { try? FileManager.default.removeItem(at: rootDirectory) }
let settings = VoiceHistoryRetentionSettings(
maximumAgeDays: nil,
maximumAudioBytes: nil,
maximumArtifactCount: 1
)
let first = try SQLiteVoiceSessionHistory(
rootDirectory: rootDirectory,
retentionSettings: settings
)
let second = try SQLiteVoiceSessionHistory(
let history = try SQLiteVoiceSessionHistory(
rootDirectory: rootDirectory,
retentionSettings: settings
)
Expand All @@ -924,29 +920,29 @@ struct SQLiteVoiceSessionStoreTest {
sessionID: UUID(),
secondsAgo: 1
)
first.begin(
history.begin(
sessionID: firstDocument.id,
startedAt: firstDocument.startedAt
)
first.append(try makeVoiceAudioFixture())
second.begin(
history.append(try makeVoiceAudioFixture())
try await history.complete(firstDocument)
history.begin(
sessionID: secondDocument.id,
startedAt: secondDocument.startedAt
)
second.append(try makeVoiceAudioFixture())

async let firstCompletion: Void = first.complete(firstDocument)
async let secondCompletion: Void = second.complete(secondDocument)
_ = try await (firstCompletion, secondCompletion)
history.append(try makeVoiceAudioFixture())
try await history.complete(secondDocument)

let reopened = try SQLiteVoiceSessionHistory(
rootDirectory: rootDirectory,
retentionSettings: .unlimited
)
var sessions: [VoiceSessionHistoryItem] = []
for _ in 0..<100 {
for _ in 0..<500 {
sessions = try await reopened.recentSessions(limit: 10)
if sessions.filter({ $0.audioArtifactURL != nil }).count == 1 {
if sessions.filter({ $0.audioArtifactURL != nil }).count == 1,
sessions.filter({ $0.audioExpirationReason != nil }).count == 1
{
break
}
try await Task.sleep(for: .milliseconds(10))
Expand All @@ -958,6 +954,38 @@ struct SQLiteVoiceSessionStoreTest {
)
}

@Test(
.enabled(
if: ProcessInfo.processInfo.environment[
"HC_RUN_SQLITE_CONTENTION"
] == "1"
)
)
func finalizationWaitsThroughTransientDatabaseContention() async throws {
let rootDirectory = temporaryRoot("retention_contention")
defer { try? FileManager.default.removeItem(at: rootDirectory) }
let history = try SQLiteVoiceSessionHistory(
rootDirectory: rootDirectory,
retentionSettings: .unlimited
)
let document = retentionDocument(sessionID: UUID())
history.begin(sessionID: document.id, startedAt: document.startedAt)
history.append(try makeVoiceAudioFixture())
let lock = try SQLiteTestWriteLock(
databaseURL: rootDirectory.appending(path: "history.sqlite3")
)

async let completion: Void = history.complete(document)
try await Task.sleep(for: .milliseconds(2_250))
try lock.release()
try await completion

let stored = try #require(try await history.session(id: document.id))
#expect(stored.id == document.id)
#expect(stored.rawText == document.rawText)
#expect(stored.audioArtifactURL != nil)
}

@Test
func retentionStateRejectsStaleMaintenanceEvidence() {
let currentReport = VoiceHistoryRetentionReport(
Expand Down Expand Up @@ -1115,3 +1143,46 @@ struct SQLiteVoiceSessionStoreTest {
try await history.complete(document)
}
}

private final class SQLiteTestWriteLock {
private let database: OpaquePointer
private var isReleased = false

init(databaseURL: URL) throws {
var opened: OpaquePointer?
guard sqlite3_open(databaseURL.path, &opened) == SQLITE_OK,
let opened
else {
throw VoiceSessionHistoryError.storageUnavailable(
"The test database could not be opened."
)
}
database = opened
guard sqlite3_exec(database, "BEGIN IMMEDIATE;", nil, nil, nil) == SQLITE_OK
else {
sqlite3_close(database)
throw VoiceSessionHistoryError.storageUnavailable(
"The test database could not acquire its write lock."
)
}
}

func release() throws {
guard !isReleased else {
return
}
guard sqlite3_exec(database, "COMMIT;", nil, nil, nil) == SQLITE_OK else {
throw VoiceSessionHistoryError.storageUnavailable(
"The test database could not release its write lock."
)
}
isReleased = true
}

deinit {
if !isReleased {
sqlite3_exec(database, "ROLLBACK;", nil, nil, nil)
}
sqlite3_close(database)
}
}
3 changes: 3 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,9 @@ the quarantine; a failed database commit restores the file. A separate
retention actor reads the same SQLite database and owns quota selection,
expiration transactions, and file lifecycle. One shared History service applies
versioned preference schema 6 after finalization and on first startup access.
Both actor-owned connections share a five-second SQLite coordination bound, so
transient writer contention converges without entering the input-to-action hot
path; exhaustion remains a typed storage failure.
Age, count, byte-to-90%-low-water, and 1 GiB basic-volume-reserve rules select the
oldest eligible audio deterministically while excluding active, pinned, and sole
recovery artifacts. Expiration stores a typed reason and time, removes only the
Expand Down
6 changes: 4 additions & 2 deletions docs/contributor_guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@
Demo mode uses deterministic process data and does not request Accessibility,
Microphone, Speech Recognition, or hardware access. Tests replace process
boundaries and skip opt-in system checks unless their environment flag is set.
`scripts/check.sh` runs the required HID latency soak in its own test process so
unrelated parallel suites cannot create scheduler outliers.
`scripts/check.sh` runs the required SQLite contention and HID latency checks in
separate test processes. The contention check deliberately holds a writer lock;
the HID check measures scheduler latency. Isolation keeps either from distorting
unrelated parallel suites.

## Source map

Expand Down
10 changes: 7 additions & 3 deletions docs/decisions/0031_bounded_voice_history_audio.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ only recovery artifact for a failed or incomplete delivery.
maintenance. Run policy and SQLite/file work on actors after finalization and
at first startup access, outside hardware callbacks, target validation, and
text insertion.
- Give session and retention connections one five-second SQLite coordination
bound. Transient writer contention waits outside the input-to-action hot path;
exhaustion remains an explicit storage failure.
- Request OS-backup exclusion for the owned Voice History root on supported
volumes. Manual copies, filesystem snapshots, and external backup tools remain
outside app control.
Expand All @@ -57,9 +60,10 @@ only recovery artifact for a failed or incomplete delivery.
Pure-policy tests cover defaults, `Unlimited`, zero, protected artifacts,
stable ordering, the byte low-water mark, low disk, invalid sizes, and invalid
configuration. SQLite tests cover startup and post-finalization enforcement,
concurrent finalization, corrupt or missing sizes, recovery protection,
rapid shared-service finalization, corrupt or missing sizes, recovery protection,
expiration provenance, stale maintenance ordering, capacity inspection failure,
wall-clock rollback, concurrent pin protection, search preservation, and export
without audio. The complete 451-test/69-suite corpus passes. Measured
wall-clock rollback, concurrent pin protection, search preservation, export
without audio, and release after a deterministic 2.25-second writer lock. The
complete 451-test/69-suite M7 corpus passed. Measured
5,000-session warm-search p95 is 2.615 ms against the 250 ms requirement;
packaged-UI evidence is recorded in the game plan.
2 changes: 2 additions & 0 deletions scripts/build_release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,8 @@ main() {
Sources/HardwareControllerAudioBoundary/audio_engine_exception_boundary.m \
Sources/HardwareControllerAudioBoundary/include/audio_engine_exception_boundary.h
swift test
HC_RUN_SQLITE_CONTENTION=1 swift test \
--filter finalizationWaitsThroughTransientDatabaseContention
HC_RUN_HID_PERFORMANCE=1 swift test \
--filter tenThousandTransitionSoakMeetsDispatchBudget
zsh -n scripts/*.sh
Expand Down
2 changes: 2 additions & 0 deletions scripts/check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ xcrun clang-format --dry-run --Werror \
Sources/HardwareControllerAudioBoundary/include/audio_engine_exception_boundary.h
zsh -n scripts/*.sh
swift test
HC_RUN_SQLITE_CONTENTION=1 swift test \
--filter finalizationWaitsThroughTransientDatabaseContention
HC_RUN_HID_PERFORMANCE=1 swift test \
--filter tenThousandTransitionSoakMeetsDispatchBudget
swift build -c release --product HardwareController
Expand Down