Skip to content

fix(gcsartifact): prevent artifact version race in concurrent Save - #1172

Merged
wolo-lab merged 4 commits into
mainfrom
wolo/gcs-artifact-version-race
Aug 7, 2026
Merged

wolo-lab merged 4 commits into
mainfrom
wolo/gcs-artifact-version-race

Conversation

@wolo-lab

@wolo-lab wolo-lab commented Jul 18, 2026 •

Copy link
Copy Markdown
Contributor

Problem

gcsService.Save assigned version numbers non-atomically: it listed the
existing versions, computed max+1, and wrote the blob at that version. Two
concurrent Save calls for the same artifact read the same version set, pick
the same next version, and the second upload silently overwrites the first —
callers get duplicate version numbers and one object is lost. The code carried a
TODO acknowledging the race.

Note: adk-python's GcsArtifactService has the same unguarded
max(versions) + 1 logic, so this is Go-specific hardening rather than a port.

Summary

  • Write the new blob with a "does not exist" generation precondition
    (ifGenerationMatch=0) via
    ObjectHandle.If(storage.Conditions{DoesNotExist: true}). GCS has no
    transaction spanning the list and the write, so a colliding writer now fails
    the precondition with HTTP 412 instead of clobbering the existing object.
  • On a 412, Save re-reads the versions and retries with a fresh number,
    bounded by maxSaveAttempts (returns ErrVersionConflict if exhausted). N
    concurrent saves therefore yield exactly versions 1..N with no lost writes.
  • Extracted writeArtifact / isPreconditionFailed helpers and added an
    ifNotExist() method to the internal gcsObject seam and its wrapper.
  • Public API: one additive, backward-compatible change — the new exported
    sentinel gcsartifact.ErrVersionConflict, so callers can distinguish a lost
    version race from a real write failure and retry. apidiff reports no
    incompatible change. artifact.Service.Save's doc now notes that an
    optimistic implementation may transiently fail to claim a version.
  • The in-memory GCS fake was rewritten so it actually models the precondition,
    and it now tracks object existence explicitly — a zero-byte artifact is a
    real, listable object, as it is in GCS. A separate httptest-backed test
    drives the production ObjectHandle wrapper offline and asserts the
    precondition really reaches the wire.

@wolo-lab
wolo-lab force-pushed the wolo/gcs-artifact-version-race branch 3 times, most recently from e9db6f3 to 99e5437 Compare July 18, 2026 22:29
@wolo-lab
wolo-lab marked this pull request as ready for review July 19, 2026 14:14
@wolo-lab
wolo-lab requested a review from baptmont July 19, 2026 14:14
@wolo-lab
wolo-lab force-pushed the wolo/gcs-artifact-version-race branch from 99e5437 to 3517be1 Compare July 22, 2026 21:20

@karolpiotrowicz karolpiotrowicz left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The race is real and this closes it. I reproduced the original bug against the merge-base and it fails hard: 10 concurrent Save calls for one artifact returned duplicate version numbers and silently dropped writes in 18 of 20 runs (e.g. returned [1 2 2 3 3 4 4 4 5 6], stored [1 2 3 4 5 6]). The same reproduction passes 50/50 on this branch, and 30/30 under -race with no data races. I also wrote a deterministic control that commits a competing blob at the same object name just before the conditional write commits: Save correctly retried, returned version 2, and left the competitor's bytes at version 1 intact. Build, go vet, gofmt, staticcheck, golangci-lint and go mod tidy -diff are all clean, and apidiff reports one compatible addition and no incompatible change.

Worth calling out as a bonus: the fake rewrite fixes a fidelity bug of its own. On the merge-base, merely taking an object handle registered a listable entry, so a failed Load of a missing version poisoned the version listing (Versions() returned [1 7 8] after one save plus one failed load; it now correctly returns [1 2]).

Two small things, then a couple of questions. Neither blocks.

Test coverage of the actual precondition. See the inline notes on artifact/gcsartifact/gcs_client.go and artifact/gcsartifact/gcs_test.go.

PR description. The description says "No public API change", but ErrVersionConflict is a new exported symbol in a package whose only prior export was NewService. It is purely additive and backward compatible (apidiff agrees), so this is just a wording fix — but since the module's exported surface is permanent, it is worth being explicit about in the description so the addition gets a deliberate look.

Questions, not change requests:

  • Is a fixed attempt budget the shape you want, rather than a deadline? A count bounds a closed batch of writers, but under a steady arrival stream a writer that backs off can be lapped by newcomers that start with no delay. Modelling GCS list latency (a bucket wrapper that makes the listing stale by a fixed delay) with the real jittered backoff, I saw 0 conflicts at 50 and 60 concurrent savers and 1/100 at 100, but 52/200 with 200 savers on a single artifact name and a 40 ms stale listing. That is well beyond anything the in-tree callers produce — they all build per-invocation or per-millisecond file names — and failing closed is strictly better than the old silent overwrite, so this may well be fine as is. If it is, would it be worth noting the failure mode on artifact.Service.Save as well? Callers program against the interface, and none of the current ones inspect or retry the error.

  • The Python implementation still uses the unguarded max(versions) + 1. Since a precondition-based scheme only holds when every writer uses it, a bucket written by both implementations would still lose a version. Might be worth a line in the doc comment, and an issue on the Python side.

Comment thread artifact/gcsartifact/gcs_client.go Resolved
Comment thread artifact/gcsartifact/gcs_test.go Resolved
Save assigned versions non-atomically: it listed the existing versions,
computed max+1, and wrote that blob. Two concurrent Save calls for the same
artifact would read the same version set, pick the same next version, and the
second write would silently overwrite the first — duplicate returned versions
and a lost object. The code carried a TODO acknowledging this.

Assign versions with optimistic concurrency instead: write the new blob with a
"does not exist" generation precondition (x-goog-if-generation-match: 0). GCS
has no transaction spanning the list and the write, so a colliding writer now
fails the precondition (HTTP 412); Save re-reads the versions and retries with
a fresh number, using a jittered backoff between attempts and bounded by
maxSaveAttempts. It returns errVersionConflict if the budget is exhausted
rather than overwriting an existing version. N concurrent saves therefore yield
exactly versions 1..N with no lost writes.

Testing Plan:
- go build -mod=readonly ./...
- go test -race -mod=readonly -count=1 -shuffle=on ./artifact/...
- golangci-lint run ./artifact/...
- go mod tidy -diff
- TestGCSArtifactServiceConcurrentSave: concurrent savers assert distinct,
  contiguous versions and that nothing is overwritten (saver count kept within
  the retry budget). Verified it fails against the pre-fix code.
- TestSaveReturnsWriteErrorWithoutRetry: a non-precondition write error is
  surfaced as-is and not retried.
- TestSaveExhaustsRetriesOnPersistentConflict: persistent 412s give up with
  errVersionConflict after maxSaveAttempts.
- TestBackoffDelayBounds: jittered backoff stays within bounds.
The does-not-exist precondition on the production ObjectHandle wrapper had
no test: replacing it with a no-op left the whole suite green, so a
regression there would silently restore last-writer-wins. Add an
httptest-backed test that drives the real wrapper offline and asserts the
upload carries ifGenerationMatch=0 (the client sends the condition as a
query parameter, not a header).

The fake also used data != nil as its existence test, so a zero-byte
artifact -- reachable through the public API, since SaveRequest.Validate
only requires Part.InlineData -- was modelled as not existing: saving it
twice returned version 1 both times. Track existence in an explicit
exists flag instead, which also subsumes the deleted flag.

Also wrap the context error from the retry backoff, drop the unreachable
nil check on the sleep hook, and note the adk-python parity gap and the
per-attempt listing cost in the Save doc.
@wolo-lab
wolo-lab force-pushed the wolo/gcs-artifact-version-race branch from 0b67a1f to 5a41ec2 Compare August 7, 2026 08:14
The caveat about other writers is a property of the scheme, so state it in
those terms rather than pinning it to another implementation's current
behaviour, which this repo cannot keep in sync.
@wolo-lab

wolo-lab commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

The race is real and this closes it. I reproduced the original bug against the merge-base and it fails hard: 10 concurrent Save calls for one artifact returned duplicate version numbers and silently dropped writes in 18 of 20 runs (e.g. returned [1 2 2 3 3 4 4 4 5 6], stored [1 2 3 4 5 6]). The same reproduction passes 50/50 on this branch, and 30/30 under -race with no data races. I also wrote a deterministic control that commits a competing blob at the same object name just before the conditional write commits: Save correctly retried, returned version 2, and left the competitor's bytes at version 1 intact. Build, go vet, gofmt, staticcheck, golangci-lint and go mod tidy -diff are all clean, and apidiff reports one compatible addition and no incompatible change.

Worth calling out as a bonus: the fake rewrite fixes a fidelity bug of its own. On the merge-base, merely taking an object handle registered a listable entry, so a failed Load of a missing version poisoned the version listing (Versions() returned [1 7 8] after one save plus one failed load; it now correctly returns [1 2]).

Two small things, then a couple of questions. Neither blocks.

Test coverage of the actual precondition. See the inline notes on artifact/gcsartifact/gcs_client.go and artifact/gcsartifact/gcs_test.go.

PR description. The description says "No public API change", but ErrVersionConflict is a new exported symbol in a package whose only prior export was NewService. It is purely additive and backward compatible (apidiff agrees), so this is just a wording fix — but since the module's exported surface is permanent, it is worth being explicit about in the description so the addition gets a deliberate look.

Questions, not change requests:

  • Is a fixed attempt budget the shape you want, rather than a deadline? A count bounds a closed batch of writers, but under a steady arrival stream a writer that backs off can be lapped by newcomers that start with no delay. Modelling GCS list latency (a bucket wrapper that makes the listing stale by a fixed delay) with the real jittered backoff, I saw 0 conflicts at 50 and 60 concurrent savers and 1/100 at 100, but 52/200 with 200 savers on a single artifact name and a 40 ms stale listing. That is well beyond anything the in-tree callers produce — they all build per-invocation or per-millisecond file names — and failing closed is strictly better than the old silent overwrite, so this may well be fine as is. If it is, would it be worth noting the failure mode on artifact.Service.Save as well? Callers program against the interface, and none of the current ones inspect or retry the error.
  • The Python implementation still uses the unguarded max(versions) + 1. Since a precondition-based scheme only holds when every writer uses it, a bucket written by both implementations would still lose a version. Might be worth a line in the doc comment, and an issue on the Python side.

Thanks — the reproduction numbers and the mutation check made this straight
forward to act on. All four points addressed:

Test coverage of the precondition and the fake's zero-byte handling —
both fixed in this PR; details in the inline threads.

PR description — you're right, and it was wrong twice over: it also
described the precondition as an x-goog-if-generation-match header, which is
not what goes on the wire. Rewritten to call out ErrVersionConflict explicitly
as the one additive public API change.

Attempt budget vs deadline. I'd like to keep the count, because the context
already supplies the deadline: sleepContext selects on ctx.Done(), so a
caller with a timeout bounds the whole loop regardless of the budget. The
uncapped worst case is the sum of the jittered caps, ~9.3s of backoff across 15
sleeps (expected ~4.6s with full jitter), which a caller can cut short at any
point. Your starvation scenario is real, but it needs sustained arrivals on one
artifact name, and failing closed beats the previous silent overwrite. What I
did change: the context error from the backoff was previously returned bare, so
a caller got a naked context.DeadlineExceeded with no indication of which save
failed — it's now wrapped with the filename.

On documenting it on artifact.Service.Save: agreed that callers program
against the interface, so I added a sentence there — but deliberately a generic
one ("implementations that assign versions optimistically may fail to claim one
while other writers save the same artifact; that failure is transient and the
call can be retried") rather than naming ErrVersionConflict. A GCS-specific
sentinel in the interface doc would oblige every other implementation to honour
it, and a caller that wants to branch on it can import the package. Happy to
promote it to artifact if you'd rather callers be able to detect it without
depending on the GCS implementation.

Interoperability. The Save doc now records the constraint in general
terms — the scheme only holds if every writer uses the precondition, so a bucket
also written by a client that assigns versions unconditionally can still lose
one. I kept it implementation-agnostic on purpose: a claim about another
codebase's current behaviour is not something this repo can keep in sync. I'll
raise the concrete case separately with the Python side.

Also noted in the doc that each attempt costs one listing plus one upload — I
kept the re-list rather than incrementing locally on 412, since max+1 from a
fresh listing jumps past every committed version instead of probing linearly.

@wolo-lab
wolo-lab merged commit a90b910 into main Aug 7, 2026
13 checks passed
@wolo-lab
wolo-lab deleted the wolo/gcs-artifact-version-race branch August 7, 2026 10:13
wolo-lab added a commit that referenced this pull request Aug 10, 2026
…1172, #1167, #1261) (#1279)

* fix(memory): hold read lock while scanning sessions in SearchMemory (#1139)

InMemoryService.SearchMemory read the per-user session map under the read
lock but released it before iterating, while AddSessionToMemory writes into
that same map under the write lock. Concurrent Search and Add on the same
app/user therefore race and can panic with "concurrent map iteration and
map write", even though the service is documented as thread-safe.

Hold the read lock across the whole scan. AddSessionToMemory replaces session
slices wholesale rather than mutating them in place, so a read lock over the
iteration is sufficient and still lets searches run concurrently.

Co-authored-by: João Westerberg <[email protected]>

(cherry picked from commit 0420029)

* fix(gcsartifact): prevent artifact version race in concurrent Save (#1172)

* fix(gcsartifact): prevent artifact version race in concurrent Save

Save assigned versions non-atomically: it listed the existing versions,
computed max+1, and wrote that blob. Two concurrent Save calls for the same
artifact would read the same version set, pick the same next version, and the
second write would silently overwrite the first — duplicate returned versions
and a lost object. The code carried a TODO acknowledging this.

Assign versions with optimistic concurrency instead: write the new blob with a
"does not exist" generation precondition (x-goog-if-generation-match: 0). GCS
has no transaction spanning the list and the write, so a colliding writer now
fails the precondition (HTTP 412); Save re-reads the versions and retries with
a fresh number, using a jittered backoff between attempts and bounded by
maxSaveAttempts. It returns errVersionConflict if the budget is exhausted
rather than overwriting an existing version. N concurrent saves therefore yield
exactly versions 1..N with no lost writes.

Testing Plan:
- go build -mod=readonly ./...
- go test -race -mod=readonly -count=1 -shuffle=on ./artifact/...
- golangci-lint run ./artifact/...
- go mod tidy -diff
- TestGCSArtifactServiceConcurrentSave: concurrent savers assert distinct,
  contiguous versions and that nothing is overwritten (saver count kept within
  the retry budget). Verified it fails against the pre-fix code.
- TestSaveReturnsWriteErrorWithoutRetry: a non-precondition write error is
  surfaced as-is and not retried.
- TestSaveExhaustsRetriesOnPersistentConflict: persistent 412s give up with
  errVersionConflict after maxSaveAttempts.
- TestBackoffDelayBounds: jittered backoff stays within bounds.

* test(gcsartifact): cover the real precondition and zero-byte artifacts

The does-not-exist precondition on the production ObjectHandle wrapper had
no test: replacing it with a no-op left the whole suite green, so a
regression there would silently restore last-writer-wins. Add an
httptest-backed test that drives the real wrapper offline and asserts the
upload carries ifGenerationMatch=0 (the client sends the condition as a
query parameter, not a header).

The fake also used data != nil as its existence test, so a zero-byte
artifact -- reachable through the public API, since SaveRequest.Validate
only requires Part.InlineData -- was modelled as not existing: saving it
twice returned version 1 both times. Track existence in an explicit
exists flag instead, which also subsumes the deleted flag.

Also wrap the context error from the retry backoff, drop the unreachable
nil check on the sleep hook, and note the adk-python parity gap and the
per-attempt listing cost in the Save doc.

* docs(gcsartifact): drop the cross-implementation claim from Save

The caveat about other writers is a property of the scheme, so state it in
those terms rather than pinning it to another implementation's current
behaviour, which this repo cannot keep in sync.

(cherry picked from commit a90b910)

* fix(llminternal): guard RunLive errChan sends so abandoned producers exit on reconnect (#1167)

* fix(llminternal): guard RunLive errChan sends so abandoned producers exit on reconnect

RunLive's reader and sender goroutines did bare sends on the unbuffered,
per-attempt errChan. Once the consumer loop stops reading that channel --
after a resumable-error reconnect, a parent context cancellation, or any
other teardown -- cleanup() closes the connection, the producer's in-flight
call fails, and its send blocks forever, leaking one goroutine (plus its
captured connection state) per occurrence. Long-lived /run_live servers
accumulate these across sessions and reconnects.

Guard all three sends with the same select-on-connCtx idiom the function
already uses for eventsChan. cleanup() always cancels connCtx before the
next attempt (and before returning), so a guarded send is guaranteed to
unblock. connCtx, not ctx: ctx outlives reconnects and would not unblock
an abandoned producer.

The regression test fakes the Gemini Live wire protocol over a local
websocket server and fails deterministically on the unfixed code with
goroutines parked in 'chan send' at the bare-send sites.

Fixes #1152

* test(llminternal): cover the SendRealtime guard and stop the leak detector failing open

Addresses review feedback on the RunLive errChan leak fix.

runLiveStacks sized its buffer once at 1 MiB and never checked for
truncation. runtime.Stack truncates silently, so a dump larger than the
buffer would under-count parked goroutines and let a real leak pass --
the wrong failure direction for a leak detector. Grow until the whole
dump fits.

Add a case driving RealtimeInput across a connection loss. Reverting only
the SendRealtime guard left every existing case green, so that third
errChan send site was uncovered; nothing in the suite populated
LiveRequest.RealtimeInput. Each of the three guards is now caught
individually when reverted.

Rename "non-resumable error terminates" and record why it passes with or
without the guards: the sender exits through the existing connCtx.Done()
arm, so nothing is stranded. It pins terminal-path behaviour rather than
guarding the leak regression.

---------

Co-authored-by: westerberg <[email protected]>

(cherry picked from commit c108489)

* fix(llminternal): close the live connection when SendHistory fails (#1261)

RunLive's SendHistory failure path was the one return in the function's own
body that released neither connCtx nor liveConn, so the websocket outlived
the invocation.

Cancelling the context would not have been enough: genai dials the live
socket with websocket.DefaultDialer.Dial, which takes no context, and
nothing in the SDK watches one -- Session.Close closing the underlying conn
is the only thing that releases it. So an early return here stranded the
connection for the life of the process, not merely until the invocation
ended. go vet's lostcancel does not catch it because cancelConn is reached
through the cleanup closure.

Call cleanup() before returning, matching every other return in the body.

The regression test drives a real websocket through the existing fake Live
server and asserts the server observes the client's close. To keep the
observation meaningful the send has to fail client-side -- a broken socket
would fail it too, but leave nothing to observe -- so the test feeds
SendHistory a turn carrying NaN, which json.Marshal rejects inside the SDK
while the connection stays healthy. On the unfixed code the server sits
until its read deadline expires and the test fails.

(cherry picked from commit 73c71de)

---------

Co-authored-by: Madan kumar <[email protected]>
Co-authored-by: chethanuk <[email protected]>
Co-authored-by: João Westerberg <[email protected]>
kdroste-google pushed a commit to kdroste-google/adk-go that referenced this pull request Sep 29, 2026
…oogle#1172)

* fix(gcsartifact): prevent artifact version race in concurrent Save

Save assigned versions non-atomically: it listed the existing versions,
computed max+1, and wrote that blob. Two concurrent Save calls for the same
artifact would read the same version set, pick the same next version, and the
second write would silently overwrite the first — duplicate returned versions
and a lost object. The code carried a TODO acknowledging this.

Assign versions with optimistic concurrency instead: write the new blob with a
"does not exist" generation precondition (x-goog-if-generation-match: 0). GCS
has no transaction spanning the list and the write, so a colliding writer now
fails the precondition (HTTP 412); Save re-reads the versions and retries with
a fresh number, using a jittered backoff between attempts and bounded by
maxSaveAttempts. It returns errVersionConflict if the budget is exhausted
rather than overwriting an existing version. N concurrent saves therefore yield
exactly versions 1..N with no lost writes.

Testing Plan:
- go build -mod=readonly ./...
- go test -race -mod=readonly -count=1 -shuffle=on ./artifact/...
- golangci-lint run ./artifact/...
- go mod tidy -diff
- TestGCSArtifactServiceConcurrentSave: concurrent savers assert distinct,
  contiguous versions and that nothing is overwritten (saver count kept within
  the retry budget). Verified it fails against the pre-fix code.
- TestSaveReturnsWriteErrorWithoutRetry: a non-precondition write error is
  surfaced as-is and not retried.
- TestSaveExhaustsRetriesOnPersistentConflict: persistent 412s give up with
  errVersionConflict after maxSaveAttempts.
- TestBackoffDelayBounds: jittered backoff stays within bounds.

* test(gcsartifact): cover the real precondition and zero-byte artifacts

The does-not-exist precondition on the production ObjectHandle wrapper had
no test: replacing it with a no-op left the whole suite green, so a
regression there would silently restore last-writer-wins. Add an
httptest-backed test that drives the real wrapper offline and asserts the
upload carries ifGenerationMatch=0 (the client sends the condition as a
query parameter, not a header).

The fake also used data != nil as its existence test, so a zero-byte
artifact -- reachable through the public API, since SaveRequest.Validate
only requires Part.InlineData -- was modelled as not existing: saving it
twice returned version 1 both times. Track existence in an explicit
exists flag instead, which also subsumes the deleted flag.

Also wrap the context error from the retry backoff, drop the unreachable
nil check on the sleep hook, and note the adk-python parity gap and the
per-attempt listing cost in the Save doc.

* docs(gcsartifact): drop the cross-implementation claim from Save

The caveat about other writers is a property of the scheme, so state it in
those terms rather than pinning it to another implementation's current
behaviour, which this repo cannot keep in sync.
li-xuankun312 added a commit to li-xuankun312/einsteinOS that referenced this pull request Sep 29, 2026
…oogle#1172)

* fix(gcsartifact): prevent artifact version race in concurrent Save

Save assigned versions non-atomically: it listed the existing versions,
computed max+1, and wrote that blob. Two concurrent Save calls for the same
artifact would read the same version set, pick the same next version, and the
second write would silently overwrite the first — duplicate returned versions
and a lost object. The code carried a TODO acknowledging this.

Assign versions with optimistic concurrency instead: write the new blob with a
"does not exist" generation precondition (x-goog-if-generation-match: 0). GCS
has no transaction spanning the list and the write, so a colliding writer now
fails the precondition (HTTP 412); Save re-reads the versions and retries with
a fresh number, using a jittered backoff between attempts and bounded by
maxSaveAttempts. It returns errVersionConflict if the budget is exhausted
rather than overwriting an existing version. N concurrent saves therefore yield
exactly versions 1..N with no lost writes.

Testing Plan:
- go build -mod=readonly ./...
- go test -race -mod=readonly -count=1 -shuffle=on ./artifact/...
- golangci-lint run ./artifact/...
- go mod tidy -diff
- TestGCSArtifactServiceConcurrentSave: concurrent savers assert distinct,
  contiguous versions and that nothing is overwritten (saver count kept within
  the retry budget). Verified it fails against the pre-fix code.
- TestSaveReturnsWriteErrorWithoutRetry: a non-precondition write error is
  surfaced as-is and not retried.
- TestSaveExhaustsRetriesOnPersistentConflict: persistent 412s give up with
  errVersionConflict after maxSaveAttempts.
- TestBackoffDelayBounds: jittered backoff stays within bounds.

* test(gcsartifact): cover the real precondition and zero-byte artifacts

The does-not-exist precondition on the production ObjectHandle wrapper had
no test: replacing it with a no-op left the whole suite green, so a
regression there would silently restore last-writer-wins. Add an
httptest-backed test that drives the real wrapper offline and asserts the
upload carries ifGenerationMatch=0 (the client sends the condition as a
query parameter, not a header).

The fake also used data != nil as its existence test, so a zero-byte
artifact -- reachable through the public API, since SaveRequest.Validate
only requires Part.InlineData -- was modelled as not existing: saving it
twice returned version 1 both times. Track existence in an explicit
exists flag instead, which also subsumes the deleted flag.

Also wrap the context error from the retry backoff, drop the unreachable
nil check on the sleep hook, and note the adk-python parity gap and the
per-attempt listing cost in the Save doc.

* docs(gcsartifact): drop the cross-implementation claim from Save

The caveat about other writers is a property of the scheme, so state it in
those terms rather than pinning it to another implementation's current
behaviour, which this repo cannot keep in sync.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants