-
Notifications
You must be signed in to change notification settings - Fork 4.9k
Re-design Utf8JsonWriter as a regular class rather than a ref struct. #36961
Conversation
| #else | ||
| public | ||
| #endif | ||
| sealed class ArrayBufferWriter<T> : IBufferWriter<T> |
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.
Add where T:Struct to avoid mixing objects and memory.
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.
I would like to match the IBufferWriter<T> constraint here (i.e. none). If there is strong opposition, we should discuss both outside of this PR in an API review.
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.
I'd rather not artificially constrain it, unless there's good reason to.
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.
Calling GetMemory() would be a bit odd with an array of objects. I'm assuming we don't do anything unsafe with these object pointers like mem copying (in the middle of a gc).
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.
Calling GetMemory() would be a bit odd with an array of objects.
Why?
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 scenario is a bit edge case IMO - when would you pass a writeable buffer of objects? There's an indirection in memory (element->object) and "ownership" now becomes a bit more ambiguous - i.e. what if the objects implement IDispose -- who's going to call that?
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.
It's no different than storing objects into an array; when do you pass arrays of things, like strings? You also can have the exact same ownership question with structs, which might themselves have resources that have lifetime, or wrap any number of reference types. Limiting it is just an artificial restriction.
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.
I agree that limiting isn't a good thing if we don't need to.
Since Memory has lifetime semantics (don't hold onto it after your method returns etc) it is not the same as an array.
Consider a buffer type that is only for data (or primitives like char or byte - not objects) and in that case there could be some memory clear and copy-like efficiencies regarding clearing after use and shuffling unprocessed elements to the beginning when making room for more data. Originally I was thinking we may be doing some of these operations here which would make it unsafe for objects...
src/System.Text.Json/src/System/Text/Json/Serialization/PooledBufferWriter.cs
Outdated
Show resolved
Hide resolved
src/System.Memory/tests/ArrayBufferWriter/ArrayBufferWriterTests.Byte.cs
Show resolved
Hide resolved
src/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Write.Stream.cs
Outdated
Show resolved
Hide resolved
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.
Given this is a large, time sensitive change, and to stir up some more targeted review, I have highlighted/commented on a few things that I would like feedback on (or at least a second set of eyes).
| /// Represents a heap-based, array-backed output sink into which <typeparam name="T"/> data can be written. | ||
| /// </summary> | ||
| #if USE_ABW_INTERNALLY | ||
| internal |
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.
Is this pattern acceptable to use? If not, I am open to suggestions.
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.
We do this in other places as well, but I think it would be better to invert the condition, such that the type is only public if you explicitly set a compilation constant... otherwise, if a project includes this file, by default it's going to be exposing a new type publicly.
| if (initialCapacity <= 0) | ||
| throw new ArgumentException(nameof(initialCapacity)); | ||
|
|
||
| _buffer = new T[initialCapacity]; |
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.
Do we want to bound initialCapacity to MinimumBufferSize from the bottom or is this fine?
|
|
||
| if (sizeHint == 0) | ||
| { | ||
| sizeHint = MinimumBufferSize; |
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.
Any feedback/concerns on this growth strategy? Any call to GetSpan/GetMemory will return at least 256 bytes, if sizeHint is not specified.
This has consequences when used with an ArrayBufferWriter ctor that has initialCapacity.
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.
I was contemplating this when looking earlier. I'd say that sizeHint of 0 should imply sizeHint of 1 (simply "not empty" but avoids growing if it can)
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.
I had considered this, but in practice, most workloads would suffer from this since they can't make forward progress with 1 byte. It effectively makes calling GetSpan without an argument useless. I am not sure returning 1 is better. If there are no strong opinions here, I would let scenario-driven user feedback guide the change, and leave it as is for now.
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.
in practice, most workloads would suffer from this since they can't make forward progress with 1 byte. It effectively makes calling GetSpan without an argument useless.
I dunno, once the regrow happens it'll double in size, so you'll end up with one call between 1 and 256, then the "oh, I need more than that..." would get a bigger buffer.
I would let scenario-driven user feedback guide the change, and leave it as is for now.
Also seems valid, but if the caller really doesn't have any notion of a hint then it seems like any size is acceptable. Otherwise passing in min/max/accurate seems like the best bet.
src/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Write.HandleValue.cs
Show resolved
Hide resolved
| } | ||
|
|
||
| if (flushThreshold >= 0 && writer.BytesWritten > flushThreshold) | ||
| if (flushThreshold >= 0 && writer.BytesPending > flushThreshold) |
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.
Note that writer.BytesWritten previously showcased the total written (i.e. BytesCommitted + whats still pending to be flushed). BytesPending now only returns the pending data. Not sure if the semantics were broken before, or now (given that we previously were creating a new Utf8JsonWriter every time, but now are not, which likely resolves the issue).
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.
BytesPending seems right. Basically we want to call the writer before we fill the buffer too much and force it to grow if the next write pushes it over the edge (can still happen, but shouldn't be common).
| [MethodImpl(MethodImplOptions.AggressiveInlining)] | ||
| private void FlushHelper() | ||
| { | ||
| _output.Advance(BytesPending); |
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.
For the stream mode, should I use the concrete type _arrayBufferWriter.Advance() rather than going through an interface call like this?
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.
Why do we have this helper at all? It's a single line in a method (with a non-descriptive name) marked AggressiveInlining.
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.
Artifact of merging two concepts/terminology. With stream/IBW mode, we have "advance IBW" and we have "write/flush to stream", which effectively have the same semantics as far as the JsonWriter is concerned so I normalized it. This just helped me make sense of what operations I am doing while reading the source (normalizes to call Flush... for both cases).
This goes back to my original question of should I call _output.Advance in the stream case or call _arrayBufferWriter.Advance?
Do you think I should remove it regardless?
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.
I suggest you remove it and then at the call sites instead do whatever is the more efficient call.
| GrowAndEnsure(minimumSize); | ||
| alreadyWritten = 0; | ||
| } | ||
| int sizeHint = Math.Max(DefaultGrowthSize, requiredSize); |
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.
Is this a reasonable growth-scheme?
| [Theory] | ||
| [InlineData(false)] | ||
| [InlineData(true)] | ||
| public static void WriteNumberAsPropertyWithLargeName(bool indented) |
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.
Improves some test coverage.
| Assert.Equal(expectedString, actualString); | ||
| } | ||
|
|
||
| private static async Task WriteLargeToStreamHelper(Stream stream, JsonWriterOptions options) |
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.
This test captures the expected usage pattern for most users.
| jsonUtf8.Flush(); | ||
|
|
||
| // Explicitly skipping flushing here | ||
| var invalidUtf8 = new byte[2] { 0xc3, 0x28 }; |
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.
This test captures the current semantics for writing comments that contain invalid data. Let me know if its fine as is.
|
@mairaw, @rpetrusha - please feel free to review/provide feedback on some of the xml comments (particularly for newer APIs like Reset and the change from ref struct to class). This has implications on the recent docs change which will need to be updated: dotnet/dotnet-api-docs#1880 Your feedback/insights here would be much appreciated :) |
|
@ahson it's not clear to me if we can skip the escaping logic. Previously the serializer would ensure the property name is already escaped and skip the expensive check. I believe we discussed creating a new "resolved string" type that could be leveraged to avoid the perf hit. |
That's TBD atm. I will submit a proposal for this outside this PR and provide that functionality. Is there anything in the serializer code (in master) that used |
|
|
||
| namespace System.Buffers.Tests | ||
| { | ||
| public static partial class ArrayBufferWriterTests_String |
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.
I didn't realize we want to support this on non-primitive types; string is normally immutable as well.
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.
I didn't realize we want to support this on non-primitive types
This is a question on the interface, tbh:
| public interface IBufferWriter<T> |
The test using string is just to verify that T : reference type works fine. As you mentioned, strings are immutable, so I don't expect anyone to create an ABW<string>.
I can remove this test all together.
It doesn't have any constraints on T. Maybe it should be T: struct or T: unmanaged.
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.
Also cc @jkotas, if you have any thoughts on the issue.
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.
IBufferWriter shipped without any constraints. Do we consider it to be a mistake?
Since IBufferWriter does not have any constraints, ArrayBufferWriter should not have any constraints either for consistency.
Not currently. However all of the write method in the serializer need to be changed to support pre-escaping the property name (pass |
| <Target Name="GetSourcesToPackage" BeforeTargets="ExpandProjectReferences"> | ||
| <ItemGroup> | ||
| <_ProjectsToBuild Include="../src/System.Text.Json.csproj" UndefineProperties="Configuration" /> | ||
| <_ProjectsToBuild Include="../src/System.Text.Json.csproj" UndefineProperties="Configuration" AdditionalProperties="TargetGroup=netstandard"/> |
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.
@joperezr, please review the packaging change :)
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.
Left a comment regarding the define constant that you declare for netstandard, since you probably don't want to require a constant to be defined in order for the source package to work. Other than that, this particular change on the pkgproj looks good to me.
|
If there is no other critical feedback, I would like to merge this in to give upstream repositories time to react accordingly (at least to the API breaking changes). Other changes, like code refactoring, etc., can be made in a subsequent PR. |
| private T[] _buffer; | ||
| private int _index; | ||
|
|
||
| private const int MinimumBufferSize = 256; |
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.
This isn't actually MinimumBufferSize, at least not how it's currently used. A better name for the current usage would be DefaultInitialBufferSize, right?
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.
Yes, I was thinking the same thing. The only concern is that I use this to define the minimum size GetMemory/GetSpan can return as well.
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 only concern is that I use this to define the minimum size GetMemory/GetSpan can return as well.
It isn't the minimum, though. It's just the value that you pretend sizeHint was if it's 0. (GetSpan(1) is allowed to return a span of Length 1)
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.
Fair enough. I meant minimum in the "default" case, but you brought up a useful clarification.
| /// </summary> | ||
| public ArrayBufferWriter() | ||
| { | ||
| _buffer = new T[MinimumBufferSize]; |
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.
Why wouldn't we start with Array.Empty<T>()? Then if it's not actually written to, we don't allocate, and if it is written to, the first allocation will be based on the supplied sizeHint. If a typical usage is creating one of these and then passing it off to something that uses a sizeHint larger than 256, this is going to unnecessarily allocate a throw-away 256-byte buffer.
| Debug.Assert(FreeCapacity > 0 && FreeCapacity >= sizeHint); | ||
| } | ||
|
|
||
| private static void ThrowInvalidOperationException(int capacity) |
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.
Nit: Seems like this should be named ThrowAdvancedTooFarException, or something like that.
| /// <summary> | ||
| /// Returns the amount of space available that can still be written into without forcing the underlying buffer to grow. | ||
| /// </summary> | ||
| public int FreeCapacity => _buffer.Length - _index; |
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.
Do we really need this? Someone can just do Capacity - WrittenCount.
Also, WrittenCount is an uncommon name. How about Position?
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 serializer uses this but you are right it is primarily a convenience thing. Do you think its exposing too much or do you just think it is unnecessary and not worth the benefit it provides?
Regarding the naming/API changes like this, how do you feel about discussing it all up in a follow up review (post preview 5)? I prefer not to make these changes in PR (in isolation). WrittenCount fits nicer with WrittenMemory/Span (especially when passed in as "offset/count" to stream.write), imo, but I am not super attached to the name.
|
|
||
| namespace System.Buffers.Tests | ||
| { | ||
| public static partial class ArrayBufferWriterTests_Char |
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.
Why are we duplicating all of these tests for different data types? Wouldn't it be better to just have a generic base test type parameterized on T, and then have multiple derived types that provide T==char, T==string, etc.? This is a ton of what appears to be largely duplicated code.
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.
Yep, definitely. The only difference is that I have stream-based tests for T=byte, but that can be extracted out.
I, generally, don't mind code duplication in tests (for clarity/debugging failed tests), but in this case I agree it goes too far.
| <value>Serializer options cannot be changed once serialization or deserialization has occurred.</value> | ||
| </data> | ||
| <data name="StreamNotWritable" xml:space="preserve"> | ||
| <value>Stream was not writable.</value> |
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.
Nit: was => is ?
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.
Looks like I borrowed it from the wrong place:
| <value>Stream was not writable.</value> |
We aren't consistent here: https://github.com/dotnet/corefx/search?utf8=%E2%9C%93&q=stream+was+not+writable&type=
However, I will follow what StreamWriter does and change to is :)
| #if BUILDING_INBOX_LIBRARY | ||
| Span<char> temp = MemoryMarshal.CreateSpan<char>(ref value, 1); | ||
| Span<char> temp = MemoryMarshal.CreateSpan(ref value, 1); | ||
| writer.WriteStringValue(temp); |
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.
Nit: you don't actually need a temp here; it can be a single line to parallel the other else case, e.g.
writer.WriteStringValue(
#if BUILDING_INBOX_LIBRARY
MemoryMarshal.CreateSpan(ref value, 1)
#else
value.ToString()
#endif
);or something like that.
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.
I decided to keep changes outside of the writer minimal, but that would work.
However, your suggestion does make it a bit harder to read (but VS coloring would help).
cc @steveharter
| { | ||
| if (_rentedBuffer == null) | ||
| ThrowHelper.ThrowObjectDisposedException(nameof(ArrayBufferWriter<T>)); | ||
| ThrowHelper.ThrowObjectDisposedException(nameof(PooledBufferWriter<T>)); |
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.
When can this exception actually occur? And if it can occur and can leak out to callers, since "This is an implementation detail", do we really want the internal type's name in the exception message?
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.
This was copy/pasted from the existing API that was going to be public. Given this is totally internal now, we don't need such checks/exceptions (and can be Debug.Asserts). We should be using it correctly, internally.
| { | ||
| throw ThrowHelper.GetInvalidOperationException_CallFlushFirst(_buffered); | ||
| _arrayBufferWriter.Clear(); | ||
| } |
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.
Nit: _arrayBufferWriter?.Clear();
| _tokenType = state._tokenType; | ||
| _writerOptions = state._writerOptions; | ||
| _bitStack = state._bitStack; | ||
| _arrayBufferWriter = default; |
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.
Why do we clear it if we're just going to drop it?
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.
And if we must clear it for some reason (we don't do this in most other situations where we just let the GC clean up), this line should be moved into the if block. It should also use null instead of default.
| Debug.Assert(_arrayBufferWriter.WrittenSpan.Length == _arrayBufferWriter.WrittenCount); | ||
| _stream.Write(_arrayBufferWriter.WrittenSpan.ToArray(), 0, _arrayBufferWriter.WrittenCount); | ||
| #endif | ||
| _arrayBufferWriter.Clear(); |
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.
We end up clearing the writer, which means zero'ing all bytes written, every time we flush. That seems unnecessary. Shouldn't we be able to just reset the position to 0 and not clear at all? When we're done using the buffer writer we'll just drop it and let the GC clean up.
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.
Agreed.
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.
We don't have a "reset" method on the ArrayBufferWriter that just resets the position 0. Should we make the WrittenCount property settable, or just change Clear() to Reset() which doesn't clear?
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.
We don't have a "reset" method on the ArrayBufferWriter that just resets the position 0. Should we make the WrittenCount property settable, or just change Clear() to Reset() which doesn't clear?
That would be my preference, with it also renamed to Position 😉
| <!-- BUILDING_INBOX_LIBRARY is only false when building for netstandard to validate that the sources are netstandard compatible. --> | ||
| <!-- This is meant to help with producing a source package and not to ship a netstandard compatible binary. --> | ||
| <DefineConstants Condition="'$(TargetsNETStandard)' != 'true'">$(DefineConstants);BUILDING_INBOX_LIBRARY</DefineConstants> | ||
| <DefineConstants Condition="'$(TargetsNETStandard)' == 'true'">$(DefineConstants);USE_ABW_INTERNALLY</DefineConstants> |
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.
If this is required for NetStandard, keep in mind that the source package won't have this constant defined, so consumers of the source package will get the netstandard sources, but won't have this constant defined.
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.
Good point. It doesn't have any concerns here since all we are doing is going from public to internal (which the source package does anyway). But, I will invert the condition. @stephentoub provided the same feedback here: #36961 (comment)
…dotnet/corefx#36961) * Add an in-box array-backed IBufferWriter<T> * Update Utf8JsonWriter ref and main writer file. * Fix JsonWriter WriteValue APIs. * Use Environment.NewLine static and invert some stream conditions. * Update JsonWriter properties and fix serializer build breaks. * Update JsonWriter unit tests. * Add xml comments, clean up, and improve test coverage. * Update JsonDocument and JsonSerializer to react to JsonWriter changes. * Normalize the reference assembly. * Do not escape/validate comments and update issue number. * Do not escape comments and validate they don't contain embedded delimiter * Remove dead code and update issue number in comments. * Throw InvalidOEx instead of ArgEx if IBW doesn't give requested memory. * Fix test build breaks for netfx. * Remove dead code and fix source packaging. * Address PR feedback. * Disable writing floats test on windows * 8 digit floats don't work well on older TFMs. Reduce to 7. Commit migrated from dotnet/corefx@1f9b84a
The first commit (i.e. a19d268 - Add an in-box array-backed IBufferWriter), fixes https://github.com/dotnet/corefx/issues/34894.
Related PR: #35094
That commit can be reviewed independently of the
Utf8JsonWriterchanges but since there was a strong dependency between these components, I submitted them together.The remaining commits update the
Utf8JsonWriterdesign based on recent usability and reliability feedback: https://gist.github.com/ahsonkhan/f6f30656717548212693e5eaa49cece5API Review Feedback TL;DR:
Utf8JsonWritershould implementIDisposableandIAsyncDisposable(on latest platform).BytesWrittenshould be renamed toBytesPendingisFinalBlockbool on flush (the user would need to validate correctness themselves).Reset()andReset(...)overloads that allow re-using theUtf8JsonWriterinstance.Clear()and clear the memory field on flush to avoid forcing the caller to reset after flushing.Utf8JsonWriter.Dispose(?).JsonWriterOptionsa struct. It cannot be modified once passed in to the constructor.ArrayBufferWriterto have the same code path for bothStreamandIBufferWritermodes.Left TODO:
This change also fixes https://github.com/dotnet/corefx/issues/35182
Note: Fixed sized IBW without slack capacity are generally not supported since the writer tends to eagerly ask for space and an exact, fixed-sized IBW would fail the
GetSpan(minimumSize)contract.cc @davidfowl, @KrzysztofCwalina, @stephentoub, @steveharter, @bartonjs, @layomia, @JeremyKuhne, @GrabYourPitchforks, @BrennanConroy, @rynowak, @pranavkm, @pakrym, @terrajobst, @glennc, @joshfree, @JamesNK