@hasna/recordings 0.3.13 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/README.md +279 -43
  2. package/bun.lock +37 -38
  3. package/contracts/v1/fixtures.json +1206 -0
  4. package/dist/cli/index.js +1909 -619
  5. package/dist/cli/macos-shortcut.d.ts +2 -2
  6. package/dist/contracts/hosted-v1.d.ts +105 -0
  7. package/dist/contracts/hosted-v1.d.ts.map +1 -0
  8. package/dist/contracts/hosted-v1.js +41 -0
  9. package/dist/contracts/stream-v1.d.ts +117 -0
  10. package/dist/contracts/stream-v1.d.ts.map +1 -0
  11. package/dist/contracts/stream-v1.js +35 -0
  12. package/dist/db/database.d.ts.map +1 -1
  13. package/dist/db/pg-migrations.d.ts.map +1 -1
  14. package/dist/db/recordings.d.ts +6 -0
  15. package/dist/db/recordings.d.ts.map +1 -1
  16. package/dist/hosted/index.d.ts +58 -0
  17. package/dist/hosted/index.d.ts.map +1 -0
  18. package/dist/hosted/index.js +266 -0
  19. package/dist/hosted/transport.d.ts +36 -0
  20. package/dist/hosted/transport.d.ts.map +1 -0
  21. package/dist/hosted-v1-aavn7ktb.js +4114 -0
  22. package/dist/hosted-v1-gdr9extc.js +84 -0
  23. package/dist/http/client.d.ts +183 -22
  24. package/dist/http/client.d.ts.map +1 -1
  25. package/dist/index.d.ts +3 -3
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +2879 -2288
  28. package/dist/lib/audio-artifact-storage.d.ts +98 -0
  29. package/dist/lib/audio-artifact-storage.d.ts.map +1 -0
  30. package/dist/lib/capture-probe.d.ts +3 -3
  31. package/dist/lib/capture-probe.d.ts.map +1 -1
  32. package/dist/lib/config.d.ts.map +1 -1
  33. package/dist/lib/local-opt-in.d.ts +132 -0
  34. package/dist/lib/local-opt-in.d.ts.map +1 -0
  35. package/dist/lib/macos-bundle.d.ts +1 -1
  36. package/dist/lib/persistence-probe.d.ts +56 -17
  37. package/dist/lib/persistence-probe.d.ts.map +1 -1
  38. package/dist/lib/transcriber.d.ts.map +1 -1
  39. package/dist/mcp/index.d.ts.map +1 -1
  40. package/dist/mcp/index.js +1713 -459
  41. package/dist/sdk/index.d.ts +13 -5
  42. package/dist/sdk/index.d.ts.map +1 -1
  43. package/dist/sdk/index.js +904 -1
  44. package/dist/sdk/resolve.d.ts +53 -0
  45. package/dist/sdk/resolve.d.ts.map +1 -0
  46. package/dist/sdk/v1.generated.d.ts +6 -0
  47. package/dist/sdk/v1.generated.d.ts.map +1 -1
  48. package/dist/server/client-ip.d.ts +39 -0
  49. package/dist/server/client-ip.d.ts.map +1 -0
  50. package/dist/server/cloud-config.d.ts +12 -0
  51. package/dist/server/cloud-config.d.ts.map +1 -1
  52. package/dist/server/cloud-readiness.d.ts.map +1 -1
  53. package/dist/server/cloud.d.ts +20 -2
  54. package/dist/server/cloud.d.ts.map +1 -1
  55. package/dist/server/index.js +590 -287
  56. package/dist/server/migrate-command.d.ts +1 -1
  57. package/dist/server/migrate-command.d.ts.map +1 -1
  58. package/dist/server/openapi.d.ts +21 -0
  59. package/dist/server/openapi.d.ts.map +1 -1
  60. package/dist/server/repo.d.ts.map +1 -1
  61. package/dist/server/serve.d.ts.map +1 -1
  62. package/dist/storage.d.ts +2 -2
  63. package/dist/storage.d.ts.map +1 -1
  64. package/dist/storage.js +1358 -874
  65. package/dist/store.d.ts +12 -3
  66. package/dist/store.d.ts.map +1 -1
  67. package/dist/types/index.d.ts +15 -0
  68. package/dist/types/index.d.ts.map +1 -1
  69. package/docs/hosted-sdk.md +71 -0
  70. package/docs/wire-contracts.md +24 -0
  71. package/package.json +33 -11
  72. package/packaging/macos/build_release_pkg.sh +3 -3
  73. package/packaging/macos/managed_bootstrap.sh +4 -4
  74. package/packaging/macos/scripts/postinstall +2 -2
  75. package/packaging/macos/scripts/preinstall +2 -2
  76. package/scripts/build_companion_cli.sh +7 -1
  77. package/scripts/ci-linux-suite.ts +23 -13
  78. package/scripts/install_macos_app.sh +27 -24
  79. package/scripts/macos_artifact.ts +60 -49
  80. package/scripts/migrate.ts +4 -20
  81. package/scripts/native/prebuilds/darwin-universal/recordings_fs_guard.node +0 -0
  82. package/scripts/native/recordings_fs_guard.c +36 -4
  83. package/scripts/native-core-receipt.py +171 -0
  84. package/scripts/native_fs_guard.ts +2 -0
  85. package/scripts/release-suite-gate.ts +227 -150
  86. package/scripts/resolve_tailscale_cli.sh +24 -3
  87. package/scripts/smoke_macos_app.sh +21 -21
  88. package/src/native/Recordings/App/ContentView.swift +53 -68
  89. package/src/native/Recordings/App/MenuBarStatusView.swift +171 -61
  90. package/src/native/Recordings/App/RecordWorkspaceView.swift +47 -449
  91. package/src/native/Recordings/App/RecorderSettingsView.swift +75 -0
  92. package/src/native/Recordings/App/RecordingDetailView.swift +3 -4
  93. package/src/native/Recordings/App/RecordingsApp.swift +185 -9
  94. package/src/native/Recordings/App/RecordingsListView.swift +60 -112
  95. package/src/native/Recordings/App/RecordingsStore.swift +124 -109
  96. package/src/native/Recordings/App/Theme.swift +73 -61
  97. package/src/native/Recordings/Package.swift +1 -0
  98. package/src/native/Recordings/RecordingsLib/BlockingOperation.swift +29 -0
  99. package/src/native/Recordings/RecordingsLib/CaptureMonitor.swift +65 -0
  100. package/src/native/Recordings/RecordingsLib/Info.plist +3 -3
  101. package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +75 -21
  102. package/src/native/Recordings/RecordingsLib/ProjectStore.swift +4 -4
  103. package/src/native/Recordings/RecordingsLib/Recording.swift +14 -2
  104. package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +539 -89
  105. package/src/native/Recordings/RecordingsLib/RecordingGlassBackground.swift +34 -0
  106. package/src/native/Recordings/RecordingsLib/RecordingPasteTarget.swift +114 -0
  107. package/src/native/Recordings/RecordingsLib/RecordingProvider.swift +112 -0
  108. package/src/native/Recordings/RecordingsLib/RecordingsCLI.swift +17 -10
  109. package/src/native/Recordings/RecordingsLib/ServiceAPIConfiguration.swift +107 -0
  110. package/src/native/Recordings/RecordingsLib/ServiceConnectionView.swift +76 -0
  111. package/src/native/Recordings/RecordingsLib/SettingsView.swift +45 -149
  112. package/src/native/Recordings/RecordingsLib/SettingsWindowController.swift +48 -0
  113. package/src/native/Recordings/RecordingsTests/BlockingOperationTests.swift +85 -0
  114. package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +454 -76
  115. package/src/native/Recordings/RecordingsTests/OpenAIAPIKeyStoreTests.swift +43 -38
  116. package/src/native/Recordings/RecordingsTests/PipeClosureFixture.swift +215 -0
  117. package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +10 -10
  118. package/src/native/Recordings/RecordingsTests/RecorderSurfaceTests.swift +60 -0
  119. package/src/native/Recordings/RecordingsTests/RecordingDateTests.swift +26 -0
  120. package/src/native/Recordings/RecordingsTests/RecordingEngineDeliveryTests.swift +1 -1
  121. package/src/native/Recordings/RecordingsTests/RecordingFrozenPasteTargetTests.swift +50 -0
  122. package/src/native/Recordings/RecordingsTests/RecordingPasteTargetTrackerTests.swift +48 -0
  123. package/src/native/Recordings/RecordingsTests/RecordingProviderTests.swift +308 -0
  124. package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +1 -1
  125. package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +123 -21
  126. package/src/native/Recordings/RecordingsTests/ServiceAPIConfigurationTests.swift +75 -0
  127. package/src/native/Recordings/RecordingsTests/SettingsWindowControllerTests.swift +28 -0
  128. package/src/native/Recordings/RecordingsTests/TestHomeDirectory.swift +5 -1
  129. package/src/native/Recordings/Updater/BootstrapPreflight/BootstrapPreflightMain.swift +1 -1
  130. package/src/native/Recordings/Updater/Broker/ApplicationNamespace.swift +2 -2
  131. package/src/native/Recordings/Updater/Broker/BrokerMain.swift +1 -1
  132. package/src/native/Recordings/Updater/Broker/CanonicalTreeCopy.swift +1 -1
  133. package/src/native/Recordings/Updater/Broker/InstallJournal.swift +1 -1
  134. package/src/native/Recordings/Updater/BrokerTests/ActivationRecoveryPolicyTests.swift +2 -2
  135. package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +1 -1
  136. package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +2 -2
  137. package/src/native/Recordings/build.sh +8 -7
  138. package/dist/__tests__/helpers/installer-guard-execution.d.ts +0 -22
  139. package/dist/__tests__/helpers/installer-guard-execution.d.ts.map +0 -1
  140. package/dist/__tests__/helpers/installer-preflight.d.ts +0 -22
  141. package/dist/__tests__/helpers/installer-preflight.d.ts.map +0 -1
  142. package/dist/__tests__/helpers/native-fs-guard.d.ts +0 -2
  143. package/dist/__tests__/helpers/native-fs-guard.d.ts.map +0 -1
  144. package/dist/__tests__/helpers/source-assertions.d.ts +0 -171
  145. package/dist/__tests__/helpers/source-assertions.d.ts.map +0 -1
  146. package/dist/__tests__/preload.d.ts +0 -2
  147. package/dist/__tests__/preload.d.ts.map +0 -1
  148. package/src/native/Recordings/App/SidebarView.swift +0 -212
@@ -4,29 +4,31 @@ import SwiftUI
4
4
  public struct SettingsView: View {
5
5
  @ObservedObject public var engine: RecordingEngine
6
6
  @ObservedObject public var shortcuts: VoiceShortcuts
7
- @ObservedObject public var projectStore: ProjectStore
8
- @AppStorage("openAIAPIKey") private var openAIAPIKey = ""
7
+ @ObservedObject public var preferences: ProjectStore
8
+ @State private var openAIAPIKey = ""
9
+ @State private var providerStatus = ""
9
10
 
10
- public init(engine: RecordingEngine, shortcuts: VoiceShortcuts, projectStore: ProjectStore) {
11
+ public init(engine: RecordingEngine, shortcuts: VoiceShortcuts, preferences: ProjectStore) {
11
12
  self.engine = engine
12
13
  self.shortcuts = shortcuts
13
- self.projectStore = projectStore
14
+ self.preferences = preferences
14
15
  }
15
16
 
16
17
  public var body: some View {
17
18
  TabView {
18
19
  generalTab.tabItem { Label("General", systemImage: "gear") }
19
- projectsTab.tabItem { Label("Projects", systemImage: "folder") }
20
20
  shortcutsTab.tabItem { Label("Voice Shortcuts", systemImage: "text.badge.star") }
21
21
  }
22
- .frame(width: 520, height: 500)
23
- .alert("Project Settings Error", isPresented: Binding(
24
- get: { projectStore.persistenceError != nil },
25
- set: { if !$0 { projectStore.clearPersistenceError() } }
22
+ .font(.system(size: 13))
23
+ .controlSize(.small)
24
+ .frame(width: 520, height: 460)
25
+ .alert("Settings Error", isPresented: Binding(
26
+ get: { preferences.persistenceError != nil },
27
+ set: { if !$0 { preferences.clearPersistenceError() } }
26
28
  )) {
27
29
  Button("OK", role: .cancel) {}
28
30
  } message: {
29
- Text(projectStore.persistenceError ?? "The project settings could not be saved.")
31
+ Text(preferences.persistenceError ?? "The settings could not be saved.")
30
32
  }
31
33
  }
32
34
 
@@ -34,18 +36,38 @@ public struct SettingsView: View {
34
36
 
35
37
  private var generalTab: some View {
36
38
  Form {
39
+ ServiceConnectionView(home: engine.home)
37
40
  Section("OpenAI") {
38
41
  SecureField("API key", text: $openAIAPIKey)
39
42
  .textFieldStyle(.roundedBorder)
40
- .onChange(of: openAIAPIKey) {
41
- // Keep the CLI config in sync — final transcription shells out to it.
42
- try? OpenAIAPIKeyStore.save(key: openAIAPIKey, homePath: engine.home)
43
+ Button("Save OpenAI Key") {
44
+ do {
45
+ try OpenAIAPIKeyStore.save(key: openAIAPIKey, homePath: engine.home)
46
+ openAIAPIKey = ""
47
+ providerStatus = "OpenAI key saved in Keychain."
48
+ } catch {
49
+ providerStatus = error.localizedDescription
50
+ }
51
+ }
52
+ .disabled(openAIAPIKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
53
+ Button("Connect Saved Key") {
54
+ do {
55
+ providerStatus = (try OpenAIAPIKeyStore.loadKeychain(allowInteraction: true))?.isEmpty == false
56
+ ? "OpenAI key available from Keychain."
57
+ : "No saved OpenAI key found in Keychain."
58
+ } catch { providerStatus = error.localizedDescription }
59
+ }
60
+ Text(providerStatus).foregroundStyle(.secondary)
61
+ .onAppear {
62
+ providerStatus = OpenAIAPIKeyStore.load(homePath: engine.home).isEmpty
63
+ ? "Add an OpenAI key for transcription."
64
+ : "OpenAI key available for transcription."
43
65
  }
44
66
  Picker("Language", selection: $engine.transcriptionLanguage) {
45
67
  Text("English").tag("en")
46
68
  Text("Auto Detect").tag("auto")
47
69
  }
48
- Text("Used for live transcription and the final paste. Stored in ~/.hasna/recordings/config.json.")
70
+ Text("Used for live transcription and the final paste. New keys are stored in macOS Keychain.")
49
71
  .foregroundStyle(.secondary)
50
72
  }
51
73
 
@@ -143,82 +165,26 @@ public struct SettingsView: View {
143
165
  }
144
166
 
145
167
  Section("Transcription Cleanup") {
146
- Picker("Mode", selection: $projectStore.settings.postProcessingMode) {
168
+ Picker("Mode", selection: $preferences.settings.postProcessingMode) {
147
169
  ForEach(PostProcessingMode.allCases) { mode in
148
170
  Text(mode.label).tag(mode.rawValue)
149
171
  }
150
172
  }
151
173
  .pickerStyle(.segmented)
152
- .onChange(of: projectStore.settings.postProcessingMode) {
153
- try? projectStore.save()
174
+ .onChange(of: preferences.settings.postProcessingMode) {
175
+ try? preferences.save()
154
176
  }
155
- TextEditor(text: $projectStore.settings.globalSystemPrompt)
156
- .frame(height: 80)
157
- .onChange(of: projectStore.settings.globalSystemPrompt) {
158
- try? projectStore.save()
177
+ TextEditor(text: $preferences.settings.globalSystemPrompt)
178
+ .frame(height: 80).scrollContentBackground(.hidden)
179
+ .onChange(of: preferences.settings.globalSystemPrompt) {
180
+ try? preferences.save()
159
181
  }
160
182
  Text("Instructions for post-transcription cleanup and formatting.")
161
183
  .foregroundStyle(.secondary)
162
184
  }
163
- .disabled(!projectStore.canMutateProjects)
164
- }
165
- .formStyle(.grouped).padding()
166
- }
167
-
168
- // MARK: - Projects
169
-
170
- @State private var newProjectName = ""
171
- @State private var editingProject: RecProject?
172
-
173
- private var projectsTab: some View {
174
- VStack(spacing: 0) {
175
- HStack(spacing: 8) {
176
- TextField("New project name", text: $newProjectName)
177
- .textFieldStyle(.roundedBorder)
178
- Button("Add") {
179
- guard !newProjectName.isEmpty else { return }
180
- let name = newProjectName
181
- Task {
182
- do {
183
- try await projectStore.addProject(name: name)
184
- newProjectName = ""
185
- } catch {}
186
- }
187
- }
188
- .disabled(newProjectName.isEmpty)
189
- }
190
- .padding()
191
-
192
- Divider()
193
-
194
- if projectStore.settings.projects.isEmpty {
195
- Spacer()
196
- VStack(spacing: 8) {
197
- Image(systemName: "folder").font(.largeTitle).foregroundStyle(.quaternary)
198
- Text("No projects yet").foregroundStyle(.secondary)
199
- }
200
- Spacer()
201
- } else {
202
- List {
203
- ForEach(projectStore.settings.projects) { project in
204
- ProjectRow(project: project) {
205
- editingProject = project
206
- }
207
- }
208
- .onDelete { indexSet in
209
- for i in indexSet {
210
- try? projectStore.removeProject(id: projectStore.settings.projects[i].id)
211
- }
212
- }
213
- }
214
- }
215
- }
216
- .disabled(!projectStore.canMutateProjects)
217
- .sheet(item: $editingProject) { project in
218
- ProjectEditView(project: project, store: projectStore) {
219
- editingProject = nil
220
- }
185
+ .disabled(!preferences.canMutateProjects)
221
186
  }
187
+ .formStyle(.grouped).scrollContentBackground(.hidden).padding(8)
222
188
  }
223
189
 
224
190
  // MARK: - Voice Shortcuts
@@ -263,73 +229,3 @@ public struct SettingsView: View {
263
229
  }
264
230
  }
265
231
  }
266
-
267
- // MARK: - Project Row
268
-
269
- struct ProjectRow: View {
270
- let project: RecProject
271
- let onEdit: () -> Void
272
-
273
- var body: some View {
274
- HStack {
275
- VStack(alignment: .leading, spacing: 2) {
276
- Text(project.name)
277
- if let path = project.path, !path.isEmpty {
278
- Text(path).foregroundStyle(.secondary).lineLimit(1)
279
- }
280
- }
281
- Spacer()
282
- Button("Edit") { onEdit() }
283
- .controlSize(.small)
284
- }
285
- }
286
- }
287
-
288
- // MARK: - Project Edit
289
-
290
- struct ProjectEditView: View {
291
- @State var project: RecProject
292
- let store: ProjectStore
293
- let onDismiss: () -> Void
294
-
295
- var body: some View {
296
- VStack(spacing: 0) {
297
- Form {
298
- Section("Project") {
299
- TextField("Name", text: $project.name)
300
- TextField("Path", text: Binding(
301
- get: { project.path ?? "" },
302
- set: { project.path = $0.isEmpty ? nil : $0 }
303
- ))
304
- .textFieldStyle(.roundedBorder)
305
- }
306
-
307
- Section("Transcriber Instructions") {
308
- TextEditor(text: Binding(
309
- get: { project.systemPrompt ?? "" },
310
- set: { project.systemPrompt = $0.isEmpty ? nil : $0 }
311
- ))
312
- .frame(height: 100)
313
- Text("Project-specific cleanup and formatting instructions.")
314
- .foregroundStyle(.secondary)
315
- }
316
- }
317
- .formStyle(.grouped)
318
- .disabled(!store.canMutateProjects)
319
-
320
- HStack {
321
- Spacer()
322
- Button("Cancel") { onDismiss() }
323
- Button("Save") {
324
- do {
325
- try store.updateProject(project)
326
- onDismiss()
327
- } catch {}
328
- }
329
- .disabled(!store.canMutateProjects)
330
- }
331
- .padding()
332
- }
333
- .frame(width: 420, height: 340)
334
- }
335
- }
@@ -0,0 +1,48 @@
1
+ import AppKit
2
+ import SwiftUI
3
+
4
+ /// Settings must also open from an accessory app with no SwiftUI workspace scene.
5
+ /// Retain the window so repeated menu actions and closing/reopening preserve its state.
6
+ @MainActor
7
+ public final class SettingsWindowController {
8
+ private var window: NSWindow?
9
+ private let makeContent: @MainActor () -> NSView
10
+
11
+ public init(content: @escaping @MainActor () -> NSView) {
12
+ makeContent = content
13
+ }
14
+
15
+ @discardableResult
16
+ public func show() -> NSWindow {
17
+ let settings: NSWindow
18
+ if let window {
19
+ settings = window
20
+ } else {
21
+ settings = NSWindow(
22
+ contentRect: NSRect(x: 0, y: 0, width: 520, height: 500),
23
+ styleMask: [.titled, .closable],
24
+ backing: .buffered,
25
+ defer: false
26
+ )
27
+ settings.title = "Hasna Recordings Settings"
28
+ settings.isReleasedWhenClosed = false
29
+ settings.isOpaque = false
30
+ settings.backgroundColor = .clear
31
+ settings.titlebarAppearsTransparent = true
32
+ let content = makeContent()
33
+ let backdrop = NSHostingView(rootView: RecordingGlassBackground())
34
+ backdrop.frame = content.bounds
35
+ backdrop.autoresizingMask = [.width, .height]
36
+ content.addSubview(backdrop, positioned: .below, relativeTo: nil)
37
+ settings.contentView = content
38
+ settings.center()
39
+ window = settings
40
+ }
41
+ // Activation is needed when invoked from a menu-bar popover. Preserve the
42
+ // activation policy so a bar-only app stays out of the Dock and app switcher.
43
+ NSApplication.shared.activate()
44
+ NSRunningApplication.current.activate(options: [.activateAllWindows])
45
+ settings.makeKeyAndOrderFront(nil)
46
+ return settings
47
+ }
48
+ }
@@ -0,0 +1,85 @@
1
+ import Dispatch
2
+ import Foundation
3
+ import Testing
4
+ @testable import RecordingsLib
5
+
6
+ @Suite("Blocking operation scheduling")
7
+ struct BlockingOperationTests {
8
+ private final class Observation: @unchecked Sendable {
9
+ let entered = DispatchSemaphore(value: 0)
10
+ let release = DispatchSemaphore(value: 0)
11
+ let peer = DispatchSemaphore(value: 0)
12
+ private let lock = NSLock()
13
+ private var result = (entered: false, peerBeforeRelease: false)
14
+
15
+ func record(entered: Bool, peerBeforeRelease: Bool) {
16
+ lock.lock()
17
+ result = (entered, peerBeforeRelease)
18
+ lock.unlock()
19
+ }
20
+
21
+ func snapshot() -> (entered: Bool, peerBeforeRelease: Bool) {
22
+ lock.lock()
23
+ defer { lock.unlock() }
24
+ return result
25
+ }
26
+ }
27
+
28
+ @Test("blocking work leaves executor progress available and drains after cancellation", arguments: [false, true], [false, true])
29
+ func peerProgressWhileBlocked(cancel: Bool, delayedSubmission: Bool) async {
30
+ let observation = Observation()
31
+ // A single buffered handoff gives the operation its already-created Task handle
32
+ // without blocking a cooperative worker or capturing an uninitialized variable.
33
+ let handoff = AsyncStream<Task<(Int, Bool), Never>>.makeStream(bufferingPolicy: .bufferingNewest(1))
34
+ let work = Task.detached {
35
+ var handles = handoff.stream.makeAsyncIterator()
36
+ guard let ownTask = await handles.next() else {
37
+ preconditionFailure("Fixture task handle was not supplied")
38
+ }
39
+ // Admission is deliberately slower than the former two-second entry timer.
40
+ // Only progress while the synchronous operation is held is under observation.
41
+ if delayedSubmission { try? await Task.sleep(for: .seconds(3)) }
42
+ let value = await BlockingOperation.run {
43
+ observation.entered.signal()
44
+ // Start the observation inside the operation, after entry. This independent
45
+ // thread always releases the gate, even when the peer misses its deadline.
46
+ Thread.detachNewThread {
47
+ defer { observation.release.signal() }
48
+ let entered = observation.entered.wait(timeout: .now()) == .success
49
+ if cancel { ownTask.cancel() }
50
+ if entered {
51
+ Task.detached { observation.peer.signal() }
52
+ }
53
+ let progressed = entered && observation.peer.wait(timeout: .now() + .seconds(1)) == .success
54
+ observation.record(entered: entered, peerBeforeRelease: progressed)
55
+ }
56
+ observation.release.wait()
57
+ return 7
58
+ }
59
+ return (value, Task.isCancelled)
60
+ }
61
+ handoff.continuation.yield(work)
62
+ handoff.continuation.finish()
63
+ let (value, wasCancelled) = await work.value
64
+ let observed = observation.snapshot()
65
+ #expect(observed.entered)
66
+ #expect(observed.peerBeforeRelease)
67
+ #expect(value == 7)
68
+ #expect(wasCancelled == cancel)
69
+ }
70
+
71
+ private enum FixtureFailure: Error { case expected }
72
+
73
+ @Test("throwing blocking work preserves its exact error")
74
+ func preservesError() async {
75
+ do {
76
+ let _: Int = try await BlockingOperation.runThrowing(qos: .utility) {
77
+ throw FixtureFailure.expected
78
+ }
79
+ Issue.record("Blocking operation discarded its error")
80
+ } catch FixtureFailure.expected {
81
+ } catch {
82
+ Issue.record("Blocking operation replaced its error: \(error)")
83
+ }
84
+ }
85
+ }