Skip to content

Commit ab0cb6f

Browse files
ncrucesAdrian Cole
andauthored
Fix fdRead: handle io.Reader corner cases (#740)
Signed-off-by: Nuno Cruces <[email protected]> Signed-off-by: Adrian Cole <[email protected]> Co-authored-by: Adrian Cole <[email protected]>
1 parent 087ab9d commit ab0cb6f

File tree

3 files changed

+144
-8
lines changed

3 files changed

+144
-8
lines changed

RATIONALE.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,36 @@ See
441441
* https://github.com/golang/go/blob/go1.19rc2/src/syscall/fs_js.go#L324
442442
* https://github.com/WebAssembly/wasi-libc/pull/214#issue-673090117
443443

444+
### Why ignore the error returned by io.Reader when n > 1?
445+
446+
Per https://pkg.go.dev/io#Reader, if we receive an error, any bytes read should
447+
be processed first. At the syscall abstraction (`fd_read`), the caller is the
448+
processor, so we can't process the bytes inline and also return the error (as
449+
`EIO`).
450+
451+
Let's assume we want to return the bytes read on error to the caller. This
452+
implies we at least temporarily ignore the error alongside them. The choice
453+
remaining is whether to persist the error returned with the read until a
454+
possible next call, or ignore the error.
455+
456+
If we persist an error returned, it would be coupled to a file descriptor, but
457+
effectively it is boolean as this case coerces to `EIO`. If we track a "last
458+
error" on a file descriptor, it could be complicated for a couple reasons
459+
including whether the error is transient or permanent, or if the error would
460+
apply to any FD operation, or just read. Finally, there may never be a
461+
subsequent read as perhaps the bytes leading up to the error are enough to
462+
satisfy the processor.
463+
464+
This decision boils down to whether or not to track an error bit per file
465+
descriptor or not. If not, the assumption is that a subsequent operation would
466+
also error, this time without reading any bytes.
467+
468+
The current opinion is to go with the simplest path, which is to return the
469+
bytes read and ignore the error the there were any. Assume a subsequent
470+
operation will err if it needs to. This helps reduce the complexity of the code
471+
in wazero and also accommodates the scenario where the bytes read are enough to
472+
satisfy its processor.
473+
444474
### FdPrestatDirName
445475

446476
`FdPrestatDirName` is a WASI function to return the path of the pre-opened directory of a file descriptor.

wasi_snapshot_preview1/fs.go

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,7 @@ var fdRead = wasm.NewGoFunc(
386386
[]string{"fd", "iovs", "iovs_len", "result.size"},
387387
func(ctx context.Context, mod api.Module, fd, iovs, iovsCount, resultSize uint32) Errno {
388388
sysCtx := mod.(*wasm.CallContext).Sys
389+
mem := mod.Memory()
389390
reader := internalsys.FdReader(ctx, sysCtx, fd)
390391
if reader == nil {
391392
return ErrnoBadf
@@ -394,33 +395,53 @@ var fdRead = wasm.NewGoFunc(
394395
var nread uint32
395396
for i := uint32(0); i < iovsCount; i++ {
396397
iovPtr := iovs + i*8
397-
offset, ok := mod.Memory().ReadUint32Le(ctx, iovPtr)
398+
offset, ok := mem.ReadUint32Le(ctx, iovPtr)
398399
if !ok {
399400
return ErrnoFault
400401
}
401-
l, ok := mod.Memory().ReadUint32Le(ctx, iovPtr+4)
402+
l, ok := mem.ReadUint32Le(ctx, iovPtr+4)
402403
if !ok {
403404
return ErrnoFault
404405
}
405-
b, ok := mod.Memory().Read(ctx, offset, l)
406+
b, ok := mem.Read(ctx, offset, l)
406407
if !ok {
407408
return ErrnoFault
408409
}
409-
n, err := reader.Read(b) // Note: n <= l
410+
411+
n, err := reader.Read(b)
410412
nread += uint32(n)
411-
if errors.Is(err, io.EOF) {
413+
414+
shouldContinue, errno := fdRead_shouldContinueRead(uint32(n), l, err)
415+
if errno != 0 {
416+
return errno
417+
} else if !shouldContinue {
412418
break
413-
} else if err != nil {
414-
return ErrnoIo
415419
}
416420
}
417-
if !mod.Memory().WriteUint32Le(ctx, resultSize, nread) {
421+
if !mem.WriteUint32Le(ctx, resultSize, nread) {
418422
return ErrnoFault
419423
}
420424
return ErrnoSuccess
421425
},
422426
)
423427

428+
// fdRead_shouldContinueRead decides whether to continue reading the next iovec
429+
// based on the amount read (n/l) and a possible error returned from io.Reader.
430+
//
431+
// Note: When there are both bytes read (n) and an error, this continues.
432+
// See /RATIONALE.md "Why ignore the error returned by io.Reader when n > 1?"
433+
func fdRead_shouldContinueRead(n, l uint32, err error) (bool, Errno) {
434+
if errors.Is(err, io.EOF) {
435+
return false, 0 // EOF isn't an error, and we shouldn't continue.
436+
} else if err != nil && n == 0 {
437+
return false, ErrnoIo
438+
} else if err != nil {
439+
return false, 0 // Allow the caller to process n bytes.
440+
}
441+
// Continue reading, unless there's a partial read or nothing to read.
442+
return n == l && n != 0, 0
443+
}
444+
424445
// fdReaddir is the WASI function named functionFdReaddir which reads directory
425446
// entries from a directory.
426447
//

wasi_snapshot_preview1/fs_test.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -615,6 +615,91 @@ func Test_fdRead_Errors(t *testing.T) {
615615
}
616616
}
617617

618+
func Test_fdRead_shouldContinueRead(t *testing.T) {
619+
tests := []struct {
620+
name string
621+
n, l uint32
622+
err error
623+
expectedOk bool
624+
expectedErrno Errno
625+
}{
626+
{
627+
name: "break when nothing to read",
628+
n: 0,
629+
l: 0,
630+
},
631+
{
632+
name: "break when nothing read",
633+
n: 0,
634+
l: 4,
635+
},
636+
{
637+
name: "break on partial read",
638+
n: 3,
639+
l: 4,
640+
},
641+
{
642+
name: "continue on full read",
643+
n: 4,
644+
l: 4,
645+
expectedOk: true,
646+
},
647+
{
648+
name: "break on EOF on nothing to read",
649+
err: io.EOF,
650+
},
651+
{
652+
name: "break on EOF on nothing read",
653+
l: 4,
654+
err: io.EOF,
655+
},
656+
{
657+
name: "break on EOF on partial read",
658+
n: 3,
659+
l: 4,
660+
err: io.EOF,
661+
},
662+
{
663+
name: "break on EOF on full read",
664+
n: 4,
665+
l: 4,
666+
err: io.EOF,
667+
},
668+
{
669+
name: "return ErrnoIo on error on nothing to read",
670+
err: io.ErrClosedPipe,
671+
expectedErrno: ErrnoIo,
672+
},
673+
{
674+
name: "return ErrnoIo on error on nothing read",
675+
l: 4,
676+
err: io.ErrClosedPipe,
677+
expectedErrno: ErrnoIo,
678+
},
679+
{ // Special case, allows processing data before err
680+
name: "break on error on partial read",
681+
n: 3,
682+
l: 4,
683+
err: io.ErrClosedPipe,
684+
},
685+
{ // Special case, allows processing data before err
686+
name: "break on error on full read",
687+
n: 4,
688+
l: 4,
689+
err: io.ErrClosedPipe,
690+
},
691+
}
692+
for _, tt := range tests {
693+
tc := tt
694+
695+
t.Run(tc.name, func(t *testing.T) {
696+
ok, errno := fdRead_shouldContinueRead(tc.n, tc.l, tc.err)
697+
require.Equal(t, tc.expectedOk, ok)
698+
require.Equal(t, tc.expectedErrno, errno)
699+
})
700+
}
701+
}
702+
618703
// Test_fdReaddir only tests it is stubbed for GrainLang per #271
619704
func Test_fdReaddir(t *testing.T) {
620705
log := requireErrnoNosys(t, functionFdReaddir, 0, 0, 0, 0, 0)

0 commit comments

Comments
 (0)