Skip to content

Commit e96cb18

Browse files
authored
Java TASTy: use new threadsafe writer implementation (#19690)
Also fix bug where Jar entries for -Yjava-tasty-output have backslash on Windows. Copies implementation from `compiler/src/dotty/tools/backend/jvm/ClassfileWriters.scala`, but this time I don't close the jar archive except from within Pickler (when its more explicit that we wont write any longer to the early output jar), I also no longer perform substitution of `.` by `/` in Pickler, instead leave it to TastyWriter to decide how to process the classname. fixes #19681
2 parents cb94abe + 5eb0845 commit e96cb18

File tree

3 files changed

+274
-58
lines changed

3 files changed

+274
-58
lines changed

Diff for: compiler/src/dotty/tools/dotc/transform/Pickler.scala

+20-8
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import tasty.*
99
import config.Printers.{noPrinter, pickling}
1010
import config.Feature
1111
import java.io.PrintStream
12-
import io.ClassfileWriterOps
12+
import io.FileWriters.TastyWriter
1313
import StdNames.{str, nme}
1414
import Periods.*
1515
import Phases.*
@@ -19,8 +19,9 @@ import reporting.{ThrowingReporter, Profile, Message}
1919
import collection.mutable
2020
import util.concurrent.{Executor, Future}
2121
import compiletime.uninitialized
22-
import dotty.tools.io.JarArchive
22+
import dotty.tools.io.{JarArchive, AbstractFile}
2323
import dotty.tools.dotc.printing.OutlinePrinter
24+
import scala.annotation.constructorOnly
2425

2526
object Pickler {
2627
val name: String = "pickler"
@@ -32,8 +33,17 @@ object Pickler {
3233
*/
3334
inline val ParallelPickling = true
3435

35-
class EarlyFileWriter(writer: ClassfileWriterOps):
36-
export writer.{writeTasty, close}
36+
class EarlyFileWriter private (writer: TastyWriter, origin: AbstractFile):
37+
def this(dest: AbstractFile)(using @constructorOnly ctx: Context) = this(TastyWriter(dest), dest)
38+
39+
export writer.writeTasty
40+
41+
def close(): Unit =
42+
writer.close()
43+
origin match {
44+
case jar: JarArchive => jar.close() // also close the file system
45+
case _ =>
46+
}
3747
}
3848

3949
/** This phase pickles trees */
@@ -184,7 +194,7 @@ class Pickler extends Phase {
184194
override def runOn(units: List[CompilationUnit])(using Context): List[CompilationUnit] = {
185195
val sigWriter: Option[Pickler.EarlyFileWriter] = ctx.settings.YjavaTastyOutput.value match
186196
case jar: JarArchive if jar.exists =>
187-
Some(Pickler.EarlyFileWriter(ClassfileWriterOps(jar)))
197+
Some(Pickler.EarlyFileWriter(jar))
188198
case _ =>
189199
None
190200
val units0 =
@@ -225,9 +235,11 @@ class Pickler extends Phase {
225235
(cls, pickled) <- unit.pickled
226236
if cls.isDefinedInCurrentRun
227237
do
228-
val binaryName = cls.binaryClassName.replace('.', java.io.File.separatorChar).nn
229-
val binaryClassName = if (cls.is(Module)) binaryName.stripSuffix(str.MODULE_SUFFIX).nn else binaryName
230-
writer.writeTasty(binaryClassName, pickled())
238+
val binaryClassName = cls.binaryClassName
239+
val internalName =
240+
if (cls.is(Module)) binaryClassName.stripSuffix(str.MODULE_SUFFIX).nn
241+
else binaryClassName
242+
val _ = writer.writeTasty(internalName, pickled())
231243
count += 1
232244
finally
233245
writer.close()

Diff for: compiler/src/dotty/tools/io/ClassfileWriterOps.scala

-50
This file was deleted.

Diff for: compiler/src/dotty/tools/io/FileWriters.scala

+254
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
package dotty.tools.io
2+
3+
import dotty.tools.dotc.core.Contexts.*
4+
import dotty.tools.dotc.core.Decorators.em
5+
import dotty.tools.dotc.report
6+
import dotty.tools.io.AbstractFile
7+
import dotty.tools.io.JarArchive
8+
import dotty.tools.io.PlainFile
9+
10+
import java.io.BufferedOutputStream
11+
import java.io.DataOutputStream
12+
import java.io.FileOutputStream
13+
import java.io.IOException
14+
import java.nio.ByteBuffer
15+
import java.nio.channels.ClosedByInterruptException
16+
import java.nio.channels.FileChannel
17+
import java.nio.file.FileAlreadyExistsException
18+
import java.nio.file.Files
19+
import java.nio.file.Path
20+
import java.nio.file.StandardOpenOption
21+
import java.nio.file.attribute.FileAttribute
22+
import java.util
23+
import java.util.concurrent.ConcurrentHashMap
24+
import java.util.zip.CRC32
25+
import java.util.zip.Deflater
26+
import java.util.zip.ZipEntry
27+
import java.util.zip.ZipOutputStream
28+
import scala.language.unsafeNulls
29+
30+
/** Copied from `dotty.tools.backend.jvm.ClassfileWriters` but no `PostProcessorFrontendAccess` needed */
31+
object FileWriters {
32+
type InternalName = String
33+
type NullableFile = AbstractFile | Null
34+
35+
/**
36+
* The interface to writing classfiles. GeneratedClassHandler calls these methods to generate the
37+
* directory and files that are created, and eventually calls `close` when the writing is complete.
38+
*
39+
* The companion object is responsible for constructing a appropriate and optimal implementation for
40+
* the supplied settings.
41+
*
42+
* Operations are threadsafe.
43+
*/
44+
sealed trait TastyWriter {
45+
/**
46+
* Write a `.tasty` file.
47+
*
48+
* @param name the internal name of the class, e.g. "scala.Option"
49+
*/
50+
def writeTasty(name: InternalName, bytes: Array[Byte])(using Context): NullableFile
51+
52+
/**
53+
* Close the writer. Behavior is undefined after a call to `close`.
54+
*/
55+
def close(): Unit
56+
57+
protected def classToRelativePath(className: InternalName): String =
58+
className.replace('.', '/').nn + ".tasty"
59+
}
60+
61+
object TastyWriter {
62+
63+
def apply(output: AbstractFile)(using Context): TastyWriter = {
64+
65+
// In Scala 2 depenening on cardinality of distinct output dirs MultiClassWriter could have been used
66+
// In Dotty we always use single output directory
67+
val basicTastyWriter = new SingleTastyWriter(
68+
FileWriter(output, None)
69+
)
70+
71+
basicTastyWriter
72+
}
73+
74+
private final class SingleTastyWriter(underlying: FileWriter) extends TastyWriter {
75+
76+
override def writeTasty(className: InternalName, bytes: Array[Byte])(using Context): NullableFile = {
77+
underlying.writeFile(classToRelativePath(className), bytes)
78+
}
79+
80+
override def close(): Unit = underlying.close()
81+
}
82+
83+
}
84+
85+
sealed trait FileWriter {
86+
def writeFile(relativePath: String, bytes: Array[Byte])(using Context): NullableFile
87+
def close(): Unit
88+
}
89+
90+
object FileWriter {
91+
def apply(file: AbstractFile, jarManifestMainClass: Option[String])(using Context): FileWriter =
92+
if (file.isInstanceOf[JarArchive]) {
93+
val jarCompressionLevel = ctx.settings.YjarCompressionLevel.value
94+
// Writing to non-empty JAR might be an undefined behaviour, e.g. in case if other files where
95+
// created using `AbstractFile.bufferedOutputStream`instead of JarWritter
96+
val jarFile = file.underlyingSource.getOrElse{
97+
throw new IllegalStateException("No underlying source for jar")
98+
}
99+
assert(file.isEmpty, s"Unsafe writing to non-empty JAR: $jarFile")
100+
new JarEntryWriter(jarFile, jarManifestMainClass, jarCompressionLevel)
101+
}
102+
else if (file.isVirtual) new VirtualFileWriter(file)
103+
else if (file.isDirectory) new DirEntryWriter(file.file.toPath.nn)
104+
else throw new IllegalStateException(s"don't know how to handle an output of $file [${file.getClass}]")
105+
}
106+
107+
private final class JarEntryWriter(file: AbstractFile, mainClass: Option[String], compressionLevel: Int) extends FileWriter {
108+
//keep these imports local - avoid confusion with scala naming
109+
import java.util.jar.Attributes.Name.{MANIFEST_VERSION, MAIN_CLASS}
110+
import java.util.jar.{JarOutputStream, Manifest}
111+
112+
val storeOnly = compressionLevel == Deflater.NO_COMPRESSION
113+
114+
val jarWriter: JarOutputStream = {
115+
import scala.util.Properties.*
116+
val manifest = new Manifest
117+
val attrs = manifest.getMainAttributes.nn
118+
attrs.put(MANIFEST_VERSION, "1.0")
119+
attrs.put(ScalaCompilerVersion, versionNumberString)
120+
mainClass.foreach(c => attrs.put(MAIN_CLASS, c))
121+
122+
val jar = new JarOutputStream(new BufferedOutputStream(new FileOutputStream(file.file), 64000), manifest)
123+
jar.setLevel(compressionLevel)
124+
if (storeOnly) jar.setMethod(ZipOutputStream.STORED)
125+
jar
126+
}
127+
128+
lazy val crc = new CRC32
129+
130+
override def writeFile(relativePath: String, bytes: Array[Byte])(using Context): NullableFile = this.synchronized {
131+
val entry = new ZipEntry(relativePath)
132+
if (storeOnly) {
133+
// When using compression method `STORED`, the ZIP spec requires the CRC and compressed/
134+
// uncompressed sizes to be written before the data. The JarOutputStream could compute the
135+
// values while writing the data, but not patch them into the stream after the fact. So we
136+
// need to pre-compute them here. The compressed size is taken from size.
137+
// https://stackoverflow.com/questions/1206970/how-to-create-uncompressed-zip-archive-in-java/5868403
138+
// With compression method `DEFLATED` JarOutputStream computes and sets the values.
139+
entry.setSize(bytes.length)
140+
crc.reset()
141+
crc.update(bytes)
142+
entry.setCrc(crc.getValue)
143+
}
144+
jarWriter.putNextEntry(entry)
145+
try jarWriter.write(bytes, 0, bytes.length)
146+
finally jarWriter.flush()
147+
null
148+
}
149+
150+
override def close(): Unit = this.synchronized(jarWriter.close())
151+
}
152+
153+
private final class DirEntryWriter(base: Path) extends FileWriter {
154+
val builtPaths = new ConcurrentHashMap[Path, java.lang.Boolean]()
155+
val noAttributes = Array.empty[FileAttribute[?]]
156+
private val isWindows = scala.util.Properties.isWin
157+
158+
private def checkName(component: Path)(using Context): Unit = if (isWindows) {
159+
val specials = raw"(?i)CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9]".r
160+
val name = component.toString
161+
def warnSpecial(): Unit = report.warning(em"path component is special Windows device: ${name}")
162+
specials.findPrefixOf(name).foreach(prefix => if (prefix.length == name.length || name(prefix.length) == '.') warnSpecial())
163+
}
164+
165+
def ensureDirForPath(baseDir: Path, filePath: Path)(using Context): Unit = {
166+
import java.lang.Boolean.TRUE
167+
val parent = filePath.getParent
168+
if (!builtPaths.containsKey(parent)) {
169+
parent.iterator.forEachRemaining(checkName)
170+
try Files.createDirectories(parent, noAttributes*)
171+
catch {
172+
case e: FileAlreadyExistsException =>
173+
// `createDirectories` reports this exception if `parent` is an existing symlink to a directory
174+
// but that's fine for us (and common enough, `scalac -d /tmp` on mac targets symlink).
175+
if (!Files.isDirectory(parent))
176+
throw new FileConflictException(s"Can't create directory $parent; there is an existing (non-directory) file in its path", e)
177+
}
178+
builtPaths.put(baseDir, TRUE)
179+
var current = parent
180+
while ((current ne null) && (null ne builtPaths.put(current, TRUE))) {
181+
current = current.getParent
182+
}
183+
}
184+
checkName(filePath.getFileName())
185+
}
186+
187+
// the common case is that we are are creating a new file, and on MS Windows the create and truncate is expensive
188+
// because there is not an options in the windows API that corresponds to this so the truncate is applied as a separate call
189+
// even if the file is new.
190+
// as this is rare, its best to always try to create a new file, and it that fails, then open with truncate if that fails
191+
192+
private val fastOpenOptions = util.EnumSet.of(StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)
193+
private val fallbackOpenOptions = util.EnumSet.of(StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)
194+
195+
override def writeFile(relativePath: String, bytes: Array[Byte])(using Context): NullableFile = {
196+
val path = base.resolve(relativePath)
197+
try {
198+
ensureDirForPath(base, path)
199+
val os = if (isWindows) {
200+
try FileChannel.open(path, fastOpenOptions)
201+
catch {
202+
case _: FileAlreadyExistsException => FileChannel.open(path, fallbackOpenOptions)
203+
}
204+
} else FileChannel.open(path, fallbackOpenOptions)
205+
206+
try os.write(ByteBuffer.wrap(bytes), 0L)
207+
catch {
208+
case ex: ClosedByInterruptException =>
209+
try Files.deleteIfExists(path) // don't leave a empty of half-written classfile around after an interrupt
210+
catch { case _: Throwable => () }
211+
throw ex
212+
}
213+
os.close()
214+
} catch {
215+
case e: FileConflictException =>
216+
report.error(em"error writing ${path.toString}: ${e.getMessage}")
217+
case e: java.nio.file.FileSystemException =>
218+
if (ctx.settings.Ydebug.value) e.printStackTrace()
219+
report.error(em"error writing ${path.toString}: ${e.getClass.getName} ${e.getMessage}")
220+
}
221+
AbstractFile.getFile(path)
222+
}
223+
224+
override def close(): Unit = ()
225+
}
226+
227+
private final class VirtualFileWriter(base: AbstractFile) extends FileWriter {
228+
private def getFile(base: AbstractFile, path: String): AbstractFile = {
229+
def ensureDirectory(dir: AbstractFile): AbstractFile =
230+
if (dir.isDirectory) dir
231+
else throw new FileConflictException(s"${base.path}/${path}: ${dir.path} is not a directory")
232+
val components = path.split('/')
233+
var dir = base
234+
for (i <- 0 until components.length - 1) dir = ensureDirectory(dir) subdirectoryNamed components(i).toString
235+
ensureDirectory(dir) fileNamed components.last.toString
236+
}
237+
238+
private def writeBytes(outFile: AbstractFile, bytes: Array[Byte]): Unit = {
239+
val out = new DataOutputStream(outFile.bufferedOutput)
240+
try out.write(bytes, 0, bytes.length)
241+
finally out.close()
242+
}
243+
244+
override def writeFile(relativePath: String, bytes: Array[Byte])(using Context):NullableFile = {
245+
val outFile = getFile(base, relativePath)
246+
writeBytes(outFile, bytes)
247+
outFile
248+
}
249+
override def close(): Unit = ()
250+
}
251+
252+
/** Can't output a file due to the state of the file system. */
253+
class FileConflictException(msg: String, cause: Throwable = null) extends IOException(msg, cause)
254+
}

0 commit comments

Comments
 (0)