Skip to content

Fix WriteRecordsAsync writing a delimiter instead of a blank line for null records - #2384

Open
virzak wants to merge 2 commits into
JoshClose:masterfrom
virzak:fix/write-records-async-null-record
Open

Fix WriteRecordsAsync writing a delimiter instead of a blank line for null records#2384
virzak wants to merge 2 commits into
JoshClose:masterfrom
virzak:fix/write-records-async-null-record

Conversation

@virzak

@virzak virzak commented Jul 27, 2026

Copy link
Copy Markdown

The bug

WriteRecords(IEnumerable) and WriteRecordsAsync(IEnumerable) disagree about null records.

records: [ Foo(1,"one"), null, Foo(2,"two") ]

WriteRecords       ->  Id,Name\r\n1,one\r\n\r\n2,two\r\n
WriteRecordsAsync  ->  Id,Name\r\n1,one\r\n,\r\n2,two\r\n

The synchronous version has an explicit branch - "Since every record could be a different type, just write a blank line" - which the asynchronous version lacks, so the asynchronous path falls through to the write delegate and emits a delimiter per member instead. When the first record is null it throws a WriterException, because there is no type to resolve a delegate from.

git blame points at d0b6e3b8 ("Added nullable", May 2024), which updated the two copies inconsistently. It has been latent for a little over two years, because WriteNullTests covers the synchronous path only.

Commit 1 - the fix

Restores the branch in WriteRecordsAsync, and adds the missing asynchronous twins to WriteNullTests. Those tests fail before the change and pass after. This commit is self-contained - if you would rather not take the second one, it stands on its own.

Commit 2 - removing the duplication

Three comments in these files are doing real work:

  • CsvReader.Read() - "Don't forget about the async method below!"
  • CsvWriter.WriteRecords() - "Changes in this method require changes in method WriteRecords<T>(IEnumerable<T>) also."
  • WriteRecordsAsync() - "These methods should all be the same"

The bug above is what happens when one of them is missed. This commit deletes the synchronous implementations and generates them from the asynchronous ones with Zomp.SyncMethodGenerator, so the two cannot drift apart again:

Method
CsvWriter.WriteRecords(IEnumerable) generated
CsvWriter.WriteRecords<T>(IEnumerable<T>) generated
CsvWriter.NextRecord() generated
CsvWriter.Flush() generated
CsvReader.Read() generated
CsvParser.Read() generated

Around 200 lines of duplicated implementation removed. The generator is build time only (PrivateAssets="all"), contributes nothing to the shipped assembly, and supports every framework this project targets.

Disclosure: I maintain that generator. It is also used by FluentValidation and MiniExcel (mini-software/MiniExcel#799).

Deliberately left alone

  • FlushBuffer - its documentation says "Asynchronously flushes the buffer", and documentation is copied to the generated method as written, so the synchronous one would carry a wrong summary. NextRecord and Flush were fine because they use <inheritdoc/>.
  • GetRecords / EnumerateRecords - their ObjectDisposedException messages name the method, and the asynchronous copies still describe the synchronous overload ("GetRecords<T>() returns an IEnumerable<T>"). Generating would push that text into the synchronous side.

Verification

Builds on all seven target frameworks. net9.0: 1061 passed, 4 failed. net48: 1051 passed, 4 failed. The 4 are pre-existing failures on master (culture dependent date formatting) and unrelated to this change. Two more tests pass than on master, from the added asynchronous coverage.

virzak added 2 commits July 27, 2026 16:22
WriteRecords(IEnumerable) writes a blank line when a record is null, since
every record in an untyped sequence could be a different type. The
asynchronous overload lost that branch and falls through to the write
delegate instead, so it emits a delimiter per member, and throws when the
first record is null because there is no type to resolve a delegate from.

The two implementations were last changed together in d0b6e3b, which updated
them inconsistently. WriteNullTests only covered the synchronous path, so
nothing noticed.

Add the branch back, and give the null tests asynchronous counterparts.
Three comments in these files ask that the synchronous and asynchronous
implementations be kept identical by hand:

  CsvReader.Read       Don't forget about the async method below!
  CsvWriter.WriteRecords    Changes in this method require changes in
                            method WriteRecords<T>(IEnumerable<T>) also.
  CsvWriter.WriteRecordsAsync   These methods should all be the same

The previous commit fixes what happens when one of them is missed.

Delete the synchronous implementations and generate them from the
asynchronous ones instead, so that the two cannot drift apart again:

  CsvWriter.WriteRecords(IEnumerable)
  CsvWriter.WriteRecords<T>(IEnumerable<T>)
  CsvWriter.NextRecord
  CsvWriter.Flush
  CsvReader.Read
  CsvParser.Read

The generator runs at build time only and contributes nothing to the
shipped assembly.

Two pairs are left as they are. FlushBuffer documents itself as
asynchronous, and documentation is copied to the generated method as
written. GetRecords and EnumerateRecords name the method in an exception
message, and the asynchronous copies of those messages still describe the
synchronous overload.
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.

1 participant