expo-modules-jsi 57.0.0 → 57.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/apple/Sources/ExpoModulesJSI/Coding/JavaScriptCodable+Date.swift +70 -0
- package/apple/Sources/ExpoModulesJSI/Runtime/JavaScriptRef.swift +12 -0
- package/apple/Sources/ExpoModulesJSI/Runtime/JavaScriptRuntime.swift +83 -1
- package/apple/Sources/ExpoModulesJSI/Runtime/LongLivedObjectCollection.swift +46 -0
- package/apple/Sources/ExpoModulesJSI/Runtime/Values/JavaScriptError.swift +15 -0
- package/apple/Sources/ExpoModulesJSI/Runtime/Values/JavaScriptPromise.swift +89 -31
- package/apple/Sources/ExpoModulesJSI/Runtime/Values/JavaScriptValue.swift +4 -2
- package/apple/Sources/ExpoModulesJSI-Cxx/include/JSIUtils.h +10 -0
- package/apple/Sources/ExpoModulesJSI-Cxx/include/RuntimeScheduler.h +3 -2
- package/apple/Tests/JavaScriptCodableDateTests.swift +270 -0
- package/apple/Tests/JavaScriptErrorTests.swift +33 -0
- package/apple/Tests/JavaScriptPromiseTests.swift +125 -0
- package/apple/Tests/JavaScriptRuntimeTests.swift +67 -0
- package/apple/Tests/LongLivedObjectCollectionTests.swift +228 -0
- package/package.json +2 -2
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// Copyright 2025-present 650 Industries. All rights reserved.
|
|
2
|
+
|
|
3
|
+
import ExpoModulesJSI
|
|
4
|
+
import Foundation
|
|
5
|
+
import Testing
|
|
6
|
+
|
|
7
|
+
@Suite("JavaScriptCodable+Date")
|
|
8
|
+
@JavaScriptActor
|
|
9
|
+
struct JavaScriptCodableDateTests {
|
|
10
|
+
let runtime = JavaScriptRuntime()
|
|
11
|
+
|
|
12
|
+
// MARK: - Decode
|
|
13
|
+
|
|
14
|
+
@Test
|
|
15
|
+
func `decodes a JS Date to the same instant`() throws {
|
|
16
|
+
// 2021-01-01T00:00:00.000Z expressed as an absolute UTC millisecond instant. `Date.UTC` returns
|
|
17
|
+
// that instant directly, so no local timezone enters the JS side.
|
|
18
|
+
let value = try runtime.eval("new Date(Date.UTC(2021, 0, 1, 0, 0, 0, 0))")
|
|
19
|
+
let decoded = try Date.decode(value, in: runtime)
|
|
20
|
+
#expect(decoded.timeIntervalSince1970 == 1_609_459_200.0)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
@Test
|
|
24
|
+
func `decodes the Unix epoch`() throws {
|
|
25
|
+
let decoded = try Date.decode(runtime.eval("new Date(0)"), in: runtime)
|
|
26
|
+
#expect(decoded.timeIntervalSince1970 == 0.0)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
@Test
|
|
30
|
+
func `decodes a pre-epoch Date`() throws {
|
|
31
|
+
// A negative millisecond instant (1960-01-01T00:00:00Z) must decode to a negative interval.
|
|
32
|
+
let value = try runtime.eval("new Date(Date.UTC(1960, 0, 1))")
|
|
33
|
+
let decoded = try Date.decode(value, in: runtime)
|
|
34
|
+
#expect(decoded.timeIntervalSince1970 == -315_619_200.0)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
@Test
|
|
38
|
+
func `decode preserves sub-second milliseconds`() throws {
|
|
39
|
+
// Assert on the whole-millisecond instant (an integer-valued Double, always exact) rather than the
|
|
40
|
+
// reconstructed fractional seconds, whose last bit can differ by an ULP across the JS boundary.
|
|
41
|
+
let decoded = try Date.decode(runtime.eval("new Date(1234)"), in: runtime)
|
|
42
|
+
#expect((decoded.timeIntervalSince1970 * 1000.0).rounded() == 1234.0)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
@Test
|
|
46
|
+
func `decodes a JS Date through the borrowed-value overload`() throws {
|
|
47
|
+
// `Date` does not override the zero-copy overload (it needs a `getTime()` call), so this goes
|
|
48
|
+
// through the default that copies the borrowed value into an owning one and forwards.
|
|
49
|
+
let value = try runtime.eval("new Date(5000)")
|
|
50
|
+
let buffer = JavaScriptValuesBuffer.copying(in: runtime, values: [value])
|
|
51
|
+
let decoded = try Date.decode(buffer.unownedValue(at: 0), in: runtime)
|
|
52
|
+
#expect(decoded.timeIntervalSince1970 == 5.0)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// MARK: - Encode
|
|
56
|
+
|
|
57
|
+
@Test
|
|
58
|
+
func `encodes a Date to a JS Date`() throws {
|
|
59
|
+
let encoded = try Date.encode(Date(timeIntervalSince1970: 1_609_459_200.0), in: runtime)
|
|
60
|
+
#expect(encoded.is("Date") == true)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
@Test
|
|
64
|
+
func `encoded Date carries the same instant into JS`() throws {
|
|
65
|
+
// Cross-check the encoded value against JS's own `getTime()`, so the assertion is that JS and
|
|
66
|
+
// Swift agree on the absolute millisecond instant — not merely that a Date was produced.
|
|
67
|
+
let date = Date(timeIntervalSince1970: 1_609_459_200.0)
|
|
68
|
+
let encoded = try Date.encode(date, in: runtime)
|
|
69
|
+
let millisecondsInJS = try encoded.asObject().callFunction("getTime").asDouble()
|
|
70
|
+
#expect(millisecondsInJS == 1_609_459_200_000.0)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// MARK: - Timezone / calendar independence
|
|
74
|
+
|
|
75
|
+
@Test
|
|
76
|
+
func `instant is independent of the JS Date's local-time getters`() throws {
|
|
77
|
+
// Build the same absolute instant two ways: from a UTC millisecond value, and from local-time
|
|
78
|
+
// components. `Date.UTC` and the `new Date(y, m, d, …)` local constructor produce different
|
|
79
|
+
// instants unless the runtime is at UTC, so we decode the explicitly-UTC one and assert the
|
|
80
|
+
// instant matches its `getTime()` exactly. The point: decode reads `getTime()` (absolute UTC ms),
|
|
81
|
+
// never a local-time getter, so no timezone offset is folded in.
|
|
82
|
+
let value = try runtime.eval("new Date(Date.UTC(2021, 5, 15, 12, 30, 45, 123))")
|
|
83
|
+
let expectedMilliseconds = try value.asObject().callFunction("getTime").asDouble()
|
|
84
|
+
let decoded = try Date.decode(value, in: runtime)
|
|
85
|
+
#expect((decoded.timeIntervalSince1970 * 1000.0).rounded() == expectedMilliseconds)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
@Test
|
|
89
|
+
func `round-trip is calendar and timezone agnostic`() throws {
|
|
90
|
+
// A `Date` is an absolute instant with no stored calendar or timezone; both JS `Date` and Swift
|
|
91
|
+
// `Date` are epoch-relative. This encodes to JS and decodes back, asserting the instant is
|
|
92
|
+
// unchanged at millisecond granularity regardless of the ambient calendar/timezone.
|
|
93
|
+
let original = Date(timeIntervalSince1970: 1_655_296_245.123)
|
|
94
|
+
let encoded = try Date.encode(original, in: runtime)
|
|
95
|
+
let roundTripped = try Date.decode(encoded, in: runtime)
|
|
96
|
+
// Compare at whole-millisecond granularity (JS `Date`'s resolution); the original is already an
|
|
97
|
+
// exact number of milliseconds, so nothing is lost, but the comparison avoids float-equality ULP noise.
|
|
98
|
+
#expect(
|
|
99
|
+
(roundTripped.timeIntervalSince1970 * 1000.0).rounded() == (original.timeIntervalSince1970 * 1000.0).rounded())
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
@Test
|
|
103
|
+
func `round-trip drops sub-millisecond precision`() throws {
|
|
104
|
+
// JS `Date` is integer-millisecond resolution, so the microsecond tail of a Swift `Date` is lost
|
|
105
|
+
// on the way through JS. The round-trip lands on the truncated whole millisecond (JS's `Date`
|
|
106
|
+
// constructor truncates the fractional millisecond toward zero), and no longer equals the original.
|
|
107
|
+
let original = Date(timeIntervalSince1970: 1.2345678)
|
|
108
|
+
let roundTripped = try Date.decode(Date.encode(original, in: runtime), in: runtime)
|
|
109
|
+
#expect((roundTripped.timeIntervalSince1970 * 1000.0).rounded() == 1234.0)
|
|
110
|
+
#expect(roundTripped.timeIntervalSince1970 != original.timeIntervalSince1970)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
@Test
|
|
114
|
+
func `Date current time round-trips to the millisecond`() throws {
|
|
115
|
+
let now = Date()
|
|
116
|
+
let roundTripped = try Date.decode(Date.encode(now, in: runtime), in: runtime)
|
|
117
|
+
// Truncate the original to whole milliseconds (JS's resolution) and compare on the millisecond
|
|
118
|
+
// instant, which is an exact integer-valued Double on both sides.
|
|
119
|
+
let nowMilliseconds = (now.timeIntervalSince1970 * 1000.0).rounded(.towardZero)
|
|
120
|
+
#expect((roundTripped.timeIntervalSince1970 * 1000.0).rounded() == nowMilliseconds)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// MARK: - Decode from a JS number (milliseconds since the epoch)
|
|
124
|
+
|
|
125
|
+
@Test
|
|
126
|
+
func `decodes a number as milliseconds since the epoch`() throws {
|
|
127
|
+
// A JS number is interpreted the same way `new Date(ms)` interprets it, so `getTime()` round-trips.
|
|
128
|
+
let decoded = try Date.decode(runtime.eval("1609459200000"), in: runtime)
|
|
129
|
+
#expect(decoded.timeIntervalSince1970 == 1_609_459_200.0)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
@Test
|
|
133
|
+
func `decodes zero as the Unix epoch`() throws {
|
|
134
|
+
let decoded = try Date.decode(runtime.eval("0"), in: runtime)
|
|
135
|
+
#expect(decoded.timeIntervalSince1970 == 0.0)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
@Test
|
|
139
|
+
func `decodes a negative number as a pre-epoch instant`() throws {
|
|
140
|
+
let decoded = try Date.decode(runtime.eval("-315619200000"), in: runtime)
|
|
141
|
+
#expect(decoded.timeIntervalSince1970 == -315_619_200.0)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
@Test
|
|
145
|
+
func `a JS Date's getTime decodes to the same instant as the Date`() throws {
|
|
146
|
+
// Passing `someDate` and passing `someDate.getTime()` must decode to the same Swift `Date`.
|
|
147
|
+
let fromDate = try Date.decode(runtime.eval("new Date(Date.UTC(2021, 0, 1))"), in: runtime)
|
|
148
|
+
let fromMilliseconds = try Date.decode(runtime.eval("new Date(Date.UTC(2021, 0, 1)).getTime()"), in: runtime)
|
|
149
|
+
#expect(fromDate.timeIntervalSince1970 == fromMilliseconds.timeIntervalSince1970)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
@Test
|
|
153
|
+
func `decode truncates a fractional number toward zero like the Date constructor`() throws {
|
|
154
|
+
// `new Date(1.9).getTime()` is `1` and `new Date(-1.9).getTime()` is `-1`: the constructor applies
|
|
155
|
+
// TimeClip (truncate toward zero), so the number branch must too rather than keeping 1.9 ms.
|
|
156
|
+
let positive = try Date.decode(runtime.eval("1.9"), in: runtime)
|
|
157
|
+
#expect((positive.timeIntervalSince1970 * 1000.0).rounded() == 1.0)
|
|
158
|
+
let negative = try Date.decode(runtime.eval("-1.9"), in: runtime)
|
|
159
|
+
#expect((negative.timeIntervalSince1970 * 1000.0).rounded() == -1.0)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
@Test
|
|
163
|
+
func `decode accepts the maximum representable JS date`() throws {
|
|
164
|
+
// ±8.64e15 ms (100,000,000 days from the epoch) is the inclusive bound of a valid JS `Date`.
|
|
165
|
+
let maximum = try Date.decode(runtime.eval("8640000000000000"), in: runtime)
|
|
166
|
+
#expect((maximum.timeIntervalSince1970 * 1000.0) == 8_640_000_000_000_000.0)
|
|
167
|
+
let minimum = try Date.decode(runtime.eval("-8640000000000000"), in: runtime)
|
|
168
|
+
#expect((minimum.timeIntervalSince1970 * 1000.0) == -8_640_000_000_000_000.0)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
@Test
|
|
172
|
+
func `decode rejects a number beyond the JS date range`() throws {
|
|
173
|
+
// One millisecond past the bound is an `Invalid Date` in JS (`new Date(8640000000000001).getTime()`
|
|
174
|
+
// is `NaN`), so decode must reject it rather than accept an instant JS `Date` can't hold.
|
|
175
|
+
#expect(throws: InvalidDateException.self) {
|
|
176
|
+
_ = try Date.decode(runtime.eval("8640000000000001"), in: runtime)
|
|
177
|
+
}
|
|
178
|
+
#expect(throws: InvalidDateException.self) {
|
|
179
|
+
_ = try Date.decode(runtime.eval("-8640000000000001"), in: runtime)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
@Test
|
|
184
|
+
func `decode matches new Date for the same number`() throws {
|
|
185
|
+
// Cross-check the number branch against the constructor for values that exercise TimeClip.
|
|
186
|
+
for source in ["1.9", "-1.9", "0.4", "1234.5678", "8640000000000000"] {
|
|
187
|
+
let expected = try runtime.eval("new Date(\(source)).getTime()").asDouble()
|
|
188
|
+
let decoded = try Date.decode(runtime.eval(source), in: runtime)
|
|
189
|
+
#expect((decoded.timeIntervalSince1970 * 1000.0).rounded() == expected)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// MARK: - Decode from a string (parsed by the JS `Date` constructor)
|
|
194
|
+
|
|
195
|
+
@Test
|
|
196
|
+
func `decodes an ISO 8601 string with fractional seconds`() throws {
|
|
197
|
+
let decoded = try Date.decode(runtime.eval("\"2021-01-01T00:00:00.123Z\""), in: runtime)
|
|
198
|
+
#expect((decoded.timeIntervalSince1970 * 1000.0).rounded() == 1_609_459_200_123.0)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
@Test
|
|
202
|
+
func `decodes an ISO 8601 string without fractional seconds`() throws {
|
|
203
|
+
let decoded = try Date.decode(runtime.eval("\"2021-01-01T00:00:00Z\""), in: runtime)
|
|
204
|
+
#expect(decoded.timeIntervalSince1970 == 1_609_459_200.0)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
@Test
|
|
208
|
+
func `decodes an ISO 8601 string with a timezone offset to the correct UTC instant`() throws {
|
|
209
|
+
// 01:00 at +01:00 is the same absolute instant as 00:00 UTC; the offset must be applied, not ignored.
|
|
210
|
+
let decoded = try Date.decode(runtime.eval("\"2021-01-01T01:00:00+01:00\""), in: runtime)
|
|
211
|
+
#expect(decoded.timeIntervalSince1970 == 1_609_459_200.0)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
@Test
|
|
215
|
+
func `decodes a date-only string as UTC midnight`() throws {
|
|
216
|
+
// The JS `Date` constructor reads a date-only ISO string as UTC midnight (unlike a zone-less
|
|
217
|
+
// date-time, which it reads as local). This form is accepted here because `new Date(str)` accepts it.
|
|
218
|
+
let decoded = try Date.decode(runtime.eval("\"2021-01-01\""), in: runtime)
|
|
219
|
+
#expect(decoded.timeIntervalSince1970 == 1_609_459_200.0)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
@Test
|
|
223
|
+
func `decodes a string the same way new Date parses it`() throws {
|
|
224
|
+
// The decode contract for a string is "exactly what `new Date(str)` produces". Cross-check the
|
|
225
|
+
// decoded instant against the engine's own parse of the same string, so this holds for whatever
|
|
226
|
+
// date grammar the runtime supports (ISO, RFC-2822-ish, etc.), identically on every platform.
|
|
227
|
+
let source = "\"2021-06-15T12:30:45.500Z\""
|
|
228
|
+
let expectedMilliseconds = try runtime.eval("new Date(\(source)).getTime()").asDouble()
|
|
229
|
+
let decoded = try Date.decode(runtime.eval(source), in: runtime)
|
|
230
|
+
#expect((decoded.timeIntervalSince1970 * 1000.0).rounded() == expectedMilliseconds)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// MARK: - Error paths
|
|
234
|
+
|
|
235
|
+
@Test
|
|
236
|
+
func `Date decode throws on an unparseable string`() throws {
|
|
237
|
+
// `new Date(...)` yields an `Invalid Date` (NaN time) rather than throwing; decode turns that NaN
|
|
238
|
+
// into a thrown error.
|
|
239
|
+
#expect(throws: InvalidDateException.self) {
|
|
240
|
+
_ = try Date.decode(runtime.eval("\"not a date\""), in: runtime)
|
|
241
|
+
}
|
|
242
|
+
#expect(throws: InvalidDateException.self) {
|
|
243
|
+
_ = try Date.decode(runtime.eval("\"\""), in: runtime)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
@Test
|
|
248
|
+
func `Date decode throws on NaN`() throws {
|
|
249
|
+
// A NaN number produces an `Invalid Date` in JS; decode must reject it rather than yield a bogus instant.
|
|
250
|
+
#expect(throws: InvalidDateException.self) {
|
|
251
|
+
_ = try Date.decode(runtime.eval("NaN"), in: runtime)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
@Test
|
|
256
|
+
func `Date decode throws on an unsupported type`() throws {
|
|
257
|
+
#expect(throws: InvalidDateException.self) {
|
|
258
|
+
_ = try Date.decode(runtime.eval("({})"), in: runtime)
|
|
259
|
+
}
|
|
260
|
+
#expect(throws: InvalidDateException.self) {
|
|
261
|
+
_ = try Date.decode(runtime.eval("null"), in: runtime)
|
|
262
|
+
}
|
|
263
|
+
#expect(throws: InvalidDateException.self) {
|
|
264
|
+
_ = try Date.decode(runtime.eval("true"), in: runtime)
|
|
265
|
+
}
|
|
266
|
+
#expect(throws: InvalidDateException.self) {
|
|
267
|
+
_ = try Date.decode(runtime.eval("[1, 2, 3]"), in: runtime)
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
@@ -179,6 +179,39 @@ struct JavaScriptErrorTests {
|
|
|
179
179
|
#expect(caught.getObject().getProperty("message").getString() == "native failure")
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
// MARK: - JavaScriptError.from(_:in:) helper
|
|
183
|
+
|
|
184
|
+
@Test
|
|
185
|
+
func `from returns an existing JavaScriptError unchanged`() {
|
|
186
|
+
// An existing `JavaScriptError` must be returned as the very same instance so its wrapped
|
|
187
|
+
// value (which may be an arbitrary JS value) reaches JS unchanged.
|
|
188
|
+
let original = JavaScriptError(runtime, value: JavaScriptValue(runtime, "just a string"))
|
|
189
|
+
let result = JavaScriptError.from(original, in: runtime)
|
|
190
|
+
|
|
191
|
+
#expect(result === original)
|
|
192
|
+
#expect(result.toValue().getString() == "just a string")
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
@Test
|
|
196
|
+
func `from routes a JavaScriptThrowable through the code-preserving initializer`() {
|
|
197
|
+
let throwable = CodedError(message: "Not found", code: "ERR_NOT_FOUND")
|
|
198
|
+
let object = JavaScriptError.from(throwable, in: runtime).toValue().getObject()
|
|
199
|
+
|
|
200
|
+
#expect(object.getProperty("message").getString() == "Not found")
|
|
201
|
+
#expect(object.getProperty("code").getString() == "ERR_NOT_FOUND")
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
@Test
|
|
205
|
+
func `from stringifies any other native error into a generic Error`() {
|
|
206
|
+
struct TestError: Error, CustomStringConvertible {
|
|
207
|
+
var description: String { "native failure" }
|
|
208
|
+
}
|
|
209
|
+
let object = JavaScriptError.from(TestError(), in: runtime).toValue().getObject()
|
|
210
|
+
|
|
211
|
+
#expect(object.getProperty("message").getString() == "native failure")
|
|
212
|
+
#expect(object.getProperty("code").isUndefined() == true)
|
|
213
|
+
}
|
|
214
|
+
|
|
182
215
|
@Test
|
|
183
216
|
func `nested host function errors are independent`() throws {
|
|
184
217
|
let outer = runtime.createFunction("outer") { [self] _, _ in
|
|
@@ -106,6 +106,9 @@ struct JavaScriptPromiseTests {
|
|
|
106
106
|
#expect(throws: Error.self) {
|
|
107
107
|
_ = try promiseValue.getPromise()
|
|
108
108
|
}
|
|
109
|
+
// A failed initializer must not leave its state registered, or it would pin the promise object
|
|
110
|
+
// in the collection until teardown even though no `JavaScriptPromise` escaped.
|
|
111
|
+
#expect(runtime.longLivedObjects.count == 0)
|
|
109
112
|
}
|
|
110
113
|
|
|
111
114
|
@Test
|
|
@@ -370,4 +373,126 @@ struct JavaScriptPromiseTests {
|
|
|
370
373
|
|
|
371
374
|
#expect(promise.isDeferred == false)
|
|
372
375
|
}
|
|
376
|
+
|
|
377
|
+
// MARK: - Long-lived object registration
|
|
378
|
+
|
|
379
|
+
@Test
|
|
380
|
+
func `deferred promise registers as a long-lived object`() throws {
|
|
381
|
+
let runtime = JavaScriptRuntime()
|
|
382
|
+
#expect(runtime.longLivedObjects.count == 0)
|
|
383
|
+
|
|
384
|
+
let promise = try JavaScriptPromise(runtime)
|
|
385
|
+
_ = promise.isDeferred
|
|
386
|
+
|
|
387
|
+
#expect(runtime.longLivedObjects.count == 1)
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
@Test
|
|
391
|
+
func `wrapping an existing promise registers to own its object`() throws {
|
|
392
|
+
let runtime = JavaScriptRuntime()
|
|
393
|
+
let promise = try runtime.eval("Promise.resolve(42)").getPromise()
|
|
394
|
+
#expect(promise.isDeferred == false)
|
|
395
|
+
|
|
396
|
+
// Even a wrapped promise owns a JSI object that must not be released against a freed runtime,
|
|
397
|
+
// so it registers to have that object swept at teardown.
|
|
398
|
+
#expect(runtime.longLivedObjects.count == 1)
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
@Test
|
|
402
|
+
func `settling keeps the promise registered while the wrapper is alive`() async throws {
|
|
403
|
+
let runtime = JavaScriptRuntime()
|
|
404
|
+
let promise = try JavaScriptPromise(runtime)
|
|
405
|
+
#expect(runtime.longLivedObjects.count == 1)
|
|
406
|
+
|
|
407
|
+
promise.resolve(JavaScriptValue(runtime, 42))
|
|
408
|
+
_ = try await promise.await()
|
|
409
|
+
|
|
410
|
+
// Settling releases the resolve/reject functions, but the state stays registered so it continues
|
|
411
|
+
// to own the promise object for as long as the wrapper is alive (it is only deregistered when the
|
|
412
|
+
// wrapper is dropped, see the tests below, or by the teardown sweep).
|
|
413
|
+
#expect(promise.isDeferred == false)
|
|
414
|
+
#expect(runtime.longLivedObjects.count == 1)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
@Test
|
|
418
|
+
func `dropping a settled promise deregisters its state`() async throws {
|
|
419
|
+
let runtime = JavaScriptRuntime()
|
|
420
|
+
|
|
421
|
+
do {
|
|
422
|
+
let promise = try JavaScriptPromise(runtime)
|
|
423
|
+
promise.resolve(JavaScriptValue(runtime, 42))
|
|
424
|
+
_ = try await promise.await()
|
|
425
|
+
#expect(runtime.longLivedObjects.count == 1)
|
|
426
|
+
// Leaving the scope drops the last owner of the wrapper.
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Dropping the wrapper deregisters its state and releases the promise object, so a stream of
|
|
430
|
+
// short-lived promises doesn't pin their objects (and resolution values) until teardown.
|
|
431
|
+
#expect(runtime.longLivedObjects.count == 0)
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
@Test
|
|
435
|
+
func `dropping an unsettled promise deregisters its state`() throws {
|
|
436
|
+
let runtime = JavaScriptRuntime()
|
|
437
|
+
|
|
438
|
+
do {
|
|
439
|
+
let promise = try JavaScriptPromise(runtime)
|
|
440
|
+
#expect(promise.isDeferred == true)
|
|
441
|
+
#expect(runtime.longLivedObjects.count == 1)
|
|
442
|
+
// The promise is never settled; dropping the wrapper here is the only owner going away.
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Even an unsettled promise releases its state when its wrapper is dropped: nothing outside can
|
|
446
|
+
// settle it anymore, so keeping it registered would only pin the object until teardown.
|
|
447
|
+
#expect(runtime.longLivedObjects.count == 0)
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
@Test
|
|
451
|
+
func `dropping a wrapped promise deregisters its state`() throws {
|
|
452
|
+
let runtime = JavaScriptRuntime()
|
|
453
|
+
|
|
454
|
+
do {
|
|
455
|
+
let promise = try runtime.eval("Promise.resolve(42)").getPromise()
|
|
456
|
+
#expect(promise.isDeferred == false)
|
|
457
|
+
#expect(runtime.longLivedObjects.count == 1)
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
#expect(runtime.longLivedObjects.count == 0)
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
@Test
|
|
464
|
+
func `an unsettled deferred promise is released by the teardown sweep`() throws {
|
|
465
|
+
let runtime = JavaScriptRuntime()
|
|
466
|
+
let promise = try JavaScriptPromise(runtime)
|
|
467
|
+
#expect(runtime.longLivedObjects.count == 1)
|
|
468
|
+
|
|
469
|
+
// The promise is never settled; the runtime's teardown sweep must release its long-lived state.
|
|
470
|
+
runtime.longLivedObjects.clear()
|
|
471
|
+
|
|
472
|
+
#expect(runtime.longLivedObjects.count == 0)
|
|
473
|
+
// After the sweep the settle functions are released, so it can no longer be settled.
|
|
474
|
+
#expect(promise.isDeferred == false)
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
@Test
|
|
478
|
+
func `an unsettled deferred promise outliving its runtime does not crash on teardown`() throws {
|
|
479
|
+
// Reproduces the shape of the promise-teardown crash (#47454): a deferred promise is still in
|
|
480
|
+
// flight when its runtime is torn down. Before the state was owned by the runtime's
|
|
481
|
+
// `LongLivedObjectCollection`, its JSI values were released after the Hermes runtime was already
|
|
482
|
+
// destroyed, a use-after-free. Now the runtime's teardown sweep releases the state on the JS
|
|
483
|
+
// thread while the runtime is still valid.
|
|
484
|
+
var promise: JavaScriptPromise? = nil
|
|
485
|
+
|
|
486
|
+
do {
|
|
487
|
+
let runtime = JavaScriptRuntime()
|
|
488
|
+
promise = try JavaScriptPromise(runtime)
|
|
489
|
+
#expect(promise?.isDeferred == true)
|
|
490
|
+
// Leaving the scope releases the runtime while the promise is still unsettled. Its teardown
|
|
491
|
+
// sweep must release the promise's state before Hermes is destroyed.
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Dropping the promise wrapper here must not touch a freed runtime. This is the crash point in
|
|
495
|
+
// #47454; reaching the end of the test without a crash is the assertion.
|
|
496
|
+
promise = nil
|
|
497
|
+
}
|
|
373
498
|
}
|
|
@@ -790,6 +790,73 @@ struct JavaScriptRuntimeTests {
|
|
|
790
790
|
let otherRuntime = JavaScriptRuntime()
|
|
791
791
|
#expect(otherRuntime.id != runtime.id)
|
|
792
792
|
}
|
|
793
|
+
|
|
794
|
+
@Test
|
|
795
|
+
func `creating and releasing standalone runtimes repeatedly does not crash`() throws {
|
|
796
|
+
// Each standalone runtime owns its Hermes runtime and destroys it on `deinit`. Cycling through
|
|
797
|
+
// many create/use/release rounds exercises that teardown and would surface a use-after-free or
|
|
798
|
+
// double-free (destroying a runtime must not corrupt a subsequently created one). Calling `is`
|
|
799
|
+
// caches a `PropNameID` on the runtime, so this also covers releasing cached JSI objects before
|
|
800
|
+
// the runtime is freed.
|
|
801
|
+
for index in 0..<20 {
|
|
802
|
+
let localRuntime = JavaScriptRuntime()
|
|
803
|
+
let value = try localRuntime.eval("({ index: \(index) })")
|
|
804
|
+
#expect(value.is("Object") == true)
|
|
805
|
+
#expect(value.getObject().getProperty("index").getInt() == index)
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// MARK: - Long-lived objects teardown
|
|
810
|
+
|
|
811
|
+
/// Records whether `allowRelease()` was called.
|
|
812
|
+
final class TrackedObject: LongLivedObject {
|
|
813
|
+
private(set) var released = false
|
|
814
|
+
|
|
815
|
+
func allowRelease() {
|
|
816
|
+
released = true
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
@Test
|
|
821
|
+
func `tearing down the runtime clears its long-lived objects`() {
|
|
822
|
+
let tracked = TrackedObject()
|
|
823
|
+
|
|
824
|
+
do {
|
|
825
|
+
let localRuntime = JavaScriptRuntime()
|
|
826
|
+
localRuntime.longLivedObjects.add(tracked)
|
|
827
|
+
#expect(localRuntime.longLivedObjects.count == 1)
|
|
828
|
+
// Leaving the scope releases the runtime. Its `deinit` destroys the owned Hermes runtime,
|
|
829
|
+
// tearing down the JS heap, which drops the teardown object's native state and fires its
|
|
830
|
+
// deallocator, sweeping the collection.
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
#expect(tracked.released == true)
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
@Test
|
|
837
|
+
func `an object removed before teardown is not released by the sweep`() {
|
|
838
|
+
let tracked = TrackedObject()
|
|
839
|
+
|
|
840
|
+
do {
|
|
841
|
+
let localRuntime = JavaScriptRuntime()
|
|
842
|
+
localRuntime.longLivedObjects.add(tracked)
|
|
843
|
+
localRuntime.longLivedObjects.remove(tracked)
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
#expect(tracked.released == false)
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
@Test
|
|
850
|
+
func `wrapping the same runtime again does not sweep the first wrapper's objects`() {
|
|
851
|
+
// Each wrapper pins its own teardown object under a per-wrapper property name. A second wrapper
|
|
852
|
+
// of the same underlying runtime must not overwrite the first's pinned object (which would let
|
|
853
|
+
// it be collected early and sweep the first wrapper's collection while the runtime is alive).
|
|
854
|
+
let tracked = TrackedObject()
|
|
855
|
+
runtime.longLivedObjects.add(tracked)
|
|
856
|
+
_ = runtime.withUnsafePointee { JavaScriptRuntime(unsafePointer: $0) }
|
|
857
|
+
#expect(tracked.released == false)
|
|
858
|
+
#expect(runtime.longLivedObjects.count == 1)
|
|
859
|
+
}
|
|
793
860
|
}
|
|
794
861
|
|
|
795
862
|
/// Runs `body` on a freshly spawned synchronous thread and bridges the result back into the
|