Skip to content

pkg/command: wrap jsonmessage.DisplayJSONMessagesStream with go context - #5663

Merged
Benehiko merged 1 commit into
docker:masterfrom
Benehiko:ctx-jsonmessage
Jan 21, 2025
Merged

pkg/command: wrap jsonmessage.DisplayJSONMessagesStream with go context#5663
Benehiko merged 1 commit into
docker:masterfrom
Benehiko:ctx-jsonmessage

Conversation

@Benehiko

@BenehikoBenehiko commented Dec 2, 2024

Copy link
Copy Markdown
Member

Follow-up to #5639

Allows for the jsonmessage.DisplayJSONMessagesStream to
correctly return when the context is cancelled with the
appropriate error message, instead of just a nil error.

Follow-up to 30a73ff

- What I did

- How I did it

- How to verify it

- Description for the changelog

- A picture of a cute animal (not mandatory but encouraged)

@Benehiko
Benehikoforce-pushed the ctx-jsonmessage branch 2 times, most recently from c21438f to 4884112CompareDecember 2, 2024 14:42
@BenehikoBenehiko changed the title pkg/command: pass context along to DisplayJSONMessagesStreampkg/command: wrap DisplayJSONMessagesToStream and DisplayJSONMessagesStreamJan 13, 2025
@codecov-commenter

codecov-commenter commented Jan 13, 2025

Copy link
Copy Markdown

Codecov Report

Attention: Patch coverage is 24.48980% with 37 lines in your changes missing coverage. Please review.

Project coverage is 59.42%. Comparing base (b462778) to head (91adb70).
Report is 34 commits behind head on master.

Additional details and impacted files
@@ Coverage Diff @@## master #5663 +/- ##
==========================================
- Coverage 59.51% 59.42% -0.10% 
==========================================
Files 346 347 +1 Lines 29376 29402 +26 ==========================================
- Hits 17483 17472 -11 - Misses 10923 10958 +35 - Partials 970 972 +2 

@Benehiko
Benehikoforce-pushed the ctx-jsonmessage branch 3 times, most recently from d55ee6d to bfd3131CompareJanuary 13, 2025 15:30
Comment threadcli/internal/jsonmessage/display.go Outdated
Comment threadcli/command/image/build.go Outdated
Comment threadcli/internal/jsonmessage/display.go Outdated
Comment on lines +16 to +32
// DisplayStream prints the JSON messages from the given reader to the given stream.
//
// It wraps the [jsonmessage.DisplayJSONMessagesToStream] function to make it
// "context aware" and appropriately returns why the function was canceled.
//
// It returns an error if the context is canceled, but not if the input reader / stream is closed.
func DisplayStream(ctx context.Context, in io.Reader, stream Stream, auxCallback func(JSONMessage)) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
err := jsonmessage.DisplayJSONMessagesToStream(in, stream, auxCallback)
if err != nil {
return err
}
if ctx.Done() != nil {
return ctx.Err()
}
}
return nil
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could drop the select to simplify this a bit

Suggested change
// DisplayStream prints the JSON messages from the given reader to the given stream.
//
// It wraps the [jsonmessage.DisplayJSONMessagesToStream] function to make it
// "context aware" and appropriately returns why the function was canceled.
//
// It returns an error if the context is canceled, but not if the input reader / stream is closed.
funcDisplayStream(ctx context.Context, in io.Reader, streamStream, auxCallbackfunc(JSONMessage)) error {
select {
case<-ctx.Done():
returnctx.Err()
default:
err:=jsonmessage.DisplayJSONMessagesToStream(in, stream, auxCallback)
iferr!=nil {
returnerr
}
ifctx.Done() !=nil {
returnctx.Err()
}
}
returnnil
}
// DisplayStream prints the JSON messages from the given reader to the given stream.
//
// It wraps the [jsonmessage.DisplayJSONMessagesToStream] function to make it
// "context aware" and appropriately returns why the function was canceled.
//
// It returns an error if the context is canceled, but not if the input reader / stream is closed.
funcDisplayStream(ctx context.Context, in io.Reader, streamStream, auxCallbackfunc(JSONMessage)) error {
ifctx.Err() !=nil {
returnctx.Err()
}
iferr:=jsonmessage.DisplayJSONMessagesToStream(in, stream, auxCallback); err!=nil {
returnerr
}
returnctx.Err()
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could also mostly support the context cancellation by wrapping the in io.Reader we pass into DisplayJSONMessagesToStream with something like:

typestopableReaderstruct {
r io.Readerclosederror
}
func (c*stopableReader) Read(p []byte) (int, error) {
if (c.closed!=nil) {
return0, c.closed
}
returnc.r.Read(p)
}

and then set closed = ctx.Err() after the context is done.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think i'd prefer we be explicit about it instead of in the io.Reader. I'll remove the select

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The io.Reader wrapper is a separate thing though.

With the current implementation the context cancellation isn't actually handled once the DisplayJSONMessagesToStream is called, the cancellation won't have any actual effect.

I pushed a small test to your branch to illustrate that.

The reader wrapper is trying to achieve that, that means this would effectively remove the need to make changes to the pkg/jsonmessage on moby side.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(sorry, late to comment; I saw the discussion, and was indeed wondering if closing the reader would make the jsonmessages properly cancel, as ISTR it was originally designed around some of that)

@BenehikoBenehikoJan 16, 2025

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the current implementation the context cancellation isn't actually handled once the DisplayJSONMessagesToStream is called, the cancellation won't have any actual effect.

yes, but the function was never designed for that. This PR is only trying to solve returning a context error instead of just a nil (when the reader is implicitly cancelled by context through say http.NewRequestWithContext).

The reader wrapper is trying to achieve that, that means this would effectively remove the need to make changes to the pkg/jsonmessage on moby side.

This PR's intent is to circumvent the moby side PR - or at least find an alternative without needing to refactor the function.

and was indeed wondering if closing the reader would make the jsonmessages properly cancel

Yes you are correct, it won't. The jsonmessages functions rely on the fact that io.EOF was returned to break the for loop.

EDIT: I meant to say that jsonmessages functions solely rely on io.EOF from the reader to exit, but that doesn't mean we will know why it was cancelled (the io.EOF reason). So closing the reader would exit the loop. https://github.com/moby/moby/blob/master/pkg/jsonmessage/jsonmessage.go#L237-L242

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It will also exit on any Read other error and will return it:

https://github.com/moby/moby/blob/1153242d3a6c3635770db65d9233bb892ee1cbc3/pkg/jsonmessage/jsonmessage.go#L237-L243

If the Read inside Decode returns context.Canceled then it would also be returned from Decode.
That's what the stopableReader above does.

This PR's intent is to circumvent the moby side PR - or at least find an alternative without needing to refactor the function.

I get that, but why not make the DisplayStream function actually respect the cancellation if we can?
We control the reader passed so we can make it start returning error for subsequent Reads after the context is done.

With that stopableReader I posted above, we could just wait for ctx.Done() channel to be closed and set r.closed = ctx.Err() to make the reader start returning the context error.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i see, alright I'll look into this then

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I mentioned in #5645 (comment) and #5645 (comment), I don't think adding a context to jsonmessage functions is the best approach – it would be simpler to let those continue only caring about whether the read fails or not to know when to stop instead of having to care about both things.

@vvoland's suggestion allows addressing the issue without needless changes to jsonmessage.

@BenehikoBenehiko changed the title pkg/command: wrap DisplayJSONMessagesToStream and DisplayJSONMessagesStreampkg/command: wrap jsonmessage.DisplayJSONMessagesStream with go contextJan 16, 2025
@Benehiko
Benehiko requested a review from vvolandJanuary 16, 2025 10:06
Comment threadcli/internal/jsonmessage/display.go Outdated
// "context aware" and appropriately returns why the function was canceled.
//
// It returns an error if the context is canceled, but not if the input reader / stream is closed.
func DisplayStream(ctx context.Context, in io.Reader, stream Stream, auxCallback func(JSONMessage)) error {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One other thing consider is to add 2 separate functions; one that takes an auxCallBack and one without; looking at the CLI code, I see that we need the auxCallBack in only 2 places;

cli/command/container/create.go: return jsonmessage.DisplayJSONMessagesToStream(responseBody, out, nil)
cli/command/image/import.go: return jsonmessage.DisplayJSONMessagesToStream(responseBody, dockerCli.Out(), nil)
cli/command/image/load.go: return jsonmessage.DisplayJSONMessagesToStream(response.Body, dockerCli.Out(), nil)
cli/command/image/push.go: err = jsonmessage.DisplayJSONMessagesToStream(responseBody, streams.NewOut(io.Discard), handleAux(dockerCli))
cli/command/image/push.go: return jsonmessage.DisplayJSONMessagesToStream(responseBody, dockerCli.Out(), handleAux(dockerCli))
cli/command/image/trust.go: if err := jsonmessage.DisplayJSONMessagesToStream(in, ioStreams.Out(), nil); err != nil {
cli/command/image/trust.go: if err := jsonmessage.DisplayJSONMessagesToStream(in, ioStreams.Out(), handleTarget); err != nil {
cli/command/image/trust.go: return jsonmessage.DisplayJSONMessagesToStream(responseBody, out, nil)
cli/command/plugin/install.go: if err := jsonmessage.DisplayJSONMessagesToStream(responseBody, dockerCli.Out(), nil); err != nil {
cli/command/plugin/push.go: return jsonmessage.DisplayJSONMessagesToStream(responseBody, dockerCli.Out(), nil)
cli/command/plugin/upgrade.go: if err := jsonmessage.DisplayJSONMessagesToStream(responseBody, dockerCli.Out(), nil); err != nil {
cli/command/service/helpers.go: err := jsonmessage.DisplayJSONMessagesToStream(pipeReader, dockerCli.Out(), nil)
cli/command/swarm/ca.go: err := jsonmessage.DisplayJSONMessagesToStream(pipeReader, dockerCli.Out(), nil)

Alternatively, we could consider (for a expandable UX) having it accept a DisplayOptions{} struct (or fancy functional DisplayOptions()) , e.g.

err:=DisplayWithOptions(ctx, in, stream, Options{
AuxCallback: myCallback,
})

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

another is to make Display a variadic function like so:

typeOptionsfunc(*options)
typeoptionsstruct {
AuxCallbackfunc(JSONMessage)
}
funcWithAuxCallback(cbfunc(JSONMessage)) Options {
returnfunc(o*options) {
o.AuxCallback=cb
}
}
funcDisplay(ctx context.Context, in io.Reader, streamStream, opts...Options) error {
}

Then when you use it, you don't even need to specify the last parameter in most cases.

err:=jsonstream.Display(ctx, reader, out)
// orerr:=jsonstream.Display(ctx, reader, out, jsonstream.WithAuxCallback(func(j jsonstream.JSONMessage){}))

Comment threadcli/internal/jsonmessage/display.go Outdated
@@ -0,0 +1,32 @@
package jsonmessage

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we could name this package jsonstream it could help disambiguate with the jsonmessage package, and we could name the function Display() as it would be called as jsonstream.Display()

Comment threadcli/internal/jsonmessage/display.go Outdated
type (
Stream = jsonmessage.Stream
JSONMessage = jsonmessage.JSONMessage
JSONError = jsonmessage.JSONError

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is still an internal package, so not too problematic, but it looks like JSONError is literally used in a single place in the CLI. We may want to have a look if we want to keep it as an exported type, or if there's alternatives to reduce the "public" (but right now, still "internal") API;

ifjerr, ok:=err.(*jsonmessage.JSONError); ok {
// If no error code is set, default to 1
ifjerr.Code==0 {
jerr.Code=1
}
ifoptions.quiet {
fmt.Fprintf(dockerCli.Err(), "%s%s", progBuff, buildBuff)
}
return cli.StatusError{Status: jerr.Message, StatusCode: jerr.Code}
}

@BenehikoBenehikoJan 17, 2025

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The idea behind this was to keep all the jsonmessage code in a singular place inside an internal package inside the CLI. Maybe i'm being a bit excessive, but it felt cleaner

Comment threadcli/internal/jsonstream/display.go Outdated
Comment threadcli/internal/jsonstream/display.go Outdated
…text
Allows for the `jsonmessage.DisplayJSONMessagesStream` function
to correctly return when the context is cancelled with the appropriate
reason (`ctx.Error()`) instead of just a nil error.
Follow-up to docker@30a73ff
Signed-off-by: Alano Terblanche <18033717+Benehiko@users.noreply.github.com>
Co-authored-by: Paweł Gronowski <pawel.gronowski@docker.com>

@vvolandvvoland left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants

@Benehiko@codecov-commenter@thaJeztah@vvoland@laurazard