@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.
- package/README.md +279 -43
- package/bun.lock +37 -38
- package/contracts/v1/fixtures.json +1206 -0
- package/dist/cli/index.js +1909 -619
- package/dist/cli/macos-shortcut.d.ts +2 -2
- package/dist/contracts/hosted-v1.d.ts +105 -0
- package/dist/contracts/hosted-v1.d.ts.map +1 -0
- package/dist/contracts/hosted-v1.js +41 -0
- package/dist/contracts/stream-v1.d.ts +117 -0
- package/dist/contracts/stream-v1.d.ts.map +1 -0
- package/dist/contracts/stream-v1.js +35 -0
- package/dist/db/database.d.ts.map +1 -1
- package/dist/db/pg-migrations.d.ts.map +1 -1
- package/dist/db/recordings.d.ts +6 -0
- package/dist/db/recordings.d.ts.map +1 -1
- package/dist/hosted/index.d.ts +58 -0
- package/dist/hosted/index.d.ts.map +1 -0
- package/dist/hosted/index.js +266 -0
- package/dist/hosted/transport.d.ts +36 -0
- package/dist/hosted/transport.d.ts.map +1 -0
- package/dist/hosted-v1-aavn7ktb.js +4114 -0
- package/dist/hosted-v1-gdr9extc.js +84 -0
- package/dist/http/client.d.ts +183 -22
- package/dist/http/client.d.ts.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2879 -2288
- package/dist/lib/audio-artifact-storage.d.ts +98 -0
- package/dist/lib/audio-artifact-storage.d.ts.map +1 -0
- package/dist/lib/capture-probe.d.ts +3 -3
- package/dist/lib/capture-probe.d.ts.map +1 -1
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/local-opt-in.d.ts +132 -0
- package/dist/lib/local-opt-in.d.ts.map +1 -0
- package/dist/lib/macos-bundle.d.ts +1 -1
- package/dist/lib/persistence-probe.d.ts +56 -17
- package/dist/lib/persistence-probe.d.ts.map +1 -1
- package/dist/lib/transcriber.d.ts.map +1 -1
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +1713 -459
- package/dist/sdk/index.d.ts +13 -5
- package/dist/sdk/index.d.ts.map +1 -1
- package/dist/sdk/index.js +904 -1
- package/dist/sdk/resolve.d.ts +53 -0
- package/dist/sdk/resolve.d.ts.map +1 -0
- package/dist/sdk/v1.generated.d.ts +6 -0
- package/dist/sdk/v1.generated.d.ts.map +1 -1
- package/dist/server/client-ip.d.ts +39 -0
- package/dist/server/client-ip.d.ts.map +1 -0
- package/dist/server/cloud-config.d.ts +12 -0
- package/dist/server/cloud-config.d.ts.map +1 -1
- package/dist/server/cloud-readiness.d.ts.map +1 -1
- package/dist/server/cloud.d.ts +20 -2
- package/dist/server/cloud.d.ts.map +1 -1
- package/dist/server/index.js +590 -287
- package/dist/server/migrate-command.d.ts +1 -1
- package/dist/server/migrate-command.d.ts.map +1 -1
- package/dist/server/openapi.d.ts +21 -0
- package/dist/server/openapi.d.ts.map +1 -1
- package/dist/server/repo.d.ts.map +1 -1
- package/dist/server/serve.d.ts.map +1 -1
- package/dist/storage.d.ts +2 -2
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +1358 -874
- package/dist/store.d.ts +12 -3
- package/dist/store.d.ts.map +1 -1
- package/dist/types/index.d.ts +15 -0
- package/dist/types/index.d.ts.map +1 -1
- package/docs/hosted-sdk.md +71 -0
- package/docs/wire-contracts.md +24 -0
- package/package.json +33 -11
- package/packaging/macos/build_release_pkg.sh +3 -3
- package/packaging/macos/managed_bootstrap.sh +4 -4
- package/packaging/macos/scripts/postinstall +2 -2
- package/packaging/macos/scripts/preinstall +2 -2
- package/scripts/build_companion_cli.sh +7 -1
- package/scripts/ci-linux-suite.ts +23 -13
- package/scripts/install_macos_app.sh +27 -24
- package/scripts/macos_artifact.ts +60 -49
- package/scripts/migrate.ts +4 -20
- package/scripts/native/prebuilds/darwin-universal/recordings_fs_guard.node +0 -0
- package/scripts/native/recordings_fs_guard.c +36 -4
- package/scripts/native-core-receipt.py +171 -0
- package/scripts/native_fs_guard.ts +2 -0
- package/scripts/release-suite-gate.ts +227 -150
- package/scripts/resolve_tailscale_cli.sh +24 -3
- package/scripts/smoke_macos_app.sh +21 -21
- package/src/native/Recordings/App/ContentView.swift +53 -68
- package/src/native/Recordings/App/MenuBarStatusView.swift +171 -61
- package/src/native/Recordings/App/RecordWorkspaceView.swift +47 -449
- package/src/native/Recordings/App/RecorderSettingsView.swift +75 -0
- package/src/native/Recordings/App/RecordingDetailView.swift +3 -4
- package/src/native/Recordings/App/RecordingsApp.swift +185 -9
- package/src/native/Recordings/App/RecordingsListView.swift +60 -112
- package/src/native/Recordings/App/RecordingsStore.swift +124 -109
- package/src/native/Recordings/App/Theme.swift +73 -61
- package/src/native/Recordings/Package.swift +1 -0
- package/src/native/Recordings/RecordingsLib/BlockingOperation.swift +29 -0
- package/src/native/Recordings/RecordingsLib/CaptureMonitor.swift +65 -0
- package/src/native/Recordings/RecordingsLib/Info.plist +3 -3
- package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +75 -21
- package/src/native/Recordings/RecordingsLib/ProjectStore.swift +4 -4
- package/src/native/Recordings/RecordingsLib/Recording.swift +14 -2
- package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +539 -89
- package/src/native/Recordings/RecordingsLib/RecordingGlassBackground.swift +34 -0
- package/src/native/Recordings/RecordingsLib/RecordingPasteTarget.swift +114 -0
- package/src/native/Recordings/RecordingsLib/RecordingProvider.swift +112 -0
- package/src/native/Recordings/RecordingsLib/RecordingsCLI.swift +17 -10
- package/src/native/Recordings/RecordingsLib/ServiceAPIConfiguration.swift +107 -0
- package/src/native/Recordings/RecordingsLib/ServiceConnectionView.swift +76 -0
- package/src/native/Recordings/RecordingsLib/SettingsView.swift +45 -149
- package/src/native/Recordings/RecordingsLib/SettingsWindowController.swift +48 -0
- package/src/native/Recordings/RecordingsTests/BlockingOperationTests.swift +85 -0
- package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +454 -76
- package/src/native/Recordings/RecordingsTests/OpenAIAPIKeyStoreTests.swift +43 -38
- package/src/native/Recordings/RecordingsTests/PipeClosureFixture.swift +215 -0
- package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +10 -10
- package/src/native/Recordings/RecordingsTests/RecorderSurfaceTests.swift +60 -0
- package/src/native/Recordings/RecordingsTests/RecordingDateTests.swift +26 -0
- package/src/native/Recordings/RecordingsTests/RecordingEngineDeliveryTests.swift +1 -1
- package/src/native/Recordings/RecordingsTests/RecordingFrozenPasteTargetTests.swift +50 -0
- package/src/native/Recordings/RecordingsTests/RecordingPasteTargetTrackerTests.swift +48 -0
- package/src/native/Recordings/RecordingsTests/RecordingProviderTests.swift +308 -0
- package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +1 -1
- package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +123 -21
- package/src/native/Recordings/RecordingsTests/ServiceAPIConfigurationTests.swift +75 -0
- package/src/native/Recordings/RecordingsTests/SettingsWindowControllerTests.swift +28 -0
- package/src/native/Recordings/RecordingsTests/TestHomeDirectory.swift +5 -1
- package/src/native/Recordings/Updater/BootstrapPreflight/BootstrapPreflightMain.swift +1 -1
- package/src/native/Recordings/Updater/Broker/ApplicationNamespace.swift +2 -2
- package/src/native/Recordings/Updater/Broker/BrokerMain.swift +1 -1
- package/src/native/Recordings/Updater/Broker/CanonicalTreeCopy.swift +1 -1
- package/src/native/Recordings/Updater/Broker/InstallJournal.swift +1 -1
- package/src/native/Recordings/Updater/BrokerTests/ActivationRecoveryPolicyTests.swift +2 -2
- package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +1 -1
- package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +2 -2
- package/src/native/Recordings/build.sh +8 -7
- package/dist/__tests__/helpers/installer-guard-execution.d.ts +0 -22
- package/dist/__tests__/helpers/installer-guard-execution.d.ts.map +0 -1
- package/dist/__tests__/helpers/installer-preflight.d.ts +0 -22
- package/dist/__tests__/helpers/installer-preflight.d.ts.map +0 -1
- package/dist/__tests__/helpers/native-fs-guard.d.ts +0 -2
- package/dist/__tests__/helpers/native-fs-guard.d.ts.map +0 -1
- package/dist/__tests__/helpers/source-assertions.d.ts +0 -171
- package/dist/__tests__/helpers/source-assertions.d.ts.map +0 -1
- package/dist/__tests__/preload.d.ts +0 -2
- package/dist/__tests__/preload.d.ts.map +0 -1
- package/src/native/Recordings/App/SidebarView.swift +0 -212
|
@@ -4,11 +4,114 @@ import Testing
|
|
|
4
4
|
@testable import RecordingsLib
|
|
5
5
|
|
|
6
6
|
struct CLIRunnerTests {
|
|
7
|
+
private final class DeadlineClock: @unchecked Sendable {
|
|
8
|
+
private let lock = NSLock()
|
|
9
|
+
private var nanoseconds: UInt64 = 1_000_000_000
|
|
10
|
+
|
|
11
|
+
func now() -> UInt64 {
|
|
12
|
+
lock.lock()
|
|
13
|
+
defer { lock.unlock() }
|
|
14
|
+
return nanoseconds
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
func advance(seconds: UInt64) {
|
|
18
|
+
lock.lock()
|
|
19
|
+
defer { lock.unlock() }
|
|
20
|
+
nanoseconds += seconds * 1_000_000_000
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
@Test("a fractional supplied deadline respects its original budget with a nonadvancing clock")
|
|
25
|
+
func fractionalDeadlineDoesNotExceedOriginalBudget() throws {
|
|
26
|
+
let clock = DeadlineClock()
|
|
27
|
+
let budget = 3.0000000001
|
|
28
|
+
let result = try CLIRunner.runExecutable(
|
|
29
|
+
"/usr/bin/true", arguments: [], environment: [:],
|
|
30
|
+
totalWallClockBudget: budget,
|
|
31
|
+
wallClockDeadline: .init(after: budget, now: { clock.now() })
|
|
32
|
+
)
|
|
33
|
+
#expect(result.terminationStatus == 0)
|
|
34
|
+
#expect(result.stdout.isEmpty)
|
|
35
|
+
#expect(result.stderr.isEmpty)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
@Test("rewrite admission consumes its original budget before the blocking worker starts")
|
|
39
|
+
func rewriteAdmissionConsumesOriginalBudget() async {
|
|
40
|
+
let clock = DeadlineClock()
|
|
41
|
+
let operation = RecordingEngine.makeCommandRewriteOperation(
|
|
42
|
+
args: ["rewrite-selection"], home: "/fictional-test-home",
|
|
43
|
+
runCLI: { args, home, budget in
|
|
44
|
+
#expect(args == ["rewrite-selection"])
|
|
45
|
+
#expect(home == "/fictional-test-home")
|
|
46
|
+
#expect(budget == 7)
|
|
47
|
+
return "fictional rewrite"
|
|
48
|
+
},
|
|
49
|
+
deadline: .init(after: 10, now: { clock.now() })
|
|
50
|
+
)
|
|
51
|
+
clock.advance(seconds: 3)
|
|
52
|
+
#expect(await BlockingOperation.run(operation) == "fictional rewrite")
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@Test("expired and cleanup-only rewrite admission never calls the command seam", arguments: [8, 10, 11])
|
|
56
|
+
func expiredRewriteAdmissionAvoidsCommand(seconds: UInt64) async {
|
|
57
|
+
let clock = DeadlineClock()
|
|
58
|
+
let operation = RecordingEngine.makeCommandRewriteOperation(
|
|
59
|
+
args: ["rewrite-selection"], home: "/fictional-test-home",
|
|
60
|
+
runCLI: { _, _, _ in
|
|
61
|
+
Issue.record("An exhausted queued rewrite invoked its command seam")
|
|
62
|
+
return "unexpected command"
|
|
63
|
+
},
|
|
64
|
+
deadline: .init(after: 10, now: { clock.now() })
|
|
65
|
+
)
|
|
66
|
+
clock.advance(seconds: seconds)
|
|
67
|
+
let output = await BlockingOperation.run(operation)
|
|
68
|
+
#expect(output.hasPrefix("ERROR:"))
|
|
69
|
+
#expect(output.contains("timed out"))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
@Test("command preparation consumes the original deadline and cannot spawn after exhaustion", arguments: [false, true], [0, 2, 3])
|
|
73
|
+
func preparationDeadlineAvoidsExpiredSpawn(expiredBeforePreparation: Bool, spentSeconds: UInt64) throws {
|
|
74
|
+
let home = URL(fileURLWithPath: makeIsolatedTestHome("rewrite-preparation-deadline"), isDirectory: true)
|
|
75
|
+
defer { try? FileManager.default.removeItem(at: home) }
|
|
76
|
+
let executable = home.appendingPathComponent(".bun/bin/recordings")
|
|
77
|
+
let marker = home.appendingPathComponent("spawned")
|
|
78
|
+
try FileManager.default.createDirectory(at: executable.deletingLastPathComponent(), withIntermediateDirectories: true)
|
|
79
|
+
try """
|
|
80
|
+
#!/bin/sh
|
|
81
|
+
printf spawned > "$1"
|
|
82
|
+
printf complete
|
|
83
|
+
""".write(to: executable, atomically: true, encoding: .utf8)
|
|
84
|
+
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path)
|
|
85
|
+
let clock = DeadlineClock()
|
|
86
|
+
let deadline = CLIRunner.WallClockDeadline(after: 3, now: { clock.now() })
|
|
87
|
+
if expiredBeforePreparation { clock.advance(seconds: spentSeconds) }
|
|
88
|
+
var preparations = 0
|
|
89
|
+
let output = CLIRunner.run(
|
|
90
|
+
[marker.path], home: home.path, timeout: 3, totalWallClockBudget: 3,
|
|
91
|
+
wallClockDeadline: deadline,
|
|
92
|
+
environmentProvider: {
|
|
93
|
+
preparations += 1
|
|
94
|
+
if !expiredBeforePreparation { clock.advance(seconds: spentSeconds) }
|
|
95
|
+
return [:]
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
if spentSeconds == 0 {
|
|
99
|
+
#expect(preparations == 1)
|
|
100
|
+
#expect(output == "complete")
|
|
101
|
+
#expect(try String(contentsOf: marker, encoding: .utf8) == "spawned")
|
|
102
|
+
} else {
|
|
103
|
+
#expect(preparations == (expiredBeforePreparation ? 0 : 1))
|
|
104
|
+
#expect(output.hasPrefix("ERROR:"))
|
|
105
|
+
#expect(output.contains("timed out"))
|
|
106
|
+
#expect(!FileManager.default.fileExists(atPath: marker.path))
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
7
110
|
@Test("bundled CLI takes precedence over a stale user installation")
|
|
8
111
|
func bundledCLIIsPreferred() throws {
|
|
9
112
|
let root = FileManager.default.temporaryDirectory
|
|
10
113
|
.appendingPathComponent("recordings-bundled-cli-\(UUID().uuidString)")
|
|
11
|
-
let app = root.appendingPathComponent("
|
|
114
|
+
let app = root.appendingPathComponent("Hasna Recordings.app")
|
|
12
115
|
let helper = app.appendingPathComponent("Contents/Helpers/recordings")
|
|
13
116
|
let home = root.appendingPathComponent("home")
|
|
14
117
|
let external = home.appendingPathComponent(".bun/bin/recordings")
|
|
@@ -34,7 +137,7 @@ struct CLIRunnerTests {
|
|
|
34
137
|
func packagedAppDoesNotUseGlobalFallback() throws {
|
|
35
138
|
let root = FileManager.default.temporaryDirectory
|
|
36
139
|
.appendingPathComponent("recordings-missing-companion-\(UUID().uuidString)")
|
|
37
|
-
let app = root.appendingPathComponent("
|
|
140
|
+
let app = root.appendingPathComponent("Hasna Recordings.app")
|
|
38
141
|
let home = root.appendingPathComponent("home")
|
|
39
142
|
let external = home.appendingPathComponent(".bun/bin/recordings")
|
|
40
143
|
try FileManager.default.createDirectory(at: app, withIntermediateDirectories: true)
|
|
@@ -66,6 +169,74 @@ struct CLIRunnerTests {
|
|
|
66
169
|
#expect(result.stderr.first == "e")
|
|
67
170
|
}
|
|
68
171
|
|
|
172
|
+
@Test("command spawn inherits only its standard streams, never another capture pipe")
|
|
173
|
+
func commandSpawnDoesNotInheritUnrelatedPipe() throws {
|
|
174
|
+
// Keep an unrelated pipe deliberately not close-on-exec. This is the exact
|
|
175
|
+
// state another reader can expose between pipe() and its FD_CLOEXEC setup.
|
|
176
|
+
var unrelated: [Int32] = [0, 0]
|
|
177
|
+
try #require(Darwin.pipe(&unrelated) == 0)
|
|
178
|
+
defer { for descriptor in unrelated { Darwin.close(descriptor) } }
|
|
179
|
+
// Do not turn a previously closed standard stream into this probe pipe.
|
|
180
|
+
for index in unrelated.indices where unrelated[index] < 3 {
|
|
181
|
+
let moved = Darwin.fcntl(unrelated[index], F_DUPFD, 3)
|
|
182
|
+
try #require(moved >= 3)
|
|
183
|
+
Darwin.close(unrelated[index])
|
|
184
|
+
unrelated[index] = moved
|
|
185
|
+
}
|
|
186
|
+
try #require(Darwin.fcntl(unrelated[0], F_GETFD) & FD_CLOEXEC == 0)
|
|
187
|
+
|
|
188
|
+
let home = URL(fileURLWithPath: makeIsolatedTestHome("spawn-inheritance"), isDirectory: true)
|
|
189
|
+
defer { try? FileManager.default.removeItem(at: home) }
|
|
190
|
+
let source = home.appendingPathComponent("probe.c")
|
|
191
|
+
let executable = home.appendingPathComponent("probe")
|
|
192
|
+
try """
|
|
193
|
+
#include <sys/stat.h>
|
|
194
|
+
#include <unistd.h>
|
|
195
|
+
#include <errno.h>
|
|
196
|
+
#include <fcntl.h>
|
|
197
|
+
#include <stdio.h>
|
|
198
|
+
#include <stdlib.h>
|
|
199
|
+
int main(int argc, char **argv) {
|
|
200
|
+
if (argc != 5) return 64;
|
|
201
|
+
struct stat input;
|
|
202
|
+
int inputResult = fstat(STDIN_FILENO, &input);
|
|
203
|
+
if (atoi(argv[2])) {
|
|
204
|
+
if (inputResult != 0 ||
|
|
205
|
+
(unsigned long long)input.st_dev != strtoull(argv[3], 0, 10) ||
|
|
206
|
+
(unsigned long long)input.st_ino != strtoull(argv[4], 0, 10)) return 66;
|
|
207
|
+
} else if (inputResult == 0) return 67;
|
|
208
|
+
fputs("stdout-preserved", stdout);
|
|
209
|
+
fputs("stderr-preserved", stderr);
|
|
210
|
+
errno = 0;
|
|
211
|
+
if (fcntl(atoi(argv[1]), F_GETFD) != -1 || errno != EBADF) return 65;
|
|
212
|
+
return 0;
|
|
213
|
+
}
|
|
214
|
+
""".write(to: source, atomically: true, encoding: .utf8)
|
|
215
|
+
let compiled = try CLIRunner.runExecutable(
|
|
216
|
+
"/usr/bin/cc", arguments: ["-o", executable.path, source.path],
|
|
217
|
+
environment: ["TMPDIR": home.path]
|
|
218
|
+
)
|
|
219
|
+
try #require(compiled.terminationStatus == 0)
|
|
220
|
+
|
|
221
|
+
// Inspect only stdin metadata; never read or print the caller's input.
|
|
222
|
+
var input = stat()
|
|
223
|
+
let inputFlags = Darwin.fcntl(STDIN_FILENO, F_GETFD)
|
|
224
|
+
let inheritsInput = inputFlags >= 0 && inputFlags & FD_CLOEXEC == 0
|
|
225
|
+
&& !unrelated.contains(STDIN_FILENO)
|
|
226
|
+
if inheritsInput { try #require(Darwin.fstat(STDIN_FILENO, &input) == 0) }
|
|
227
|
+
let result = try CLIRunner.runExecutable(
|
|
228
|
+
executable.path,
|
|
229
|
+
arguments: [
|
|
230
|
+
String(unrelated[0]), inheritsInput ? "1" : "0",
|
|
231
|
+
String(UInt64(bitPattern: Int64(input.st_dev))), String(input.st_ino),
|
|
232
|
+
],
|
|
233
|
+
environment: [:]
|
|
234
|
+
)
|
|
235
|
+
#expect(result.terminationStatus == 0)
|
|
236
|
+
#expect(result.stdout == "stdout-preserved")
|
|
237
|
+
#expect(result.stderr == "stderr-preserved")
|
|
238
|
+
}
|
|
239
|
+
|
|
69
240
|
@Test("process runner observes immediate exits without false timeouts")
|
|
70
241
|
func observesImmediateExits() throws {
|
|
71
242
|
for _ in 0..<25 {
|
|
@@ -336,8 +507,7 @@ struct CLIRunnerTests {
|
|
|
336
507
|
@Test("a hung rewrite helper returns within the 10 s interactive budget, cleanup included")
|
|
337
508
|
@MainActor
|
|
338
509
|
func hungRewriteHelperReturnsWithinInteractiveBudget() async throws {
|
|
339
|
-
let home =
|
|
340
|
-
.appendingPathComponent("recordings-rewrite-budget-\(UUID().uuidString)")
|
|
510
|
+
let home = URL(fileURLWithPath: makeIsolatedTestHome("rewrite-budget"), isDirectory: true)
|
|
341
511
|
let bin = home.appendingPathComponent(".bun/bin")
|
|
342
512
|
let pidFile = home.appendingPathComponent("pid")
|
|
343
513
|
try FileManager.default.createDirectory(at: bin, withIntermediateDirectories: true)
|
|
@@ -357,18 +527,27 @@ struct CLIRunnerTests {
|
|
|
357
527
|
}
|
|
358
528
|
|
|
359
529
|
// The production seam exactly as runCommandMode uses it: the engine's default
|
|
360
|
-
// commandCLI closure on
|
|
530
|
+
// commandCLI closure on the blocking queue, budgeted by commandRewriteTimeout — the
|
|
361
531
|
// observable wall time must stay inside the budget even though the helper never
|
|
362
532
|
// exits on its own and ignores SIGTERM.
|
|
363
|
-
let runCLI = RecordingEngine(homePath: home.path).commandCLI
|
|
533
|
+
let runCLI = RecordingEngine(homePath: home.path, installsGlobalHandlers: false).commandCLI
|
|
364
534
|
let homePath = home.path
|
|
365
535
|
let startedAt = ContinuousClock.now
|
|
366
|
-
let
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
let
|
|
536
|
+
let operation = RecordingEngine.makeCommandRewriteOperation(
|
|
537
|
+
args: ["rewrite-selection"], home: homePath, runCLI: runCLI
|
|
538
|
+
)
|
|
539
|
+
let (output, workerStartedAt, workerFinishedAt) = await BlockingOperation.run {
|
|
540
|
+
let workerStartedAt = ContinuousClock.now
|
|
541
|
+
let output = operation()
|
|
542
|
+
return (output, workerStartedAt, ContinuousClock.now)
|
|
543
|
+
}
|
|
544
|
+
let resumedAt = ContinuousClock.now
|
|
545
|
+
let elapsed = resumedAt - startedAt
|
|
546
|
+
// Report only on a failed elapsed assertion. Admission includes the executor
|
|
547
|
+
// and dispatch queue hops; the worker interval includes CLI preparation/cleanup.
|
|
548
|
+
let timing: Comment = "CLI phases: admission=\(workerStartedAt - startedAt), worker=\(workerFinishedAt - workerStartedAt), resume=\(resumedAt - workerFinishedAt)"
|
|
370
549
|
|
|
371
|
-
#expect(elapsed < .seconds(RecordingEngine.commandRewriteTimeout))
|
|
550
|
+
#expect(elapsed < .seconds(RecordingEngine.commandRewriteTimeout), timing)
|
|
372
551
|
#expect(output.hasPrefix("ERROR:"))
|
|
373
552
|
#expect(output.contains("timed out"))
|
|
374
553
|
}
|
|
@@ -376,8 +555,7 @@ struct CLIRunnerTests {
|
|
|
376
555
|
@Test("an exhausted rewrite deadline with pipes held by an escaped descendant returns under the public ceiling")
|
|
377
556
|
@MainActor
|
|
378
557
|
func exhaustedRewriteDeadlineWithHeldPipesReturnsUnderCeiling() async throws {
|
|
379
|
-
let home =
|
|
380
|
-
.appendingPathComponent("recordings-rewrite-exhaustion-\(UUID().uuidString)")
|
|
558
|
+
let home = URL(fileURLWithPath: makeIsolatedTestHome("rewrite-exhaustion"), isDirectory: true)
|
|
381
559
|
let bin = home.appendingPathComponent(".bun/bin")
|
|
382
560
|
let leaderPidFile = home.appendingPathComponent("leader-pid")
|
|
383
561
|
let holderPidFile = home.appendingPathComponent("holder-pid")
|
|
@@ -454,19 +632,28 @@ struct CLIRunnerTests {
|
|
|
454
632
|
// also runs to exhaustion. The observable wall time must still land under the
|
|
455
633
|
// public ceiling — the return margin absorbs spawn, poll overshoot, capture
|
|
456
634
|
// shutdown, and the task hops.
|
|
457
|
-
let runCLI = RecordingEngine(homePath: home.path).commandCLI
|
|
635
|
+
let runCLI = RecordingEngine(homePath: home.path, installsGlobalHandlers: false).commandCLI
|
|
458
636
|
let homePath = home.path
|
|
459
637
|
let startedAt = ContinuousClock.now
|
|
460
|
-
let
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
let
|
|
638
|
+
let operation = RecordingEngine.makeCommandRewriteOperation(
|
|
639
|
+
args: ["rewrite-selection"], home: homePath, runCLI: runCLI
|
|
640
|
+
)
|
|
641
|
+
let (output, workerStartedAt, workerFinishedAt) = await BlockingOperation.run {
|
|
642
|
+
let workerStartedAt = ContinuousClock.now
|
|
643
|
+
let output = operation()
|
|
644
|
+
return (output, workerStartedAt, ContinuousClock.now)
|
|
645
|
+
}
|
|
646
|
+
let resumedAt = ContinuousClock.now
|
|
647
|
+
let elapsed = resumedAt - startedAt
|
|
648
|
+
// Report only on a failed elapsed assertion. Admission includes the executor
|
|
649
|
+
// and dispatch queue hops; the worker interval includes CLI preparation/cleanup.
|
|
650
|
+
let timing: Comment = "CLI phases: admission=\(workerStartedAt - startedAt), worker=\(workerFinishedAt - workerStartedAt), resume=\(resumedAt - workerFinishedAt)"
|
|
464
651
|
|
|
465
652
|
// Upper bound is the public promise, with the ~1 s return margin left as CI
|
|
466
653
|
// tolerance above the internal deadline; the lower bound proves the deadline chain
|
|
467
654
|
// really ran to exhaustion instead of the helper exiting early.
|
|
468
|
-
#expect(elapsed < .seconds(RecordingEngine.commandRewriteTimeout))
|
|
469
|
-
#expect(elapsed > .seconds(8.4))
|
|
655
|
+
#expect(elapsed < .seconds(RecordingEngine.commandRewriteTimeout), timing)
|
|
656
|
+
#expect(elapsed > .seconds(8.4), timing)
|
|
470
657
|
#expect(output.hasPrefix("ERROR:"))
|
|
471
658
|
#expect(output.contains("timed out"))
|
|
472
659
|
|
|
@@ -878,12 +1065,27 @@ struct CLIRunnerTests {
|
|
|
878
1065
|
#expect(injectedFailureCount > 0)
|
|
879
1066
|
}
|
|
880
1067
|
|
|
881
|
-
@Test("a reaper
|
|
1068
|
+
@Test("a warmed reaper-failure fixture still joins capture readers and closes silent escaped pipes")
|
|
882
1069
|
func reapFailureStillFinishesCaptures() throws {
|
|
883
1070
|
struct InjectedReapFailure: Error {}
|
|
884
1071
|
let failureGate = NSLock()
|
|
885
1072
|
var shouldFailCapture = false
|
|
886
1073
|
var injectedFailures = 0
|
|
1074
|
+
var closeObservations: [String] = []
|
|
1075
|
+
let liveCaptureCalls = CLIRunner.PipeCaptureReader.SystemCalls.live
|
|
1076
|
+
var ownedReadDescriptors: Set<Int32> = []
|
|
1077
|
+
var successfulReadCloses: [Int32: UInt64] = [:]
|
|
1078
|
+
var returnedAt: UInt64 = 0
|
|
1079
|
+
var phaseTimes: [String: UInt64] = [:]
|
|
1080
|
+
var pipeTimes: [(read: Int32, write: Int32, at: UInt64)] = []
|
|
1081
|
+
var closeTimes: [(descriptor: Int32, result: Int32, at: UInt64)] = []
|
|
1082
|
+
var firstInjectedReadTimes: [Int32: UInt64] = [:]
|
|
1083
|
+
func recordPhase(_ name: String) {
|
|
1084
|
+
let at = PipeClosureFixture.now()
|
|
1085
|
+
failureGate.lock()
|
|
1086
|
+
phaseTimes[name] = at
|
|
1087
|
+
failureGate.unlock()
|
|
1088
|
+
}
|
|
887
1089
|
|
|
888
1090
|
let root = FileManager.default.temporaryDirectory
|
|
889
1091
|
.appendingPathComponent("recordings-reap-failure-\(UUID().uuidString)")
|
|
@@ -891,6 +1093,10 @@ struct CLIRunnerTests {
|
|
|
891
1093
|
let executable = root.appendingPathComponent("silent-holder")
|
|
892
1094
|
let holderPidFile = root.appendingPathComponent("holder-pid")
|
|
893
1095
|
let markerFile = root.appendingPathComponent("holder-probe")
|
|
1096
|
+
let detailsFile = root.appendingPathComponent("holder-probe-details")
|
|
1097
|
+
let deadlineFile = root.appendingPathComponent("holder-probe-deadline")
|
|
1098
|
+
let leaderTimingFile = root.appendingPathComponent("leader-timing")
|
|
1099
|
+
let holderTimingFile = root.appendingPathComponent("holder-timing")
|
|
894
1100
|
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
|
895
1101
|
defer {
|
|
896
1102
|
if let holderPid = Self.readPID(from: holderPidFile) {
|
|
@@ -899,27 +1105,75 @@ struct CLIRunnerTests {
|
|
|
899
1105
|
try? FileManager.default.removeItem(at: root)
|
|
900
1106
|
}
|
|
901
1107
|
|
|
1108
|
+
// On failure, identify only descriptors that share these two fixture pipes.
|
|
1109
|
+
// Bounded numeric diagnostics contain no process names, file paths or contents.
|
|
902
1110
|
try """
|
|
903
1111
|
#include <errno.h>
|
|
1112
|
+
#include <libproc.h>
|
|
1113
|
+
#include <sys/proc_info.h>
|
|
904
1114
|
#include <signal.h>
|
|
905
1115
|
#include <stdio.h>
|
|
906
1116
|
#include <stdlib.h>
|
|
1117
|
+
#include <string.h>
|
|
907
1118
|
#include <sys/wait.h>
|
|
908
1119
|
#include <time.h>
|
|
909
1120
|
#include <unistd.h>
|
|
910
1121
|
|
|
1122
|
+
\(PipeClosureFixture.observerSource)
|
|
1123
|
+
|
|
911
1124
|
static volatile sig_atomic_t probeRequested = 0;
|
|
912
1125
|
static void requestProbe(int signo) { (void)signo; probeRequested = 1; }
|
|
913
1126
|
|
|
1127
|
+
static void recordFixtureTiming(const char *path, uint64_t entered, uint64_t forked, uint64_t milestone) {
|
|
1128
|
+
FILE *timing = fopen(path, "w");
|
|
1129
|
+
if (!timing) _exit(69);
|
|
1130
|
+
fprintf(timing, "%llu %llu %llu", (unsigned long long)entered,
|
|
1131
|
+
(unsigned long long)forked, (unsigned long long)milestone);
|
|
1132
|
+
if (fclose(timing)) _exit(69);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
static void reportPipeOwners(FILE *file, int stream) {
|
|
1136
|
+
struct pipe_fdinfo target = {0};
|
|
1137
|
+
int inspected = proc_pidfdinfo(getpid(), stream, PROC_PIDFDPIPEINFO, &target, sizeof(target));
|
|
1138
|
+
fprintf(file, "; target fd=%d result=%d handle=%llx peer=%llx", stream, inspected,
|
|
1139
|
+
(unsigned long long)target.pipeinfo.pipe_handle, (unsigned long long)target.pipeinfo.pipe_peerhandle);
|
|
1140
|
+
if (inspected != sizeof(target)) return;
|
|
1141
|
+
pid_t pids[4096];
|
|
1142
|
+
int pidBytes = proc_listpids(PROC_UID_ONLY, getuid(), pids, sizeof(pids));
|
|
1143
|
+
if (pidBytes > (int)sizeof(pids)) pidBytes = (int)sizeof(pids);
|
|
1144
|
+
int matches = 0;
|
|
1145
|
+
for (int i = 0; i < pidBytes / (int)sizeof(pid_t) && matches < 64; i++) {
|
|
1146
|
+
struct proc_fdinfo descriptors[2048];
|
|
1147
|
+
int fdBytes = proc_pidinfo(pids[i], PROC_PIDLISTFDS, 0, descriptors, sizeof(descriptors));
|
|
1148
|
+
if (fdBytes > (int)sizeof(descriptors)) fdBytes = (int)sizeof(descriptors);
|
|
1149
|
+
for (int j = 0; j < fdBytes / (int)sizeof(struct proc_fdinfo) && matches < 64; j++) {
|
|
1150
|
+
if (descriptors[j].proc_fdtype != PROX_FDTYPE_PIPE) continue;
|
|
1151
|
+
struct pipe_fdinfo candidate = {0};
|
|
1152
|
+
if (proc_pidfdinfo(pids[i], descriptors[j].proc_fd, PROC_PIDFDPIPEINFO, &candidate, sizeof(candidate)) != sizeof(candidate)) continue;
|
|
1153
|
+
if ((target.pipeinfo.pipe_handle && candidate.pipeinfo.pipe_handle == target.pipeinfo.pipe_handle) ||
|
|
1154
|
+
(target.pipeinfo.pipe_peerhandle && candidate.pipeinfo.pipe_handle == target.pipeinfo.pipe_peerhandle)) {
|
|
1155
|
+
fprintf(file, "; owner pid=%d fd=%d flags=%u status=%u handle=%llx peer=%llx", pids[i], descriptors[j].proc_fd,
|
|
1156
|
+
candidate.pfi.fi_openflags, candidate.pfi.fi_status,
|
|
1157
|
+
(unsigned long long)candidate.pipeinfo.pipe_handle, (unsigned long long)candidate.pipeinfo.pipe_peerhandle);
|
|
1158
|
+
matches++;
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
|
|
914
1164
|
int main(int argc, char **argv) {
|
|
915
|
-
|
|
1165
|
+
uint64_t enteredAt = monotonic_ns();
|
|
1166
|
+
if (argc == 2 && strcmp(argv[1], "--fixture-preflight") == 0) return 0;
|
|
1167
|
+
if (argc < 7) return 64;
|
|
916
1168
|
pid_t child = fork();
|
|
1169
|
+
uint64_t forkedAt = monotonic_ns();
|
|
917
1170
|
if (child == -1) return 65;
|
|
918
1171
|
if (child != 0) {
|
|
919
1172
|
while (access(argv[1], F_OK) == -1) {
|
|
920
1173
|
struct timespec delay = {0, 10000000};
|
|
921
1174
|
nanosleep(&delay, 0);
|
|
922
1175
|
}
|
|
1176
|
+
recordFixtureTiming(argv[5], enteredAt, forkedAt, monotonic_ns());
|
|
923
1177
|
return 0;
|
|
924
1178
|
}
|
|
925
1179
|
if (setsid() == -1) _exit(66);
|
|
@@ -929,17 +1183,13 @@ struct CLIRunnerTests {
|
|
|
929
1183
|
if (!pid) _exit(67);
|
|
930
1184
|
fprintf(pid, "%d", (int)getpid());
|
|
931
1185
|
fclose(pid);
|
|
1186
|
+
uint64_t readyClosedAt = monotonic_ns();
|
|
1187
|
+
recordFixtureTiming(argv[6], enteredAt, forkedAt, readyClosedAt);
|
|
932
1188
|
while (!probeRequested) {
|
|
933
1189
|
struct timespec delay = {0, 20000000};
|
|
934
1190
|
nanosleep(&delay, 0);
|
|
935
1191
|
}
|
|
936
|
-
|
|
937
|
-
int errBroken = (write(STDERR_FILENO, "y", 1) == -1 && errno == EPIPE);
|
|
938
|
-
FILE *marker = fopen(argv[2], "w");
|
|
939
|
-
if (!marker) _exit(68);
|
|
940
|
-
fputs(outBroken && errBroken ? "both-epipe" : "still-connected", marker);
|
|
941
|
-
fclose(marker);
|
|
942
|
-
_exit(0);
|
|
1192
|
+
_exit(observePipeClosure(argv[4], argv[2], argv[3], NULL, reportPipeOwners));
|
|
943
1193
|
}
|
|
944
1194
|
""".write(to: source, atomically: true, encoding: .utf8)
|
|
945
1195
|
let compile = Process()
|
|
@@ -949,30 +1199,113 @@ struct CLIRunnerTests {
|
|
|
949
1199
|
compile.waitUntilExit()
|
|
950
1200
|
try #require(compile.terminationStatus == 0)
|
|
951
1201
|
|
|
1202
|
+
// This nil-total-budget fixture tests warmed reaper-error lifecycle cleanup,
|
|
1203
|
+
// not a native cold-start SLA. A fresh helper has spent >2s before main while
|
|
1204
|
+
// cleanup stayed <0.61s; that cold-start cause remains unresolved. Prepare only
|
|
1205
|
+
// this exact helper once, with a separate finite budget and a retained receipt.
|
|
1206
|
+
// The other full-budget tests still execute their helpers without preflight.
|
|
1207
|
+
let fixtureBytes = try Data(contentsOf: executable)
|
|
1208
|
+
let preflightBudget = RecordingEngine.commandRewriteTimeout
|
|
1209
|
+
let digest = try CLIRunner.runExecutable(
|
|
1210
|
+
"/usr/bin/shasum", arguments: ["-a", "256", executable.path],
|
|
1211
|
+
executionTimeout: preflightBudget, totalWallClockBudget: preflightBudget
|
|
1212
|
+
)
|
|
1213
|
+
try #require(digest.terminationStatus == 0 && digest.stderr.isEmpty)
|
|
1214
|
+
let fixtureSHA256 = try #require(digest.stdout.split(whereSeparator: \.isWhitespace).first).description
|
|
1215
|
+
try #require(fixtureSHA256.count == 64 && fixtureSHA256.allSatisfy(\.isHexDigit))
|
|
1216
|
+
let preflightStartedAt = PipeClosureFixture.now()
|
|
1217
|
+
let preflight: CLIRunner.ProcessOutput
|
|
1218
|
+
do {
|
|
1219
|
+
preflight = try CLIRunner.runExecutable(
|
|
1220
|
+
executable.path, arguments: ["--fixture-preflight"],
|
|
1221
|
+
executionTimeout: preflightBudget, totalWallClockBudget: preflightBudget
|
|
1222
|
+
)
|
|
1223
|
+
} catch {
|
|
1224
|
+
print("fixture_preflight sha256=\(fixtureSHA256) elapsed_ns=\(PipeClosureFixture.now() - preflightStartedAt) budget_s=\(preflightBudget) threw=1")
|
|
1225
|
+
throw error
|
|
1226
|
+
}
|
|
1227
|
+
let preflightElapsed = PipeClosureFixture.now() - preflightStartedAt
|
|
1228
|
+
let unchangedAfterPreflight = try Data(contentsOf: executable) == fixtureBytes
|
|
1229
|
+
print("fixture_preflight sha256=\(fixtureSHA256) elapsed_ns=\(preflightElapsed) budget_s=\(preflightBudget) status=\(preflight.terminationStatus) stdout_bytes=\(preflight.stdout.utf8.count) stderr_bytes=\(preflight.stderr.utf8.count) unchanged=\(unchangedAfterPreflight)")
|
|
1230
|
+
try #require(preflightElapsed < UInt64(preflightBudget * 1_000_000_000))
|
|
1231
|
+
try #require(preflight.terminationStatus == 0 && preflight.stdout.isEmpty && preflight.stderr.isEmpty)
|
|
1232
|
+
try #require(unchangedAfterPreflight)
|
|
1233
|
+
try #require(!FileManager.default.fileExists(atPath: holderPidFile.path))
|
|
1234
|
+
|
|
1235
|
+
let timingOrigin = PipeClosureFixture.now()
|
|
952
1236
|
let startedAt = ContinuousClock.now
|
|
953
1237
|
#expect(throws: InjectedReapFailure.self) {
|
|
1238
|
+
recordPhase("runner_enter")
|
|
1239
|
+
defer {
|
|
1240
|
+
returnedAt = PipeClosureFixture.now()
|
|
1241
|
+
failureGate.lock()
|
|
1242
|
+
phaseTimes["runner_return"] = returnedAt
|
|
1243
|
+
failureGate.unlock()
|
|
1244
|
+
}
|
|
954
1245
|
_ = try CLIRunner.runExecutable(
|
|
955
1246
|
executable.path,
|
|
956
|
-
arguments: [holderPidFile.path, markerFile.path
|
|
1247
|
+
arguments: [holderPidFile.path, markerFile.path, detailsFile.path, deadlineFile.path,
|
|
1248
|
+
leaderTimingFile.path, holderTimingFile.path],
|
|
957
1249
|
pipeDrainTimeout: 0.1,
|
|
1250
|
+
beforeExecutionDeadline: { recordPhase("setup_spawn_complete") },
|
|
1251
|
+
lifecycleObserver: { event in
|
|
1252
|
+
switch event {
|
|
1253
|
+
case .leaderExitObserved: recordPhase("leader_exit_observed")
|
|
1254
|
+
case let .processGroupSignaled(signal): recordPhase("signal_\(signal)_requested")
|
|
1255
|
+
case .leaderReaped: recordPhase("leader_reaped")
|
|
1256
|
+
}
|
|
1257
|
+
},
|
|
958
1258
|
leaderReaper: { processIdentifier, _ in
|
|
1259
|
+
recordPhase("reaper_enter")
|
|
959
1260
|
failureGate.lock()
|
|
960
1261
|
shouldFailCapture = true
|
|
961
1262
|
failureGate.unlock()
|
|
962
1263
|
var status: Int32 = 0
|
|
963
1264
|
var result: pid_t
|
|
1265
|
+
recordPhase("waitpid_enter")
|
|
964
1266
|
repeat {
|
|
965
1267
|
result = Darwin.waitpid(processIdentifier, &status, 0)
|
|
966
1268
|
} while result == -1 && errno == EINTR
|
|
1269
|
+
let reapError = errno
|
|
1270
|
+
recordPhase("waitpid_return")
|
|
967
1271
|
guard result == processIdentifier else {
|
|
968
|
-
throw NSError(domain: NSPOSIXErrorDomain, code: Int(
|
|
1272
|
+
throw NSError(domain: NSPOSIXErrorDomain, code: Int(reapError))
|
|
969
1273
|
}
|
|
1274
|
+
recordPhase("reaper_throw")
|
|
970
1275
|
throw InjectedReapFailure()
|
|
971
1276
|
},
|
|
972
|
-
captureSystemCalls: .init(
|
|
1277
|
+
captureSystemCalls: .init(makePipe: { operation in
|
|
1278
|
+
let pipe = try liveCaptureCalls.makePipe(operation)
|
|
1279
|
+
let createdAt = PipeClosureFixture.now()
|
|
1280
|
+
failureGate.lock()
|
|
1281
|
+
pipeTimes.append((pipe.read, pipe.write, createdAt))
|
|
1282
|
+
if operation == "create capture pipe" {
|
|
1283
|
+
ownedReadDescriptors.insert(pipe.read)
|
|
1284
|
+
}
|
|
1285
|
+
failureGate.unlock()
|
|
1286
|
+
return pipe
|
|
1287
|
+
}, close: { descriptor in
|
|
1288
|
+
let result = Darwin.close(descriptor)
|
|
1289
|
+
let closeError = errno
|
|
1290
|
+
let closedAt = PipeClosureFixture.now()
|
|
1291
|
+
failureGate.lock()
|
|
1292
|
+
closeTimes.append((descriptor, result, closedAt))
|
|
1293
|
+
closeObservations.append("fd=\(descriptor),result=\(result),errno=\(closeError)")
|
|
1294
|
+
if result == 0 && ownedReadDescriptors.contains(descriptor) {
|
|
1295
|
+
successfulReadCloses[descriptor] = closedAt
|
|
1296
|
+
}
|
|
1297
|
+
failureGate.unlock()
|
|
1298
|
+
errno = closeError
|
|
1299
|
+
return result
|
|
1300
|
+
}, read: { descriptor, buffer, count in
|
|
973
1301
|
failureGate.lock()
|
|
974
1302
|
let shouldFail = shouldFailCapture
|
|
975
|
-
if shouldFail {
|
|
1303
|
+
if shouldFail {
|
|
1304
|
+
injectedFailures += 1
|
|
1305
|
+
if firstInjectedReadTimes[descriptor] == nil {
|
|
1306
|
+
firstInjectedReadTimes[descriptor] = PipeClosureFixture.now()
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
976
1309
|
failureGate.unlock()
|
|
977
1310
|
if shouldFail {
|
|
978
1311
|
errno = EIO
|
|
@@ -982,25 +1315,58 @@ struct CLIRunnerTests {
|
|
|
982
1315
|
})
|
|
983
1316
|
)
|
|
984
1317
|
}
|
|
985
|
-
|
|
1318
|
+
let elapsed = ContinuousClock.now - startedAt
|
|
1319
|
+
recordPhase("assertion_return")
|
|
986
1320
|
failureGate.lock()
|
|
987
1321
|
let injectedFailureCount = injectedFailures
|
|
1322
|
+
let ownedReads = ownedReadDescriptors
|
|
1323
|
+
let closedReads = successfulReadCloses
|
|
1324
|
+
let phaseSnapshot = phaseTimes
|
|
1325
|
+
let pipeSnapshot = pipeTimes
|
|
1326
|
+
let closeTimingSnapshot = closeTimes
|
|
1327
|
+
let readTimingSnapshot = firstInjectedReadTimes
|
|
988
1328
|
failureGate.unlock()
|
|
1329
|
+
// Emit bounded numeric diagnostics only when the unchanged runner ceiling
|
|
1330
|
+
// fails. Formatting is outside the measured interval; there is no per-event I/O.
|
|
1331
|
+
let phases = phaseSnapshot.sorted { $0.value < $1.value }
|
|
1332
|
+
.map { "\($0.key)=\($0.value - timingOrigin)" }.joined(separator: ",")
|
|
1333
|
+
let pipes = pipeSnapshot.map { "\($0.read)/\($0.write)@\($0.at - timingOrigin)" }.joined(separator: ",")
|
|
1334
|
+
let closes = closeTimingSnapshot.map { "\($0.descriptor):\($0.result)@\($0.at - timingOrigin)" }.joined(separator: ",")
|
|
1335
|
+
let reads = readTimingSnapshot.sorted { $0.key < $1.key }
|
|
1336
|
+
.map { "\($0.key)@\($0.value - timingOrigin)" }.joined(separator: ",")
|
|
1337
|
+
func fixtureTimes(_ file: URL) -> String {
|
|
1338
|
+
guard let text = try? String(contentsOf: file, encoding: .utf8) else { return "unavailable" }
|
|
1339
|
+
let values = text.split(whereSeparator: \.isWhitespace).compactMap { UInt64($0) }
|
|
1340
|
+
guard values.count == 3, values.allSatisfy({ $0 >= timingOrigin }) else { return "invalid" }
|
|
1341
|
+
return values.map { String($0 - timingOrigin) }.joined(separator: ",")
|
|
1342
|
+
}
|
|
1343
|
+
// The leader's final tick precedes only its small timing-sidecar write and return;
|
|
1344
|
+
// it is a userspace pre-exit marker, not a timestamp of the kernel's exit event.
|
|
1345
|
+
let fixtureTiming = elapsed >= .seconds(2)
|
|
1346
|
+
? "leader(main,fork,pre_exit)_ns=\(fixtureTimes(leaderTimingFile)); holder(main,fork,ready_closed)_ns=\(fixtureTimes(holderTimingFile))"
|
|
1347
|
+
: ""
|
|
1348
|
+
let timing: Comment = "runner phases_ns: \(phases); pipes(read/write@ns): \(pipes); closes(fd:result@ns): \(closes); injected_reads(fd@ns): \(reads); \(fixtureTiming)"
|
|
1349
|
+
#expect(elapsed < .seconds(2), timing)
|
|
1350
|
+
let elapsedSeconds = Double(elapsed.components.seconds) + Double(elapsed.components.attoseconds) / 1e18
|
|
1351
|
+
print("fixture_lifecycle sha256=\(fixtureSHA256) elapsed_s=\(elapsedSeconds) budget_s=2")
|
|
1352
|
+
#expect(try Data(contentsOf: executable) == fixtureBytes)
|
|
989
1353
|
#expect(injectedFailureCount > 0)
|
|
1354
|
+
// Runner-owned closes must already have completed at return. Other concurrent
|
|
1355
|
+
// spawns may retain copied read endpoints until exec despite CLOEXEC_DEFAULT.
|
|
1356
|
+
#expect(ownedReads.count == 2)
|
|
1357
|
+
#expect(ownedReads.allSatisfy { closedReads[$0].map { $0 <= returnedAt } == true })
|
|
990
1358
|
|
|
991
1359
|
let holderPid = try #require(Self.readPID(from: holderPidFile))
|
|
992
1360
|
#expect(Darwin.kill(holderPid, 0) == 0)
|
|
993
|
-
try
|
|
994
|
-
|
|
995
|
-
let
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
}
|
|
1003
|
-
#expect(marker == "both-epipe")
|
|
1361
|
+
let markerDeadline = try PipeClosureFixture.request(pid: holderPid, deadlineFile: deadlineFile)
|
|
1362
|
+
let marker = PipeClosureFixture.waitForMarker(at: markerFile, deadline: markerDeadline)
|
|
1363
|
+
let writeObservations = (try? String(contentsOf: detailsFile, encoding: .utf8)) ?? "unavailable"
|
|
1364
|
+
failureGate.lock()
|
|
1365
|
+
let closeSnapshot = closeObservations
|
|
1366
|
+
failureGate.unlock()
|
|
1367
|
+
// Numeric fixture diagnostics only; close errno is meaningful only on failure.
|
|
1368
|
+
let diagnostics: Comment = "pipe writes: \(writeObservations); closes: \(closeSnapshot)"
|
|
1369
|
+
#expect(marker == "both-epipe", diagnostics)
|
|
1004
1370
|
expectProcessIsGone(holderPid)
|
|
1005
1371
|
}
|
|
1006
1372
|
|
|
@@ -1032,12 +1398,12 @@ struct CLIRunnerTests {
|
|
|
1032
1398
|
@Test("a completely silent escaped descendant cannot pin the capture readers past the ceiling")
|
|
1033
1399
|
@MainActor
|
|
1034
1400
|
func silentEscapedDescendantCannotPinCaptureReaders() async throws {
|
|
1035
|
-
let home =
|
|
1036
|
-
.appendingPathComponent("recordings-rewrite-silent-holder-\(UUID().uuidString)")
|
|
1401
|
+
let home = URL(fileURLWithPath: makeIsolatedTestHome("rewrite-silent-holder"), isDirectory: true)
|
|
1037
1402
|
let bin = home.appendingPathComponent(".bun/bin")
|
|
1038
1403
|
let leaderPidFile = home.appendingPathComponent("leader-pid")
|
|
1039
1404
|
let holderPidFile = home.appendingPathComponent("holder-pid")
|
|
1040
1405
|
let markerFile = home.appendingPathComponent("holder-probe")
|
|
1406
|
+
let deadlineFile = home.appendingPathComponent("holder-probe-deadline")
|
|
1041
1407
|
let holderSource = home.appendingPathComponent("silent-holder.c")
|
|
1042
1408
|
let holderBinary = home.appendingPathComponent("silent-holder")
|
|
1043
1409
|
try FileManager.default.createDirectory(at: bin, withIntermediateDirectories: true)
|
|
@@ -1056,9 +1422,9 @@ struct CLIRunnerTests {
|
|
|
1056
1422
|
// runner is alive: a reader blocked in read(2) would never be handed the data it
|
|
1057
1423
|
// needs to run onto a revoked descriptor, which is exactly the shape that pinned
|
|
1058
1424
|
// the old blocking readers forever. Only after the runner has returned does the
|
|
1059
|
-
// test send SIGUSR1
|
|
1060
|
-
//
|
|
1061
|
-
//
|
|
1425
|
+
// test send SIGUSR1. Both streams must reach EPIPE within one closure deadline;
|
|
1426
|
+
// a concurrent spawn may temporarily retain copied read endpoints before exec.
|
|
1427
|
+
// The injectable reaper test separately checks runner-owned closure at return.
|
|
1062
1428
|
try """
|
|
1063
1429
|
#include <errno.h>
|
|
1064
1430
|
#include <signal.h>
|
|
@@ -1066,11 +1432,13 @@ struct CLIRunnerTests {
|
|
|
1066
1432
|
#include <time.h>
|
|
1067
1433
|
#include <unistd.h>
|
|
1068
1434
|
|
|
1435
|
+
\(PipeClosureFixture.observerSource)
|
|
1436
|
+
|
|
1069
1437
|
static volatile sig_atomic_t probeRequested = 0;
|
|
1070
1438
|
static void requestProbe(int signo) { (void)signo; probeRequested = 1; }
|
|
1071
1439
|
|
|
1072
1440
|
int main(int argc, char **argv) {
|
|
1073
|
-
if (argc <
|
|
1441
|
+
if (argc < 4) return 64;
|
|
1074
1442
|
if (setsid() == -1) return 65;
|
|
1075
1443
|
signal(SIGPIPE, SIG_IGN);
|
|
1076
1444
|
signal(SIGUSR1, requestProbe);
|
|
@@ -1082,13 +1450,7 @@ struct CLIRunnerTests {
|
|
|
1082
1450
|
struct timespec delay = {0, 20000000};
|
|
1083
1451
|
nanosleep(&delay, 0);
|
|
1084
1452
|
}
|
|
1085
|
-
|
|
1086
|
-
int stderrBroken = (write(STDERR_FILENO, "y", 1) == -1 && errno == EPIPE);
|
|
1087
|
-
FILE *marker = fopen(argv[2], "w");
|
|
1088
|
-
if (!marker) return 67;
|
|
1089
|
-
fputs(stdoutBroken && stderrBroken ? "both-epipe" : "still-connected", marker);
|
|
1090
|
-
fclose(marker);
|
|
1091
|
-
return 0;
|
|
1453
|
+
return observePipeClosure(argv[3], argv[2], NULL, NULL, NULL);
|
|
1092
1454
|
}
|
|
1093
1455
|
""".write(to: holderSource, atomically: true, encoding: .utf8)
|
|
1094
1456
|
let compile = Process()
|
|
@@ -1103,7 +1465,7 @@ struct CLIRunnerTests {
|
|
|
1103
1465
|
#!/bin/sh
|
|
1104
1466
|
trap '' TERM
|
|
1105
1467
|
printf '%s' "$$" > '\(leaderPidFile.path)'
|
|
1106
|
-
'\(holderBinary.path)' '\(holderPidFile.path)' '\(markerFile.path)' &
|
|
1468
|
+
'\(holderBinary.path)' '\(holderPidFile.path)' '\(markerFile.path)' '\(deadlineFile.path)' &
|
|
1107
1469
|
while [ ! -s '\(holderPidFile.path)' ]; do /bin/sleep 0.01; done
|
|
1108
1470
|
while :; do /bin/sleep 0.05; done
|
|
1109
1471
|
""".write(to: executable, atomically: true, encoding: .utf8)
|
|
@@ -1113,16 +1475,25 @@ struct CLIRunnerTests {
|
|
|
1113
1475
|
// SIGTERM and the silent holder keeps both pipes open, so every deadline in the
|
|
1114
1476
|
// chain — execution window, termination grace, drain wait — runs to exhaustion
|
|
1115
1477
|
// with zero bytes ever arriving to wake a reader.
|
|
1116
|
-
let runCLI = RecordingEngine(homePath: home.path).commandCLI
|
|
1478
|
+
let runCLI = RecordingEngine(homePath: home.path, installsGlobalHandlers: false).commandCLI
|
|
1117
1479
|
let homePath = home.path
|
|
1118
1480
|
let startedAt = ContinuousClock.now
|
|
1119
|
-
let
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
let
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1481
|
+
let operation = RecordingEngine.makeCommandRewriteOperation(
|
|
1482
|
+
args: ["rewrite-selection"], home: homePath, runCLI: runCLI
|
|
1483
|
+
)
|
|
1484
|
+
let (output, workerStartedAt, workerFinishedAt) = await BlockingOperation.run {
|
|
1485
|
+
let workerStartedAt = ContinuousClock.now
|
|
1486
|
+
let output = operation()
|
|
1487
|
+
return (output, workerStartedAt, ContinuousClock.now)
|
|
1488
|
+
}
|
|
1489
|
+
let resumedAt = ContinuousClock.now
|
|
1490
|
+
let elapsed = resumedAt - startedAt
|
|
1491
|
+
// Report only on a failed elapsed assertion. Admission includes the executor
|
|
1492
|
+
// and dispatch queue hops; the worker interval includes CLI preparation/cleanup.
|
|
1493
|
+
let timing: Comment = "CLI phases: admission=\(workerStartedAt - startedAt), worker=\(workerFinishedAt - workerStartedAt), resume=\(resumedAt - workerFinishedAt)"
|
|
1494
|
+
|
|
1495
|
+
#expect(elapsed < .seconds(RecordingEngine.commandRewriteTimeout), timing)
|
|
1496
|
+
#expect(elapsed > .seconds(8.4), timing)
|
|
1126
1497
|
#expect(output.hasPrefix("ERROR:"))
|
|
1127
1498
|
#expect(output.contains("timed out"))
|
|
1128
1499
|
|
|
@@ -1136,14 +1507,12 @@ struct CLIRunnerTests {
|
|
|
1136
1507
|
#expect(Darwin.kill(holderPid, 0) == 0)
|
|
1137
1508
|
#expect(!FileManager.default.fileExists(atPath: markerFile.path))
|
|
1138
1509
|
|
|
1139
|
-
// Ask
|
|
1140
|
-
//
|
|
1141
|
-
|
|
1142
|
-
try #require(Darwin.kill(holderPid, SIGUSR1) == 0)
|
|
1510
|
+
// Ask only after the real command path returns, with no writer traffic having
|
|
1511
|
+
// helped the readers finish. Temporary pre-exec copies share the same 3s bound.
|
|
1512
|
+
let markerDeadline = try PipeClosureFixture.request(pid: holderPid, deadlineFile: deadlineFile)
|
|
1143
1513
|
var marker: String?
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
if let contents = try? String(contentsOf: markerFile, encoding: .utf8), !contents.isEmpty {
|
|
1514
|
+
while PipeClosureFixture.now() < markerDeadline {
|
|
1515
|
+
if let contents = PipeClosureFixture.marker(at: markerFile), PipeClosureFixture.now() < markerDeadline {
|
|
1147
1516
|
marker = contents
|
|
1148
1517
|
break
|
|
1149
1518
|
}
|
|
@@ -1275,6 +1644,12 @@ struct CLIRunnerTests {
|
|
|
1275
1644
|
#expect(CLIRunner.parseError(input) == "OpenAI quota exceeded — check the OpenAI account billing")
|
|
1276
1645
|
}
|
|
1277
1646
|
|
|
1647
|
+
@Test("Service authentication and quota errors identify the Recordings API")
|
|
1648
|
+
func serviceErrors() {
|
|
1649
|
+
#expect(CLIRunner.parseError("ERROR: 401 Unauthorized", serviceAPI: true) == "Recordings API authentication failed — check the API connection in Settings")
|
|
1650
|
+
#expect(CLIRunner.parseError("ERROR: 429 Too Many Requests", serviceAPI: true) == "Recordings API request limit reached — try again later")
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1278
1653
|
@Test("parseError truncates long messages to 120 chars")
|
|
1279
1654
|
func truncation() {
|
|
1280
1655
|
let longMsg = String(repeating: "a", count: 200)
|
|
@@ -1297,9 +1672,12 @@ struct CLIRunnerTests {
|
|
|
1297
1672
|
#expect(result?.contains(token) == false)
|
|
1298
1673
|
#expect(result?.contains("[REDACTED]") == true)
|
|
1299
1674
|
|
|
1675
|
+
let plainFixture = "plain-synthetic-secret"
|
|
1676
|
+
let clientFixture = "client-synthetic-secret"
|
|
1677
|
+
let passwordFixture = "password-synthetic-secret"
|
|
1300
1678
|
let structured = CLIRunner.parseError(
|
|
1301
|
-
"ERROR: OPENAI_API_KEY
|
|
1302
|
-
+ "PASSWORD
|
|
1679
|
+
"ERROR: OPENAI_API_KEY=\(plainFixture) CLIENT_SECRET=\(clientFixture) "
|
|
1680
|
+
+ "PASSWORD=\(passwordFixture) payload={\"api_key\":\"json-synthetic-secret\","
|
|
1303
1681
|
+ "\"private_key\":\"private-synthetic-secret\"}"
|
|
1304
1682
|
)
|
|
1305
1683
|
#expect(structured?.contains("plain-synthetic-secret") == false)
|