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.