Skip to content
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

[fs] Add file.read and file.seek methods to the fs module (2/3) #3309

Merged
merged 12 commits into from
Nov 6, 2023
89 changes: 44 additions & 45 deletions js/modules/k6/experimental/fs/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -198,37 +198,32 @@ func (f *File) Read(into goja.Value) *goja.Promise {
promise, resolveFunc, rejectFunc := f.vu.Runtime().NewPromise()
callback := f.vu.RegisterCallback()

resolve := func(result any) {
callback(func() error {
resolveFunc(result)
return nil
})
}

reject := func(reason any) {
if common.IsNullish(into) {
callback(func() error {
rejectFunc(reason)
rejectFunc(newFsError(TypeError, "read() failed; reason: into cannot be null or undefined"))
return nil
})
}

if common.IsNullish(into) {
reject(newFsError(TypeError, "read() failed; reason: into cannot be null or undefined"))
return promise
}

// We expect the into argument to be a `Uint8Array` instance
intoObj := into.ToObject(f.vu.Runtime())
uint8ArrayConstructor := f.vu.Runtime().Get("Uint8Array")
if isUint8Array := intoObj.Get("constructor").SameAs(uint8ArrayConstructor); !isUint8Array {
reject(newFsError(TypeError, "read() failed; reason: into argument must be a Uint8Array"))
callback(func() error {
rejectFunc(newFsError(TypeError, "read() failed; reason: into argument must be a Uint8Array"))
return nil
})
oleiade marked this conversation as resolved.
Show resolved Hide resolved
return promise
}

// Obtain the underlying ArrayBuffer from the Uint8Array
ab, ok := intoObj.Get("buffer").Export().(goja.ArrayBuffer)
if !ok {
reject(newFsError(TypeError, "read() failed; reason: into argument cannot be interpreted as ArrayBuffer"))
callback(func() error {
rejectFunc(newFsError(TypeError, "read() failed; reason: into argument must be a Uint8Array"))
return nil
})
return promise
}

Expand All @@ -240,43 +235,47 @@ func (f *File) Read(into goja.Value) *goja.Promise {

go func() {
n, err := f.file.Read(buffer)
if err == nil {
// Although the read operation happens as part of the goroutine, we
// still need to make sure that:
// 1. Any side effects, like modifying the `buffer`, are deferred and
// executed on the main thread via the registered callback.
// 2. This approach ensures that while the file read operation can proceed
// asynchronously, any side effects that might interfere with the JS runtime
// are executed in a controlled and sequential manner on the main thread.
callback(func() error {
callback(func() error {
if err == nil {
// Although the read operation happens as part of the goroutine, we
// still need to make sure that:
// 1. Any side effects, like modifying the `buffer`, are deferred and
// executed on the main thread via the registered callback.
// 2. This approach ensures that while the file read operation can proceed
// asynchronously, any side effects that might interfere with the JS runtime
// are executed in a controlled and sequential manner on the main thread.
_ = copy(intoBytes, buffer)
resolveFunc(n)
return nil
})
return
}
}

// The [file.Read] method will return an EOFError as soon as it reached
// the end of the file.
//
// However, following deno's behavior, we express
// EOF to users by returning null, when and only when there aren't any
// more bytes to read.
//
// Thus, although the [file.Read] method will return an EOFError, and
// an n > 0, we make sure to take the EOFError returned into consideration
// only when n == 0.
var fsErr *fsError
isFsErr := errors.As(err, &fsErr)

if !isFsErr {
rejectFunc(err)
return nil
}

// The [file.Read] method will return an EOFError as soon as it reached
// the end of the file.
//
// However, following deno's behavior, we express
// EOF to users by returning null, when and only when there aren't any
// more bytes to read.
//
// Thus, although the [file.Read] method will return an EOFError, and
// an n > 0, we make sure to take the EOFError returned into consideration
// only when n == 0.
var fsErr *fsError
isFsErr := errors.As(err, &fsErr)
if isFsErr {
_ = copy(intoBytes, buffer)
if fsErr.kind == EOFError && n == 0 {
resolve(nil)
resolveFunc(nil)
} else {
resolve(n)
resolveFunc(n)
}
} else {
reject(err)
}

return nil
})
}()

return promise
Expand Down
38 changes: 38 additions & 0 deletions js/modules/k6/experimental/fs/module_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,44 @@ func TestFile(t *testing.T) {
assert.NoError(t, err)
})

// Regression test for [#3309]
//
// [#3309]: https://github.com/grafana/k6/pull/3309#discussion_r1378528010
t.Run("read with a buffer of the size of the file + 1 should succeed ", func(t *testing.T) {
t.Parallel()

runtime, err := newConfiguredRuntime(t)
require.NoError(t, err)

testFilePath := fsext.FilePathSeparator + testFileName
fs := newTestFs(t, func(fs afero.Fs) error {
return afero.WriteFile(fs, testFilePath, []byte("012"), 0o644)
})
runtime.VU.InitEnvField.FileSystems["file"] = fs

_, err = runtime.RunOnEventLoop(wrapInAsyncLambda(fmt.Sprintf(`
// file size is 3
const file = await fs.open(%q);

// Create a buffer of size fileSize + 1
let buffer = new Uint8Array(4);
let n = await file.read(buffer)
if (n !== 3) {
throw 'expected read to return 10, got ' + n + ' instead';
}

if (buffer[0] !== 48 || buffer[1] !== 49 || buffer[2] !== 50) {
throw 'expected buffer to be [48, 49, 50], got ' + buffer + ' instead';
}

if (buffer[3] !== 0) {
throw 'expected buffer to be [48, 49, 50, 0], got ' + buffer + ' instead';
}
`, testFilePath)))

assert.NoError(t, err)
})

t.Run("read called concurrently and later resolved should safely modify the buffer read into", func(t *testing.T) {
t.Parallel()

Expand Down