diff --git a/Resources/Info.plist b/Resources/Info.plist
index 8bb61df..30d4453 100644
--- a/Resources/Info.plist
+++ b/Resources/Info.plist
@@ -19,9 +19,9 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 0.8.1
+ 0.8.2
CFBundleVersion
- 9
+ 10
CFBundleDocumentTypes
diff --git a/Sources/GlossCore/BabelDOCExecutorClient.swift b/Sources/GlossCore/BabelDOCExecutorClient.swift
index 1ad7a1a..df4009b 100644
--- a/Sources/GlossCore/BabelDOCExecutorClient.swift
+++ b/Sources/GlossCore/BabelDOCExecutorClient.swift
@@ -803,6 +803,9 @@ public struct BabelDOCExecutorClient: Sendable {
timeline: timeline,
onProgress: onProgress
)
+ try await waitForWorkerToFinish(
+ executionID: created.executionID
+ )
connection.stateHandler(
.init(
taskID: taskID,
@@ -876,6 +879,27 @@ public struct BabelDOCExecutorClient: Sendable {
}
}
+ func waitForWorkerToFinish(
+ executionID: String,
+ timeout: Duration = .seconds(30),
+ pollInterval: Duration = .milliseconds(100)
+ ) async throws {
+ let clock = ContinuousClock()
+ let deadline = clock.now.advanced(by: timeout)
+ while true {
+ let snapshot = try await execution(id: executionID)
+ if snapshot.workerFinished {
+ return
+ }
+ guard clock.now < deadline else {
+ throw BabelDOCExecutorError.unavailable(
+ "PDF worker 完成后未能及时释放执行槽"
+ )
+ }
+ try await Task.sleep(for: pollInterval)
+ }
+ }
+
func waitForCancelledWorker(
executionID: String?,
taskID: String,
diff --git a/Tests/GlossCoreTests/BabelDOCExecutorClientTests.swift b/Tests/GlossCoreTests/BabelDOCExecutorClientTests.swift
index 35141da..96c54c0 100644
--- a/Tests/GlossCoreTests/BabelDOCExecutorClientTests.swift
+++ b/Tests/GlossCoreTests/BabelDOCExecutorClientTests.swift
@@ -270,6 +270,13 @@ final class BabelDOCExecutorClientTests: XCTestCase {
connection: fixture.connection
),
])
+ case ("GET", "/v1/executions/execution-1"):
+ return .json(
+ Self.executionSnapshot(
+ executionID: "execution-1",
+ status: "succeeded"
+ )
+ )
default:
return .json(["code": "not_found", "message": "not found"], status: 404)
}
@@ -321,6 +328,37 @@ final class BabelDOCExecutorClientTests: XCTestCase {
XCTAssertTrue(String(decoding: encodedBody, as: UTF8.self).contains("bridge-secret"))
}
+ func testTranslationWaitsUntilWorkerReleasesExecutionSlot() async throws {
+ let fixture = try Fixture()
+ defer { fixture.remove() }
+ let attempts = LockedValues()
+
+ StubExecutorURLProtocol.setHandler { request in
+ guard
+ request.httpMethod == "GET",
+ request.url?.path == "/v1/executions/execution-cleanup"
+ else {
+ return .json(["code": "not_found", "message": "not found"], status: 404)
+ }
+ attempts.append(1)
+ return .json(
+ Self.executionSnapshot(
+ executionID: "execution-cleanup",
+ status: "succeeded",
+ workerFinished: attempts.snapshot().count >= 3
+ )
+ )
+ }
+
+ try await fixture.client().waitForWorkerToFinish(
+ executionID: "execution-cleanup",
+ timeout: .seconds(1),
+ pollInterval: .milliseconds(1)
+ )
+
+ XCTAssertEqual(attempts.snapshot().count, 3)
+ }
+
func testReplayGapRecoversSucceededOutputFromAuthoritativeSnapshot() async throws {
let fixture = try Fixture()
defer { fixture.remove() }
@@ -365,6 +403,13 @@ final class BabelDOCExecutorClientTests: XCTestCase {
lastSequence: 24
),
], status: 410)
+ case ("GET", "/v1/executions/execution-gap"):
+ return .json(
+ Self.executionSnapshot(
+ executionID: "execution-gap",
+ status: "succeeded"
+ )
+ )
default:
return .json(["code": "not_found", "message": "not found"], status: 404)
}
@@ -571,7 +616,8 @@ final class BabelDOCExecutorClientTests: XCTestCase {
taskID: String = "task-1",
initialSequence: Int = 10,
firstAvailableSequence: Int? = 11,
- lastSequence: Int = 12
+ lastSequence: Int = 12,
+ workerFinished: Bool? = nil
) -> [String: Any] {
[
"execution_id": executionID,
@@ -582,7 +628,9 @@ final class BabelDOCExecutorClientTests: XCTestCase {
firstAvailableSequence.map { $0 as Any }
?? (NSNull() as Any),
"last_sequence": lastSequence,
- "worker_finished": status != "running" && status != "cancelling",
+ "worker_finished":
+ workerFinished
+ ?? (status != "running" && status != "cancelling"),
"created_at": 1_000.0,
"finished_at": status == "running" ? NSNull() : 1_001.0,
]
diff --git a/Tests/GlossCoreTests/BabelDOCExternalEngineTests.swift b/Tests/GlossCoreTests/BabelDOCExternalEngineTests.swift
index 74dc162..670fc11 100644
--- a/Tests/GlossCoreTests/BabelDOCExternalEngineTests.swift
+++ b/Tests/GlossCoreTests/BabelDOCExternalEngineTests.swift
@@ -22,7 +22,15 @@ final class BabelDOCExternalEngineTests: XCTestCase {
)
}
let runtime = try XCTUnwrap(BabelDOCExternalEngine.resolveRuntime())
- let session = BabelDOCServiceSession()
+ let stateDirectory = FileManager.default.temporaryDirectory
+ .appendingPathComponent(
+ "Gloss-BabelDOC-Service-Smoke-\(UUID().uuidString)",
+ isDirectory: true
+ )
+ defer { try? FileManager.default.removeItem(at: stateDirectory) }
+ let session = BabelDOCServiceSession(
+ persistedStateDirectoryURL: stateDirectory
+ )
do {
let baseURL = try await session.start(
runtime: runtime,
@@ -75,7 +83,18 @@ final class BabelDOCExternalEngineTests: XCTestCase {
withIntermediateDirectories: true
)
let runtime = try XCTUnwrap(BabelDOCExternalEngine.resolveRuntime())
- let service = usePersistentLayout ? BabelDOCServiceSession() : nil
+ let stateDirectory = FileManager.default.temporaryDirectory
+ .appendingPathComponent(
+ "Gloss-BabelDOC-Benchmark-\(UUID().uuidString)",
+ isDirectory: true
+ )
+ defer { try? FileManager.default.removeItem(at: stateDirectory) }
+ let service =
+ usePersistentLayout
+ ? BabelDOCServiceSession(
+ persistedStateDirectoryURL: stateDirectory
+ )
+ : nil
do {
let layoutServiceBaseURL = try await service?.start(
runtime: runtime,
diff --git a/docs/release-notes/v0.8.2.md b/docs/release-notes/v0.8.2.md
new file mode 100644
index 0000000..da4e89f
--- /dev/null
+++ b/docs/release-notes/v0.8.2.md
@@ -0,0 +1,34 @@
+# Gloss 0.8.2
+
+Gloss 0.8.2 makes sequential PDF batches reliable and keeps live service
+validation isolated from an already running App session.
+
+## PDF batch reliability
+
+- Waits for BabelDOC's worker cleanup to finish after a successful result
+ before submitting the next PDF.
+- Uses a bounded 30-second handoff timeout with a clear failure instead of
+ letting the next file hit a transient `busy` response.
+- Covers delayed cleanup and replayed terminal results in executor client
+ tests.
+
+## Service validation
+
+- Gives each opt-in live smoke or benchmark run its own temporary persisted
+ service state.
+- Prevents command-line validation from stopping or replacing the App's
+ resident PDF executor and layout service.
+
+## Validation
+
+- The full Gloss test suite passes.
+- The 15-page *Attention Is All You Need* PDF completed twice in one
+ persistent session: 15.50 seconds with a fresh layout cache and 6.61 seconds
+ with a layout cache hit. Both outputs retain selectable text, tables,
+ formulas, and figures.
+
+## Compatibility
+
+This release is compatible with the signed BabelDOC `0.6.4+gloss.5` runtime.
+The runtime also includes a server-side terminal-worker handoff grace period,
+so older Gloss clients remain safe while updating.