@@ -50,13 +50,30 @@ private constructor(
5050 }
5151 }
5252
53- /* * Streams the exact turn/path artifact to the caller-selected file, replacing that file. */
53+ /* * Returns the exact turn/path response. The caller owns and must close it. */
54+ @JvmOverloads
55+ fun content (path : String , options : RequestOptions = RequestOptions .none()): HttpResponse =
56+ openContent(findArtifact(path, options), options)
57+
58+ /* * Streams to an application-owned destination, replacing that file. */
5459 @JvmOverloads
5560 fun download (
5661 path : String ,
5762 destination : Path ,
5863 options : RequestOptions = RequestOptions .none(),
5964 ): SessionArtifact {
65+ val artifact = findArtifact(path, options)
66+ openContent(artifact, options).use { Files .copy(it.body(), destination, REPLACE_EXISTING ) }
67+ return artifact
68+ }
69+
70+ private fun openContent (artifact : SessionArtifact , options : RequestOptions ) =
71+ service.content(
72+ ArtifactContentParams .builder().sessionId(sessionId).artifactId(artifact.id()).build(),
73+ options,
74+ )
75+
76+ private fun findArtifact (path : String , options : RequestOptions ): SessionArtifact {
6077 var selected: SessionArtifact ? = null
6178 var params = ArtifactListParams .builder().sessionId(sessionId).build()
6279 while (true ) {
@@ -65,17 +82,7 @@ private constructor(
6582 if (! page.hasNextPage()) break
6683 params = page.nextPageParams()
6784 }
68- val artifact = checkNotNull(selected) { " No artifact matches this turn and path" }
69- service
70- .content(
71- ArtifactContentParams .builder()
72- .sessionId(sessionId)
73- .artifactId(artifact.id())
74- .build(),
75- options,
76- )
77- .use { Files .copy(it.body(), destination, REPLACE_EXISTING ) }
78- return artifact
85+ return checkNotNull(selected) { " No artifact matches this turn and path" }
7986 }
8087
8188 class Async
@@ -84,14 +91,34 @@ private constructor(
8491 private val sessionId: String ,
8592 private val turnId: String ,
8693 ) {
94+ /* * Returns the exact turn/path response. The caller owns and must close it. */
95+ @JvmOverloads
96+ fun content (
97+ path : String ,
98+ options : RequestOptions = RequestOptions .none(),
99+ ): CompletableFuture <HttpResponse > = withContent(path, options) { _, response -> response }
100+
101+ /* * Streams to an application-owned destination, replacing that file. */
87102 @JvmOverloads
88103 fun download (
89104 path : String ,
90105 destination : Path ,
91106 options : RequestOptions = RequestOptions .none(),
92- ): CompletableFuture <SessionArtifact > {
93- val result = CompletableFuture <SessionArtifact >()
107+ ): CompletableFuture <SessionArtifact > =
108+ withContent(path, options) { artifact, response ->
109+ response.use { Files .copy(it.body(), destination, REPLACE_EXISTING ) }
110+ artifact
111+ }
112+
113+ private fun <T > withContent (
114+ path : String ,
115+ options : RequestOptions ,
116+ consume : (SessionArtifact , HttpResponse ) -> T ,
117+ ): CompletableFuture <T > {
118+ val result = CompletableFuture <T >()
94119 val response = AtomicReference <HttpResponse ?>()
120+ // Native service futures are dependent stages: canceling them does not abort their
121+ // transport and can suppress delivery of a response that still needs closing.
95122 result.whenComplete { _, _ -> if (result.isCancelled) response.get()?.close() }
96123 var selected: SessionArtifact ? = null
97124 fun downloadSelected () {
@@ -110,12 +137,11 @@ private constructor(
110137 else {
111138 response.set(content)
112139 try {
113- content.use {
114- if (! result.isDone)
115- Files .copy(it.body(), destination, REPLACE_EXISTING )
116- }
117- result.complete(artifact)
140+ if (result.isDone) content.close()
141+ else if (! result.complete(consume(artifact, content)))
142+ content.close()
118143 } catch (error: Throwable ) {
144+ runCatching { content.close() }
119145 result.completeExceptionally(error)
120146 } finally {
121147 response.set(null )
0 commit comments