Skip to content

Wasm/WASI target implementation #366

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

Merged
merged 6 commits into from
Sep 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,24 @@ external object JsJodaTimeZoneModule
private val jsJodaTz = JsJodaTimeZoneModule
```

#### Note about time zones in Wasm/WASI

By default, there's only one time zone available in Kotlin/Wasm WASI: the `UTC` time zone with a fixed offset.

If you want to use all time zones in Kotlin/Wasm WASI platform, you need to add the following dependency:

```kotlin
kotlin {
sourceSets {
val wasmWasiMain by getting {
dependencies {
implementation("kotlinx-datetime-zoneinfo", "2024a-spi.0.6.0-RC.2")
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
implementation("kotlinx-datetime-zoneinfo", "2024a-spi.0.6.0-RC.2")
implementation("kotlinx-datetime-zoneinfo", "2024a-spi.0.6.0")

}
}
}
}
```

### Maven

Add a dependency to the `<dependencies>` element. Note that you need to use the platform-specific `-jvm` artifact in Maven.
Expand Down
101 changes: 101 additions & 0 deletions buildSrc/src/main/kotlin/zoneInfosResourcesGenerator.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import java.io.File

/*
* Copyright 2019-2024 JetBrains s.r.o. and contributors.
* Use of this source code is governed by the Apache 2.0 License that can be found in the LICENSE.txt file.
*/

private val pkg = "package kotlinx.datetime.timezones.tzData"

private fun generateByteArrayProperty(tzData: TzData, header: String, propertyName: String): String = buildString {
append(header)
appendLine()
appendLine()
appendLine("/* ${tzData.fullTzNames.joinToString(", ")} */")
append("internal val $propertyName get() = byteArrayOf(")
for (chunk in tzData.data.toList().chunked(16)) {
appendLine()
append(" ")
val chunkText = chunk.joinToString {
it.toString().padStart(4, ' ')
} + ","
append(chunkText)
}
appendLine()
append(")")
}

private class TzData(val data: ByteArray, val fullTzNames: MutableList<String>)
private fun loadTzBinaries(
zoneInfo: File,
currentName: String,
result: MutableList<TzData>
) {
val zoneName = if (currentName.isEmpty()) zoneInfo.name else "$currentName/${zoneInfo.name}"
if (zoneInfo.isDirectory) {
zoneInfo.listFiles()?.forEach {
loadTzBinaries(it, zoneName, result)
}
} else {
val bytes = zoneInfo.readBytes()
val foundTzData = result.firstOrNull { it.data.contentEquals(bytes) }
val tzData: TzData
if (foundTzData != null) {
tzData = foundTzData
} else {
tzData = TzData(bytes, mutableListOf())
result.add(tzData)
}

tzData.fullTzNames.add(zoneName)
}
}

fun generateZoneInfosResources(zoneInfoDir: File, outputDir: File, version: String) {
val header = buildString {
appendLine()
append("/* AUTOGENERATED FROM ZONE INFO DATABASE v.$version */")
appendLine()
appendLine()
append(pkg)
}

val loadedZones = mutableListOf<TzData>()
zoneInfoDir.listFiles()?.forEach { file ->
loadTzBinaries(file, "", loadedZones)
}

val zoneDataByNameBody = StringBuilder()
val getTimeZonesBody = StringBuilder()
loadedZones.forEachIndexed { id, tzData ->
val tzDataName = "tzData$id"
val data = generateByteArrayProperty(tzData, header, tzDataName)
File(outputDir, "$tzDataName.kt").writeText(data)
tzData.fullTzNames.forEach { name ->
zoneDataByNameBody.appendLine(" \"$name\" -> $tzDataName")
getTimeZonesBody.appendLine(" \"$name\",")
}
}

val content = buildString {
append(header)
appendLine()
appendLine()
appendLine("internal fun zoneDataByName(name: String): ByteArray = when(name) {")
append(zoneDataByNameBody)
appendLine()
append(" else -> throw kotlinx.datetime.IllegalTimeZoneException(\"Invalid timezone name\")")
appendLine()
append("}")
appendLine()
appendLine()
append("internal val timeZones: Set<String> by lazy { setOf(")
appendLine()
append(getTimeZonesBody)
appendLine()
append(")")
append("}")
}

File(outputDir, "tzData.kt").writeText(content)
}
26 changes: 25 additions & 1 deletion core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,10 @@ kotlin {
}
}

wasmWasi {
nodejs()
}

@OptIn(ExperimentalKotlinGradlePluginApi::class)
compilerOptions {
freeCompilerArgs.add("-Xexpect-actual-classes")
Expand Down Expand Up @@ -207,14 +211,34 @@ kotlin {
dependsOn(commonJsTest)
}

val nativeMain by getting {
val commonKotlinMain by creating {
dependsOn(commonMain.get())
dependencies {
api("org.jetbrains.kotlinx:kotlinx-serialization-core:$serializationVersion")
}
}

val commonKotlinTest by creating {
dependsOn(commonTest.get())
}

val nativeMain by getting {
dependsOn(commonKotlinMain)
}

val nativeTest by getting {
dependsOn(commonKotlinTest)
}

val wasmWasiMain by getting {
dependsOn(commonKotlinMain)
}

val wasmWasiTest by getting {
dependsOn(commonKotlinTest)
dependencies {
runtimeOnly(project(":kotlinx-datetime-zoneinfo"))
}
}

val darwinMain by getting {
Expand Down
6 changes: 6 additions & 0 deletions core/common/src/TimeZone.kt
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ public expect open class TimeZone {
* On Linux, this function queries the `/etc/localtime` symbolic link. If the link is missing, [UTC] is used.
* If the link points to an invalid location, [IllegalTimeZoneException] is thrown.
*
* Always returns the `UTC` timezone on the Wasm WASI platform due to the lack of support for retrieving system timezone information.
*
* @sample kotlinx.datetime.test.samples.TimeZoneSamples.currentSystemDefault
*/
public fun currentSystemDefault(): TimeZone
Expand Down Expand Up @@ -95,6 +97,10 @@ public expect open class TimeZone {
*
* @throws IllegalTimeZoneException if [zoneId] has an invalid format or a time-zone with the name [zoneId]
* is not found.
*
* @throws IllegalTimeZoneException on the Wasm WASI platform for non-fixed-offset time zones,
* unless a dependency on the `kotlinx-datetime-zoneinfo` artifact is added.
*
* @sample kotlinx.datetime.test.samples.TimeZoneSamples.constructorFunction
*/
public fun of(zoneId: String): TimeZone
Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import kotlinx.datetime.format.*
import kotlinx.datetime.serializers.UtcOffsetSerializer
import kotlinx.serialization.Serializable
import kotlin.math.abs
import kotlin.native.concurrent.ThreadLocal

@Serializable(with = UtcOffsetSerializer::class)
public actual class UtcOffset private constructor(public actual val totalSeconds: Int) {
Expand Down
14 changes: 14 additions & 0 deletions core/commonKotlin/src/internal/Platform.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/*
* Copyright 2019-2024 JetBrains s.r.o. and contributors.
* Use of this source code is governed by the Apache 2.0 License that can be found in the LICENSE.txt file.
*/

package kotlinx.datetime.internal

import kotlinx.datetime.Instant

internal expect val systemTzdb: TimeZoneDatabase

internal expect fun currentSystemDefaultZone(): Pair<String, TimeZoneRules?>

internal expect fun currentTime(): Instant
File renamed without changes.
10 changes: 3 additions & 7 deletions core/native/src/internal/Platform.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,11 @@
package kotlinx.datetime.internal

import kotlinx.cinterop.*
import kotlinx.datetime.*
import kotlinx.datetime.Instant
import platform.posix.*

internal expect val systemTzdb: TimeZoneDatabase

internal expect fun currentSystemDefaultZone(): Pair<String, TimeZoneRules?>

@OptIn(ExperimentalForeignApi::class, UnsafeNumber::class)
internal fun currentTime(): Instant = memScoped {
internal actual fun currentTime(): Instant = memScoped {
val tm = alloc<timespec>()
val error = clock_gettime(CLOCK_REALTIME.convert(), tm.ptr)
check(error == 0) { "Error when reading the system clock: ${strerror(errno)?.toKString() ?: "Unknown error"}" }
Expand All @@ -24,4 +20,4 @@ internal fun currentTime(): Instant = memScoped {
} catch (e: IllegalArgumentException) {
throw IllegalStateException("The readings from the system clock (${tm.tv_sec} seconds, ${tm.tv_nsec} nanoseconds) are not representable as an Instant")
}
}
}
44 changes: 44 additions & 0 deletions core/wasmWasi/src/internal/Platform.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
* Copyright 2019-2024 JetBrains s.r.o. and contributors.
* Use of this source code is governed by the Apache 2.0 License that can be found in the LICENSE.txt file.
*/

package kotlinx.datetime.internal

import kotlinx.datetime.Instant
import kotlin.wasm.WasmImport
import kotlin.wasm.unsafe.UnsafeWasmMemoryApi
import kotlin.wasm.unsafe.withScopedMemoryAllocator

/**
* Return the time value of a clock. Note: This is similar to `clock_gettime` in POSIX.
*/
@WasmImport("wasi_snapshot_preview1", "clock_time_get")
private external fun wasiRawClockTimeGet(clockId: Int, precision: Long, resultPtr: Int): Int

private const val CLOCKID_REALTIME = 0

@OptIn(UnsafeWasmMemoryApi::class)
private fun clockTimeGet(): Long = withScopedMemoryAllocator { allocator ->
val rp0 = allocator.allocate(8)
val ret = wasiRawClockTimeGet(
clockId = CLOCKID_REALTIME,
precision = 1,
resultPtr = rp0.address.toInt()
)
if (ret == 0) {
rp0.loadLong()
} else {
error("WASI call failed with $ret")
}
}

internal actual fun currentTime(): Instant = clockTimeGet().let { time ->
// Instant.MAX and Instant.MIN are never going to be exceeded using just the Long number of nanoseconds
Instant(time.floorDiv(NANOS_PER_ONE.toLong()), time.mod(NANOS_PER_ONE.toLong()).toInt())
}

internal actual fun currentSystemDefaultZone(): Pair<String, TimeZoneRules?> =
"UTC" to null

internal actual val systemTzdb: TimeZoneDatabase = TzdbOnData()
44 changes: 44 additions & 0 deletions core/wasmWasi/src/internal/TimeZonesInitializer.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
* Copyright 2019-2023 JetBrains s.r.o. and contributors.
* Use of this source code is governed by the Apache 2.0 License that can be found in the LICENSE.txt file.
*/

package kotlinx.datetime.internal

import kotlinx.datetime.IllegalTimeZoneException

@RequiresOptIn
internal annotation class InternalDateTimeApi

/*
This is internal API which is not intended to use on user-side.
*/
@InternalDateTimeApi
public interface TimeZonesProvider {
Copy link
Collaborator

Choose a reason for hiding this comment

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

If you mark this as internal and @Suppress the warning, will anything break? It would be nicer not to expose unnecessary symbols to autocompletion.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Well, I would prefer not suppressing anything in libraries. Only with special request from libraries team.

Copy link
Collaborator

Choose a reason for hiding this comment

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

We prioritize user experience over how clean our internal implementation is, so yes, if it's possible to avoid extra public entry points in a robust manner, we try to do so.

Copy link
Member

Choose a reason for hiding this comment

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

@Suppress the warning

Note that accessing internals outside of the module is an error, not a warning.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Ok, in that case, why not @Suppress the error?

Copy link
Member

@ilya-g ilya-g Aug 7, 2024

Choose a reason for hiding this comment

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

Suppressing such errors is not advisable by the compiler team and can stop working in K2

public fun zoneDataByName(name: String): ByteArray
public fun getTimeZones(): Set<String>
}

/*
This is internal API which is not intended to use on user-side.
*/
@InternalDateTimeApi
public fun initializeTimeZonesProvider(provider: TimeZonesProvider) {
check(timeZonesProvider != provider) { "TimeZone database redeclaration" }
Copy link
Contributor Author

Choose a reason for hiding this comment

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

We need to decide what to do if more that one timezone library were added

Copy link
Collaborator

Choose a reason for hiding this comment

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

But this issue can't occur currently, when there's only one artifact with the time zones, right? If we decide to publish partial timezone data, we could do it in a way that resolves conflicts on the build system level. For example, kotlinx-datetime-timezone-basic + kotlinx-datetime-timezones-full, with full depending on basic. So I'm not sure if we will ever encounter such conflicts.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I have no idea how to to resolve such conflicts for different flavors of timezones libraries without a special gradle plugin. We, with gradle plugin team, do not see the way for now, how to get one dependency with gradle resolution, the only way I see now is to allow redeclaration in runtime with a special "priority" level. But how to manage this level's needs a consideration.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Here's a scheme:

kotlinx-datetime-timezones-full -> kotlinx-datetime-timezones-basic -> kotlinx-datetime

A -> B means "A depends on B". kotlinx-datetime-timezones-full only provides new data, the bulk of the data is in kotlinx-datetime-timezones-basic. Then, kotlinx-datetime-timezones-basic could have another thing like initializeFullTimeZonesProvider, which kotlinx-datetime-timezones-full would call. Do you see any problems with this approach?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, for now we are not decided yet what could -basic be but a first thoughts was about to remove some information from timezones (like make only future implementation or +-5 years). But not amount timezones. So the information in timezones then will be not additive.

Copy link
Collaborator

Choose a reason for hiding this comment

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

In any case, we'll be able to work around this somehow. This is a technical thing invisible to the users, so we can safely do anything we want here.

timeZonesProvider = provider
}

@InternalDateTimeApi
private var timeZonesProvider: TimeZonesProvider? = null

@OptIn(InternalDateTimeApi::class)
internal class TzdbOnData: TimeZoneDatabase {
override fun rulesForId(id: String): TimeZoneRules {
val data = timeZonesProvider?.zoneDataByName(id)
?: throw IllegalTimeZoneException("TimeZones are not supported")
return readTzFile(data).toTimeZoneRules()
}

override fun availableTimeZoneIds(): Set<String> =
timeZonesProvider?.getTimeZones() ?: setOf("UTC")
}
4 changes: 3 additions & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
org.gradle.jvmargs=-Xmx1G -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
org.gradle.jvmargs=-Xmx2G -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
org.gradle.java.installations.fromEnv=JDK_8

group=org.jetbrains.kotlinx
version=0.6.1
versionSuffix=SNAPSHOT

tzdbVersion=2024a
Copy link
Collaborator

Choose a reason for hiding this comment

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

Note (we'll have to discuss this internally): SemVer doesn't allow letters in the initial components, so 1.0.2024a is an invalid version number. We could stick to this anyway, or we could use the less natural 1.0.2024-a (note the dash).

Copy link
Collaborator

Choose a reason for hiding this comment

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

In the end, this isn't a SemVer anyway, so dashes aren't needed, 2024a is ok.

Copy link
Collaborator

Choose a reason for hiding this comment

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

This may be out of this PR's scope, but it's possible to automatically detect that a new version of tzdb was published: https://github.com/ThreeTen/threetenbp/blob/main/.github/workflows/tzdbupdate.yml If you feel like implementing this, it would be nice; if not, we'll just do it later ourselves.


defaultKotlinVersion=1.9.21
dokkaVersion=1.9.20
serializationVersion=1.6.2
Expand Down
12 changes: 12 additions & 0 deletions serialization/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ kotlin {
}
}

wasmWasi {
nodejs {
}
}

sourceSets.all {
val suffixIndex = name.indexOfLast { it.isUpperCase() }
val targetName = name.substring(0, suffixIndex)
Expand Down Expand Up @@ -103,6 +108,13 @@ kotlin {
}
}

val wasmWasiMain by getting
val wasmWasiTest by getting {
dependencies {
runtimeOnly(project(":kotlinx-datetime-zoneinfo"))
}
}

val nativeMain by getting
val nativeTest by getting
}
Expand Down
2 changes: 2 additions & 0 deletions settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ rootProject.name = "Kotlin-DateTime-library"

include(":core")
project(":core").name = "kotlinx-datetime"
include(":timezones/full")
project(":timezones/full").name = "kotlinx-datetime-zoneinfo"
include(":serialization")
project(":serialization").name = "kotlinx-datetime-serialization"
include(":benchmarks")
Expand Down
Loading