-
Notifications
You must be signed in to change notification settings - Fork 369
feat(orch): last-cycle memory prefetch on resume #3258
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
kalyazin
wants to merge
5
commits into
main
Choose a base branch
from
kalyazin/resume-fulllife-prefetch
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
59bc45b
refactor(orch): fetch prefetch pages by contiguous extent
kalyazin 7e1a2b4
feat(orch): coalesce contiguous prefetch fetches
kalyazin 31422a0
feat(orch): prefetch the last-cycle memory diff on resume
kalyazin a0a40f0
feat(orch): cap last-cycle resume prefetch replay volume
kalyazin 6ad33d6
test(orch): cover diff prefetch mapping, source select, cap and coale…
kalyazin File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,144 @@ | ||
| //go:build linux | ||
|
|
||
| package sandbox | ||
|
|
||
| import ( | ||
| "slices" | ||
|
|
||
| "github.com/google/uuid" | ||
|
|
||
| "github.com/e2b-dev/infra/packages/orchestrator/pkg/template/metadata" | ||
| "github.com/e2b-dev/infra/packages/shared/pkg/storage/header" | ||
| "github.com/e2b-dev/infra/packages/shared/pkg/units" | ||
| ) | ||
|
|
||
| // resume-prefetch-source flag values (see featureflags.ResumePrefetchSourceFlag). | ||
| const ( | ||
| resumePrefetchOff = "off" | ||
| resumePrefetchInit = "init" | ||
| resumePrefetchLastCycle = "last-cycle" | ||
| resumePrefetchBoth = "both" | ||
| ) | ||
|
|
||
| // selectResumePrefetch maps the resume-prefetch-source flag value to which | ||
| // traces the resume prefetcher replays. "init" (and any unknown value) keeps | ||
| // today's behavior — replay only the build-time/harvested init trace — so the | ||
| // default is a no-op-equivalent. | ||
| func selectResumePrefetch(source string) (useInit, useLastCycle bool) { | ||
| switch source { | ||
| case resumePrefetchOff: | ||
| return false, false | ||
| case resumePrefetchLastCycle: | ||
| return false, true | ||
| case resumePrefetchBoth: | ||
| return true, true | ||
| default: // resumePrefetchInit and any unrecognized value | ||
| return true, false | ||
| } | ||
| } | ||
|
|
||
| // capResumePrefetch bounds a prefetch mapping to at most maxMiB of blocks, | ||
| // keeping the earliest (offset-order) blocks and leaving the rest to | ||
| // demand-fault. maxMiB < 0 means uncapped; m is returned unchanged when it | ||
| // already fits, the cap is disabled, or the block size is unknown. A nil | ||
| // mapping returns nil. | ||
| func capResumePrefetch(m *metadata.MemoryPrefetchMapping, maxMiB int) *metadata.MemoryPrefetchMapping { | ||
| if m == nil || maxMiB < 0 || m.BlockSize <= 0 { | ||
| return m | ||
| } | ||
|
|
||
| maxBlocks := units.MBToBytes(int64(maxMiB)) / m.BlockSize | ||
| if int64(len(m.Indices)) <= maxBlocks { | ||
| return m | ||
| } | ||
|
|
||
| return &metadata.MemoryPrefetchMapping{ | ||
| Indices: m.Indices[:maxBlocks], | ||
| BlockSize: m.BlockSize, | ||
| } | ||
| } | ||
|
|
||
| // buildDiffMemoryPrefetchMapping builds a prefetch mapping over only the | ||
| // blocks owned by this snapshot's own pause diff (BuildMap.BuildId == | ||
| // Metadata.BuildId) — i.e. the pages the last resume→pause cycle actually | ||
| // wrote (the sandbox's "last-cycle" working set). | ||
| // | ||
| // Pause/resume carries no build-time prefetch mapping (SameVersionTemplate | ||
| // drops it on the resumed snapshot's metadata), so today it demand-faults | ||
| // everything cold. But the pages a resume→pause cycle wrote are already | ||
| // recorded: they ARE the pause diff, present in the merged memfile header as | ||
| // the BuildMap entries whose BuildId equals this header's own build ID. No | ||
| // separate dirty-bitmap capture at pause time is needed — deriving the | ||
| // mapping from the header at resume is sufficient and reuses data that | ||
| // already exists. | ||
| // | ||
| // Blocks are enumerated at the memfile's block size (the prefetcher's fetch | ||
| // unit) and returned in offset order — readahead- and coalescing-friendly, | ||
| // and there is no access-order predictor for pause/resume anyway. | ||
| // | ||
| // Returns nil if the diff is empty, the header/metadata isn't usable, or the | ||
| // header has no base layer. The last guard matters: a base / build template | ||
| // (as opposed to a paused snapshot) maps its entire memfile to its own | ||
| // BuildId, so without it a create/build resume with resume-prefetch-source | ||
| // including last-cycle would prefetch the whole template image, not a diff. | ||
| func buildDiffMemoryPrefetchMapping(h *header.Header) *metadata.MemoryPrefetchMapping { | ||
| if h == nil || h.Metadata == nil || h.Metadata.BlockSize == 0 { | ||
| return nil | ||
| } | ||
|
|
||
| blockSize := int64(h.Metadata.BlockSize) | ||
| own := h.Metadata.BuildId | ||
|
|
||
| // A merged/dedup memfile header has overlapping BuildMap entries across | ||
| // layers (precedence is resolved at read time), so the same block offset | ||
| // can appear in multiple entries. Dedup by block index: fetch each block | ||
| // once (source.Slice resolves the top layer itself). Offsets/lengths are | ||
| // uint64; walk in uint64 to avoid an int64 cast that could overflow. | ||
| uBlockSize := uint64(blockSize) | ||
| seen := make(map[uint64]struct{}) | ||
| hasBase := false | ||
| for _, bm := range h.Mapping.All() { | ||
| if bm.BuildId == uuid.Nil { | ||
| continue | ||
| } | ||
| if bm.BuildId != own { | ||
| hasBase = true // a distinct base/parent layer exists | ||
|
|
||
| continue | ||
| } | ||
| if bm.Length == 0 { | ||
| continue | ||
| } | ||
|
|
||
| // Enumerate by block index, not by byte stride. A merged/dedup entry | ||
| // can start unaligned to the block size (dedup emits PageSize-granular | ||
| // entries, and NormalizeMappings merges adjacent same-build runs), so | ||
| // stepping by uBlockSize from an unaligned Offset would skip the final | ||
| // block whenever the entry straddles a block boundary. | ||
| firstBlock := bm.Offset / uBlockSize | ||
| lastBlock := (bm.Offset + bm.Length - 1) / uBlockSize | ||
| for idx := firstBlock; idx <= lastBlock; idx++ { | ||
| seen[idx] = struct{}{} | ||
| } | ||
| } | ||
|
|
||
| // A header that maps every block to its own BuildId has no base layer — it | ||
| // is a full image (a base / build template), not a pause diff. Returning | ||
| // its "own" blocks would prefetch the entire template on a normal | ||
| // create/build resume, so only treat own-BuildId blocks as a last-cycle | ||
| // diff when the snapshot is layered on a distinct base. | ||
| if !hasBase || len(seen) == 0 { | ||
| return nil | ||
| } | ||
|
|
||
| indices := make([]uint64, 0, len(seen)) | ||
| for idx := range seen { | ||
| indices = append(indices, idx) | ||
| } | ||
| slices.Sort(indices) | ||
|
|
||
| return &metadata.MemoryPrefetchMapping{ | ||
| Indices: indices, | ||
| BlockSize: blockSize, | ||
| } | ||
| } | ||
224 changes: 224 additions & 0 deletions
224
packages/orchestrator/pkg/sandbox/resume_prefetch_test.go
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,224 @@ | ||
| //go:build linux | ||
|
|
||
| package sandbox | ||
|
|
||
| import ( | ||
| "testing" | ||
|
|
||
| "github.com/google/uuid" | ||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
|
|
||
| "github.com/e2b-dev/infra/packages/orchestrator/pkg/template/metadata" | ||
| "github.com/e2b-dev/infra/packages/shared/pkg/storage/header" | ||
| ) | ||
|
|
||
| const testBlockSize = 4096 | ||
|
|
||
| // buildTestHeader assembles a header.Header at testBlockSize for the given | ||
| // own build ID and raw BuildMap entries (deliberately allowed to overlap, | ||
| // since real merged/dedup headers can have overlapping entries across | ||
| // layers). | ||
| func buildTestHeader(t *testing.T, own uuid.UUID, entries []header.BuildMap) *header.Header { | ||
| t.Helper() | ||
|
|
||
| mapping, err := header.NewMapping(testBlockSize, entries) | ||
| require.NoError(t, err) | ||
|
|
||
| return &header.Header{ | ||
| Metadata: &header.Metadata{ | ||
| BlockSize: testBlockSize, | ||
| BuildId: own, | ||
| }, | ||
| Mapping: mapping, | ||
| } | ||
| } | ||
|
|
||
| func TestBuildDiffMemoryPrefetchMapping(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| own := uuid.New() | ||
| other := uuid.New() | ||
|
|
||
| t.Run("selects only own-build blocks and excludes other builds", func(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| h := buildTestHeader(t, own, []header.BuildMap{ | ||
| {Offset: 0, Length: testBlockSize, BuildId: own}, | ||
| {Offset: 2 * testBlockSize, Length: testBlockSize, BuildId: other}, | ||
| {Offset: 3 * testBlockSize, Length: testBlockSize, BuildId: own}, | ||
| }) | ||
|
|
||
| got := buildDiffMemoryPrefetchMapping(h) | ||
|
|
||
| require.NotNil(t, got) | ||
| assert.Equal(t, []uint64{0, 3}, got.Indices, "only own-BuildId blocks, offset order") | ||
| assert.Equal(t, int64(testBlockSize), got.BlockSize) | ||
| }) | ||
|
|
||
| t.Run("dedups a block index covered by multiple own-build entries", func(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| // Simulates a merged header where the same block offset is covered by | ||
| // more than one BuildMap entry attributed to this build (e.g. | ||
| // overlapping ranges folded in across layers): block index 1 is | ||
| // covered by both own entries below and must be counted once. The | ||
| // trailing base entry makes this a diff (layered on a base), not a full | ||
| // image. | ||
| h := buildTestHeader(t, own, []header.BuildMap{ | ||
| {Offset: 0, Length: 2 * testBlockSize, BuildId: own}, // blocks 0, 1 | ||
| {Offset: testBlockSize, Length: 2 * testBlockSize, BuildId: own}, // blocks 1, 2 | ||
| {Offset: 5 * testBlockSize, Length: testBlockSize, BuildId: other}, | ||
| }) | ||
|
|
||
| got := buildDiffMemoryPrefetchMapping(h) | ||
|
|
||
| require.NotNil(t, got) | ||
| assert.Equal(t, []uint64{0, 1, 2}, got.Indices, "each block index counted exactly once") | ||
| }) | ||
|
|
||
| t.Run("full image with no base layer (base/build template) returns nil", func(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| // Every block owned by this build's own ID and no distinct base layer: | ||
| // a base/build template, not a pause diff. Prefetching all of it would | ||
| // pull the whole template image, so it must be skipped. | ||
| h := buildTestHeader(t, own, []header.BuildMap{ | ||
| {Offset: 0, Length: 3 * testBlockSize, BuildId: own}, | ||
| }) | ||
|
|
||
| assert.Nil(t, buildDiffMemoryPrefetchMapping(h)) | ||
| }) | ||
|
|
||
| t.Run("nil header returns nil", func(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| assert.Nil(t, buildDiffMemoryPrefetchMapping(nil)) | ||
| }) | ||
|
|
||
| t.Run("nil metadata returns nil", func(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| assert.Nil(t, buildDiffMemoryPrefetchMapping(&header.Header{Metadata: nil})) | ||
| }) | ||
|
|
||
| t.Run("zero block size returns nil", func(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| h := &header.Header{Metadata: &header.Metadata{BlockSize: 0, BuildId: own}} | ||
| assert.Nil(t, buildDiffMemoryPrefetchMapping(h)) | ||
| }) | ||
|
|
||
| t.Run("empty diff (no own-build entries) returns nil", func(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| h := buildTestHeader(t, own, []header.BuildMap{ | ||
| {Offset: 0, Length: testBlockSize, BuildId: other}, | ||
| }) | ||
|
|
||
| assert.Nil(t, buildDiffMemoryPrefetchMapping(h)) | ||
| }) | ||
|
|
||
| t.Run("empty mapping returns nil", func(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| h := buildTestHeader(t, own, nil) | ||
| assert.Nil(t, buildDiffMemoryPrefetchMapping(h)) | ||
| }) | ||
| } | ||
|
|
||
| func TestSelectResumePrefetch(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| cases := []struct { | ||
| source string | ||
| init, lastCycle bool | ||
| }{ | ||
| {"off", false, false}, | ||
| {"init", true, false}, | ||
| {"last-cycle", false, true}, | ||
| {"both", true, true}, | ||
| {"", true, false}, // unknown -> init (default) | ||
| {"garbage", true, false}, // unknown -> init (default) | ||
| } | ||
| for _, c := range cases { | ||
| i, f := selectResumePrefetch(c.source) | ||
| assert.Equalf(t, c.init, i, "useInit for source %q", c.source) | ||
| assert.Equalf(t, c.lastCycle, f, "useLastCycle for source %q", c.source) | ||
| } | ||
| } | ||
|
|
||
| func TestCapResumePrefetch(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| const bs = int64(2 * 1024 * 1024) | ||
| // 5 blocks == 10 MiB. | ||
| m := &metadata.MemoryPrefetchMapping{Indices: []uint64{0, 1, 2, 3, 4}, BlockSize: bs} | ||
|
|
||
| t.Run("uncapped passes through unchanged", func(t *testing.T) { | ||
| t.Parallel() | ||
| assert.Same(t, m, capResumePrefetch(m, -1)) | ||
| }) | ||
|
|
||
| t.Run("cap larger than the set passes through unchanged", func(t *testing.T) { | ||
| t.Parallel() | ||
| assert.Same(t, m, capResumePrefetch(m, 100)) | ||
| }) | ||
|
|
||
| t.Run("cap truncates to the earliest blocks in offset order", func(t *testing.T) { | ||
| t.Parallel() | ||
| got := capResumePrefetch(m, 6) // 6 MiB / 2 MiB = 3 blocks | ||
| require.NotNil(t, got) | ||
| assert.Equal(t, []uint64{0, 1, 2}, got.Indices) | ||
| assert.Equal(t, bs, got.BlockSize) | ||
| assert.Equal(t, []uint64{0, 1, 2, 3, 4}, m.Indices, "original mapping is not mutated") | ||
| }) | ||
|
|
||
| t.Run("zero cap truncates to an empty set", func(t *testing.T) { | ||
| t.Parallel() | ||
| got := capResumePrefetch(m, 0) | ||
| require.NotNil(t, got) | ||
| assert.Empty(t, got.Indices, "0 MiB keeps no blocks; everything demand-faults") | ||
| assert.Equal(t, bs, got.BlockSize) | ||
| }) | ||
|
|
||
| t.Run("nil mapping stays nil", func(t *testing.T) { | ||
| t.Parallel() | ||
| assert.Nil(t, capResumePrefetch(nil, 4)) | ||
| }) | ||
|
|
||
| t.Run("unknown block size passes through unchanged", func(t *testing.T) { | ||
| t.Parallel() | ||
| z := &metadata.MemoryPrefetchMapping{Indices: []uint64{0, 1}, BlockSize: 0} | ||
| assert.Same(t, z, capResumePrefetch(z, 1)) | ||
| }) | ||
| } | ||
|
|
||
| // TestBuildDiffMemoryPrefetchMappingUnalignedEntry pins the fix for a dedup | ||
| // entry aligned to a smaller granule than Metadata.BlockSize that straddles a | ||
| // block boundary: enumerating by block index must record every block the entry | ||
| // covers, not just the first. BlockSize is 8192 while the own-build entry | ||
| // (offset 4096, length 8192) spans bytes 4096–12287 — blocks 0 and 1 — so | ||
| // byte-stepping from the unaligned offset would drop block 1. | ||
| func TestBuildDiffMemoryPrefetchMappingUnalignedEntry(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| own := uuid.New() | ||
| other := uuid.New() | ||
|
|
||
| mapping, err := header.NewMapping(4096, []header.BuildMap{ | ||
| {Offset: 0, Length: 4096, BuildId: other}, // base layer, so this reads as a diff | ||
| {Offset: 4096, Length: 8192, BuildId: own}, | ||
| }) | ||
| require.NoError(t, err) | ||
|
|
||
| h := &header.Header{ | ||
| Metadata: &header.Metadata{BlockSize: 8192, BuildId: own}, | ||
| Mapping: mapping, | ||
| } | ||
|
|
||
| got := buildDiffMemoryPrefetchMapping(h) | ||
| require.NotNil(t, got) | ||
| assert.Equal(t, []uint64{0, 1}, got.Indices, "the tail block of a cross-boundary entry must not be dropped") | ||
| assert.Equal(t, int64(8192), got.BlockSize) | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔴 The full-life prefetch mapping's inner loop walks each own-build
BuildMapentry by+= uBlockSizefrom the (possibly unaligned)bm.Offset, so a merged/dedup entry whoseOffsetisn't aligned toMetadata.BlockSizeAND extends into another block only records its first block — the tail block is silently missed and will demand-fault cold on resume, defeating full-life coverage for that fraction of the diff (reachable withHugePages+memfile-diff-dedupon andresume-prefetch-source=fulllife/both). Fix by iterating by block index the same waytemplate/header_metrics.go'smaxEntriesPerBlockalready does:firstBlock := bm.Offset/uBlockSize; lastBlock := (bm.Offset+bm.Length-1)/uBlockSize; for idx := firstBlock; idx <= lastBlock; idx++ { seen[idx] = struct{}{} }.Extended reasoning...
The bug
buildDiffMemoryPrefetchMapping(packages/orchestrator/pkg/sandbox/resume_prefetch.go:97-99) enumerates the blocks aBuildMapentry covers by walking bytes frombm.Offsetin steps ofuBlockSize:If
bm.Offsetis not aligned touBlockSizeand the entry extends into the next block, onlyfloor(Length / uBlockSize) + 1steps fire — landinguBlockSizeapart from the unaligned start — so a block whose byte range is covered but not fully spanned by oneuBlockSizestep is skipped.Reachability (the trigger chain)
All three conditions are reachable via existing feature-flag rollouts on this PR:
Metadata.BlockSize=HugepageSize(2 MiB). Withconfig.HugePageson (config/config.goMemfilePageSize(true) = HugepageSize),NewEmptystores this intoMetadata.BlockSize, andMetadata.NextGenerationpreserves it across pauses.PageSize(4 KiB) aligned, notHugepageSizealigned. WhenMemfileDiffDedupFlag.enabled=true,block/memfd.go:304setsDiffMetadata.BlockSize = header.PageSize;CreateMapping(mapping.go) then emits entries at PageSize.Mapping.Validateis explicitly called withPageSize(metadata.go:176,// Dedup emits PageSize-granular mappings; validate at PageSize.), so PageSize-aligned entries in a HugepageSize header are valid.NormalizeMappingsmerges adjacent same-build entries without any block-size alignment constraint, so a merged own-build entry can straddle a HugepageSize boundary.Step-by-step proof
Take dirty pages 511 and 512 (page 512 * 4096 = 2097152 = HugepageSize, so this run straddles the block 0/1 boundary).
CreateMappingemits one entry{Offset: 511*4096=2093056, Length: 2*4096=8192, BuildId: own}, and the loop withuBlockSize=2097152does:off = 2093056;2093056 < 2101248→ true;seen[2093056/2097152] = seen[0].off += 2097152 → 4190208.4190208 < 2101248? false → exit.Only
seen[0]is added. Block 1 (bytes 2097152–2101247), which the entry does cover (4 KiB of dirty data lives there), is silently missed. It will demand-fault cold on resume, defeating full-life prefetch for that block.Why existing safeguards don't prevent it
The codebase already knows this pattern —
packages/orchestrator/pkg/sandbox/template/header_metrics.go:78-79(maxEntriesPerBlock) walks the same merged mapping and uses:with an explicit
end > startbranch for runs spilling past their first block. Every other consumer that iterates the merged mapping at HugepageSize granularity uses this form. The newbuildDiffMemoryPrefetchMappingis the only walker that makes the (broken) assumption that own-build entries are block-aligned.Mapping.Validateonly enforces PageSize alignment for merged headers, so the header itself is valid — the new loop's assumption is what breaks.Impact
No crash, no data corruption, no correctness-of-guest-state issue: the guest still faults the missed blocks and reads them cold from object storage on demand. But the whole point of
buildDiffMemoryPrefetchMappingis to enumerate every block the last cycle wrote so the full-life prefetcher can warm them; silently dropping the tail of every cross-boundary run partially defeats the feature under the intendedHugePages+memfile-diff-dedup+resume-prefetch-source=fulllife/bothcombination — which is a plausible near-term rollout combination.Fix
Iterate by block index instead of by unaligned byte offset — the one-line pattern already used in
header_metrics.go:There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed.
buildDiffMemoryPrefetchMappingnow enumerates by block index instead of byte-stepping from a possibly-unalignedbm.Offset—firstBlock = Offset/blockSize,lastBlock = (Offset+Length-1)/blockSize, iterate inclusive — so a merged/dedup entry that straddles a block boundary contributes every block it covers, not just the first. (Also guardsLength == 0.) Added a regression test (TestBuildDiffMemoryPrefetchMappingUnalignedEntry) with the exact 2 MiB-block / 4 KiB-aligned straddling case. Folded into the last-cycle feat commit (7bef957). Thanks for the detailed repro.