8000 Fix #5014: Implement java.io.BufferedWriter by ThijsBroersen · Pull Request #5015 · scala-js/scala-js · GitHub
[go: up one dir, main page]

Skip to content

Fix #5014: Implement java.io.BufferedWriter #5015

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
wants to merge 4 commits into
base: main
Choose a base branch
from

Conversation

ThijsBroersen
Copy link
@ThijsBroersen ThijsBroersen commented Aug 16, 2024

Implementation copied from Scala-native

I excluded any copyright headers Scala-js uses in all files because this code comes from Scala-native

Implementation copied from Scala-native
@ThijsBroersen
Copy link
Author
ThijsBroersen commented Aug 16, 2024

Completed the CLA process

@ekrich
Copy link
Contributor
ekrich commented Aug 16, 2024

When we port code from Scala.js to Scala Native, we typically put the following to give some context and a starting point. Not sure if Scala.js wants to use this convention or not. Note: we don't have a special header but this is verbatim from java.util.AbstractMap.

// Ported from Scala.js commit: 2253950 dated: 2022-10-02
// Note: this file has differences noted below

/*
 * Scala.js (https://www.scala-js.org/)
 *
 * Copyright EPFL.
 *
 * Licensed under Apache License 2.0
 * (https://www.apache.org/licenses/LICENSE-2.0).
 *
 * See the NOTICE file distributed with this work for
 * additional information regarding copyright ownership.
 */

Copy link
Contributor
@gzm0 gzm0 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TY for starting this!

I've done a first pass. As usual, even these relatively simple APIs turn out to be quite subtle :-/

def flush(): Unit = {
ensureOpen()
out.write(buffer, 0, pos)
out.flush()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I cannot infer this behavior from the JDK documentation. Maybe we should add an explicit test to ensure the behavior is aligned?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I read

Thus one flush() invocation will flush all the buffers in a chain of Writers and OutputStreams

override def write(s: String, off: Int, len: Int): Unit =
write(s.toCharArray, off, len)

def write(cbuf: Array[Char], off: Int, len: Int): Unit = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From the JDK doc:

If the requested length is at least as large as the buffer, however, then this method will flush the buffer and write the characters directly to the underlying stream.

IIUC this behavior isn't implemented.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reimplemented the method

if (available >= len) {
System.arraycopy(cbuf, off, buffer, pos, len)
pos += len
if (pos == sz) flush()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will also flush the underlying stream. Are you sure this is the right behavior?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also (IIRC) Scala.js style guide dictates that side-effecting calls are on their own line.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the flush action is correctly flushing the whole chain then flushing on write when write directly to the chain writer makes sense. Note: the new code is a bit different but still flushes the chained writer when writing directly to it.

pos += len
if (pos == sz) flush()
} else {
write(cbuf, off, available)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of calling the method recursively, have you considered a helper:

val available = sz - pos
if (available >= len) {
  writeToBuf(cbuf, off, buffer, len)
} else {
  writeToBuf(cbuf, off, buffer, available)
  writeToBuf(cbuf, off + available, len - available)
}

(probably necessary anyways to build the "flush the buffer and write directly" behavior.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it needs recursion. Reimplemented it to where it write to the buffer or it flushes the buffer and writes the all to the chained writer (as this is the behaviour I interpret from the JDK spec).

Ordinarily this method stores characters from the given array into this stream's buffer, flushing the buffer to the underlying stream as needed. If the requested length is at least as large as the buffer, however, then this method will flush the buffer and write the characters directly to the underlying stream. Thus redundant BufferedWriters will not copy data unnecessarily.

write(System.lineSeparator(), 0, System.lineSeparator().length)

override def write(c: Int): Unit =
write(Array(c.toChar), 0, 1)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIUC there is always at least one spot free in the buffer (otherwise it would be flushed).

So couldn't this be:

buffer(pos) = c.toChar
pos += 1
if (pos == len)
  flush()


import org.scalajs.testsuite.utils.AssertThrows.assertThrows

class BufferedWriterTest {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'll probably have to add more tests, notably (probably not a complete list, sorry):

  • operations on closed writers throw
  • index out of bounds use cases
  • corner cases pointed out in the implementation review (notably flushing behavior)
  • writing to a buffered writer multiple times (with different buffer boundary conditions).

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have added more test cases.

@ThijsBroersen
Copy link
Author
ThijsBroersen commented Aug 17, 2024

@gzm0 thanks for your extensive review of this code. As I mentioned the code is copied from Scala-native, it is 1:1 the same. I will go through your comments and see if the code can be improved. But I have some concern that if we make changes, the behaviour could be different than that of Scala-native. It would be nice if the can both benefit from the same implementation. Would it be possible Scala-js and Scala-native somehow share sources for these things?

@sjrd
Copy link
Member
sjrd commented Aug 17, 2024

But I have some concern that if we make changes, the behaviour could be different than that of Scala-native.

That doesn't matter. The reference is the JVM implementation. Both Scala.js and Scala Native must follow the specced behavior of the JDK. Scala.js, being more mature, is more demanding in terms of adhering to it.

It would be nice if the can both benefit from the same implementation. Would it be possible Scala-js and Scala-native somehow share sources for these things?

No, that's not possible nor desirable. Copy-pasting is the way to go. Usually copying from JS to Native will be fine. Copying in the other direction tends to meet the higher quality standards of Scala.js, as you are experiencing now.

@ThijsBroersen ThijsBroersen marked this pull request as draft August 18, 2024 00:20
@ThijsBroersen ThijsBroersen marked this pull request as ready for review August 18, 2024 09:14
@ThijsBroersen
Copy link
Author
ThijsBroersen commented Aug 18, 2024

@gzm0 I have done another iteration, ready to review again.

@ThijsBroersen
Copy link
Author

did sign the CLA (again)

@ThijsBroersen
Copy link
Author

The AppVeyer CI seems be pass but if I check the logs than I see many 'failed'....
The Jenkins CI shows failed tests but all tests pass 'on-my-machine'

@sjrd
Copy link
Member
sjrd commented Aug 20, 2024

The Jenkins CI jobs show the command that failed at the end of the log. In this case it seems the JVM test suite is failing:

Command was: sbt ++2.12.19 testSuiteJVM2_12/test testSuiteExJVM2_12/test

You should be able to replicate locally with testSuiteJVM2_12/test.

@ThijsBroersen
Copy link
Author
ThijsBroersen commented Aug 20, 2024

Interesting, so the JDK spec says write nothing where the JVM implementation throws an StringIndexOutOfBoundsException exception.
Should we adjust the test to expect a StringIndexOutOfBoundsException exception or wrote nothing? Or ignore the JDK spec and just throw the exception in JS?

@sjrd
Copy link
Member
sjrd commented Aug 20, 2024

If the JVM implementation is in direct contradiction with its (latest) spec, it might be a bug on the JVM. Consider searching the JDK bug database. However, recall that you are not allowed to even look at the JDK implementation nor their tests.. If it's a known issue on the JVM we can exclude the test on the JVM with a comment to the JDK bug.

@ThijsBroersen
Copy link
Author

Looks like it the was was reported before and 'fixed', so JVM should not throw for negative values but it still does 🤔
https://bugs.openjdk.org/browse/JDK-8029804?jql=text%20~%20%22BufferedWriter%22

@ThijsBroersen
Copy link
Author

But if we suspect a bug in the JVM code we can also not look at the source code and try to identify/fix it? Sounds silly...

@ThijsBroersen
Copy link
Author
ThijsBroersen commented Aug 20, 2024

The JVM test also deviates in writing contents largen than the (available) buffer partially to the underlying stream. So it unnecessary splits/copies data. The is not what I interpret from the docs.
e.g. if a test tries to write "Hello" to a BufferedWriter with size 3 on the JVM it writes "Hel" to the underlying stream and keeps "lo" in the buffer...
I would expect it to do one write of all characters directly to the underlying stream

@ThijsBroersen
Copy link
Author

I get the feeling the JDK is quite open to interpretation (or implemented wrong or not updated often) 🤣 . This also does make me a little sad 😢

@sjrd
Copy link
Member
sjrd commented Aug 20, 2024

I get the feeling the JDK is quite open to interpretation (or implemented wrong or not updated often) 🤣 . This also does make me a little sad 😢

This is actually quite rare. The JDK is remarkably well specified overall. If the latest JVM implementation still contradicts the spec, it might be worth opening a new issue, if only so that we can reference it in our tests to justify why we disagree with the JVM.

But if we suspect a bug in the JVM code we can also not look at the source code and try to identify/fix it? Sounds silly...

Such is our life. You consider it silly; we see potential lawsuits if we don't play by the rules. We can report bugs, but not fix them nor investigate from the source. Essentially we have to treat the JDK as (free-as-in-beer) proprietary software.

@ThijsBroersen
Copy link
Author

Silly from their perspective I mean, as in it discourages people from contributing. I understand the position of Scala-js.

@ThijsBroersen
Copy link
Author
ThijsBroersen commented Aug 20, 2024

But for this PR.. will these differences be a blocker for now? Ignore those tests? Or should we replicate the JVM behaviour?

@sjrd
Copy link
Member
sjrd commented Aug 21, 2024

It's not a blocker, but it should be documented as a JDK bug. See for example

@Test def indexNotInRangeThrows(): Unit = {
assumeFalse("https://bugs.openjdk.java.net/browse/JDK-8253875",
executingInJVMOnLowerThanJDK16)
/* The public JavaDoc says that these situations throw an
* IllegalFormatException, without being more specific. However, the bug
* report specifically chooses a design that throws a package-private
* java.util.IllegalFormatArgumentIndexException, with the explicit intent
* of making it public in the future if the need arises. Therefore, we also
* throw such an exception, and check that its class name is as expected.
*/
def expectIllegalFormatArgumentIndexException(format: String, args: Any*): Unit = {
val e = expectFormatterThrows(classOf[IllegalFormatException], format, args: _*)
assertEquals("java.util.IllegalFormatArgumentIndexException", e.getClass().getName())
}
expectIllegalFormatArgumentIndexException("%9876543210$d", 56, 78)
expectIllegalFormatArgumentIndexException("%d %9876543210$d", 56, 78)
expectIllegalFormatArgumentIndexException("%d %0$d", 56, 78)
}

and
if (!executingInJVM) {
/* https://bugs.java.com/bugdatabase/view_bug.do?bug_id=JDK-8260221
* OpenJDK never throws FormatFlagsConversionMismatchException, although
* it should. We chose to put it last in the precedence chain, although
* we don't know where OpenJDK would put it, should they eventually fix
* the bug.
*/
// 4-5 MissingFormatWidthException > FormatFlagsConversionMismatchException
expectFormatterThrows(classOf[MissingFormatWidthException], "%#-%")
expectFormatterThrows(classOf[FormatFlagsConversionMismatchException], "%#%")
}

for examples.

@ThijsBroersen
Copy link
Author

I think I will not continue this PR. I think it is better to skip to jdk classes and build pure Scala alternatives. It is also not satisfying that whatever was going to be implemented here would always be wrong because the spec is ambiguous or by not matching other openjdk implementations behaviour.

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.

4 participants
0