Skip to content

Commit 4504e13

Browse files
Merge pull request #393 from swiftwasm/yt/jsclosure-fix
Fix wrong deallocation management for JSClosure with FinalizationRegistry
2 parents 7c153c3 + 5f01593 commit 4504e13

File tree

6 files changed

+100
-7
lines changed

6 files changed

+100
-7
lines changed

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ unittest:
1313
-Xlinker --global-base=524288 \
1414
-Xlinker -z \
1515
-Xlinker stack-size=524288 \
16-
js test --prelude ./Tests/prelude.mjs
16+
js test --prelude ./Tests/prelude.mjs -Xnode --expose-gc
1717

1818
.PHONY: regenerate_swiftpm_resources
1919
regenerate_swiftpm_resources:

Plugins/PackageToJS/Templates/runtime.mjs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -443,7 +443,7 @@ class SwiftRuntime {
443443
});
444444
const alreadyReleased = this.exports.swjs_call_host_function(host_func_id, argv, argc, callback_func_ref);
445445
if (alreadyReleased) {
446-
throw new Error(`The JSClosure has been already released by Swift side. The closure is created at ${file}:${line}`);
446+
throw new Error(`The JSClosure has been already released by Swift side. The closure is created at ${file}:${line} @${host_func_id}`);
447447
}
448448
this.exports.swjs_cleanup_host_function_call(argv);
449449
return output;
@@ -635,7 +635,13 @@ class SwiftRuntime {
635635
const fileString = this.memory.getObject(file);
636636
const func = (...args) => this.callHostFunction(host_func_id, line, fileString, args);
637637
const func_ref = this.memory.retain(func);
638-
(_a = this.closureDeallocator) === null || _a === void 0 ? void 0 : _a.track(func, func_ref);
638+
(_a = this.closureDeallocator) === null || _a === void 0 ? void 0 : _a.track(func, host_func_id);
639+
return func_ref;
640+
},
641+
swjs_create_oneshot_function: (host_func_id, line, file) => {
642+
const fileString = this.memory.getObject(file);
643+
const func = (...args) => this.callHostFunction(host_func_id, line, fileString, args);
644+
const func_ref = this.memory.retain(func);
639645
return func_ref;
640646
},
641647
swjs_create_typed_array: (constructor_ref, elementsPtr, length) => {

Runtime/src/index.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,7 @@ export class SwiftRuntime {
224224
);
225225
if (alreadyReleased) {
226226
throw new Error(
227-
`The JSClosure has been already released by Swift side. The closure is created at ${file}:${line}`
227+
`The JSClosure has been already released by Swift side. The closure is created at ${file}:${line} @${host_func_id}`
228228
);
229229
}
230230
this.exports.swjs_cleanup_host_function_call(argv);
@@ -553,7 +553,19 @@ export class SwiftRuntime {
553553
const func = (...args: any[]) =>
554554
this.callHostFunction(host_func_id, line, fileString, args);
555555
const func_ref = this.memory.retain(func);
556-
this.closureDeallocator?.track(func, func_ref);
556+
this.closureDeallocator?.track(func, host_func_id);
557+
return func_ref;
558+
},
559+
560+
swjs_create_oneshot_function: (
561+
host_func_id: number,
562+
line: number,
563+
file: ref
564+
) => {
565+
const fileString = this.memory.getObject(file) as string;
566+
const func = (...args: any[]) =>
567+
this.callHostFunction(host_func_id, line, fileString, args);
568+
const func_ref = this.memory.retain(func);
557569
return func_ref;
558570
},
559571

Sources/JavaScriptKit/FundamentalObjects/JSClosure.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ public class JSOneshotClosure: JSObject, JSClosureProtocol {
2525
// 2. Create a new JavaScript function which calls the given Swift function.
2626
hostFuncRef = JavaScriptHostFuncRef(bitPattern: ObjectIdentifier(self))
2727
_id = withExtendedLifetime(JSString(file)) { file in
28-
swjs_create_function(hostFuncRef, line, file.asInternalJSRef())
28+
swjs_create_oneshot_function(hostFuncRef, line, file.asInternalJSRef())
2929
}
3030

3131
// 3. Retain the given body in static storage by `funcRef`.

Sources/_CJavaScriptKit/include/_CJavaScriptKit.h

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,16 @@ IMPORT_JS_FUNCTION(swjs_create_function, JavaScriptObjectRef, (const JavaScriptH
278278
unsigned int line,
279279
JavaScriptObjectRef file))
280280

281+
/// Creates a oneshot JavaScript thunk function that calls Swift side closure.
282+
///
283+
/// @param host_func_id The target Swift side function called by the created thunk function.
284+
/// @param line The line where the function is created. Will be used for diagnostics
285+
/// @param file The file name where the function is created. Will be used for diagnostics
286+
/// @returns A reference to the newly-created JavaScript thunk function
287+
IMPORT_JS_FUNCTION(swjs_create_oneshot_function, JavaScriptObjectRef, (const JavaScriptHostFuncRef host_func_id,
288+
unsigned int line,
289+
JavaScriptObjectRef file))
290+
281291
/// Instantiates a new `TypedArray` object with given elements
282292
/// This is used to provide an efficient way to create `TypedArray`.
283293
///

Tests/JavaScriptKitTests/JSClosureTests.swift

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import JavaScriptKit
1+
@_spi(JSObject_id) import JavaScriptKit
22
import XCTest
33

44
class JSClosureTests: XCTestCase {
@@ -85,4 +85,69 @@ class JSClosureTests: XCTestCase {
8585
hostFunc2.release()
8686
#endif
8787
}
88+
89+
func testRegressionTestForMisDeallocation() async throws {
90+
// Use Node.js's `--expose-gc` flag to enable manual garbage collection.
91+
guard let gc = JSObject.global.gc.function else {
92+
throw XCTSkip("Missing --expose-gc flag")
93+
}
94+
95+
// Step 1: Create many JSClosure instances
96+
let obj = JSObject()
97+
var closurePointers: Set<UInt32> = []
98+
let numberOfSourceClosures = 10_000
99+
100+
do {
101+
var closures: [JSClosure] = []
102+
for i in 0..<numberOfSourceClosures {
103+
let closure = JSClosure { _ in .undefined }
104+
obj["c\(i)"] = closure.jsValue
105+
closures.append(closure)
106+
// Store
107+
closurePointers.insert(UInt32(UInt(bitPattern: Unmanaged.passUnretained(closure).toOpaque())))
108+
109+
// To avoid all JSClosures having a common address diffs, randomly allocate a new object.
110+
if Bool.random() {
111+
_ = JSObject()
112+
}
113+
}
114+
}
115+
116+
// Step 2: Create many JSObject to make JSObject.id close to Swift heap object address
117+
let minClosurePointer = closurePointers.min() ?? 0
118+
let maxClosurePointer = closurePointers.max() ?? 0
119+
while true {
120+
let obj = JSObject()
121+
if minClosurePointer == obj.id {
122+
break
123+
}
124+
}
125+
126+
// Step 3: Create JSClosure instances and find the one with JSClosure.id == &closurePointers[x]
127+
do {
128+
while true {
129+
let c = JSClosure { _ in .undefined }
130+
if closurePointers.contains(c.id) || c.id > maxClosurePointer {
131+
break
132+
}
133+
// To avoid all JSClosures having a common JSObject.id diffs, randomly allocate a new JS object.
134+
if Bool.random() {
135+
_ = JSObject()
136+
}
137+
}
138+
}
139+
140+
// Step 4: Trigger garbage collection to call the finalizer of the conflicting JSClosure instance
141+
for _ in 0..<100 {
142+
gc()
143+
// Tick the event loop to allow the garbage collector to run finalizers
144+
// registered by FinalizationRegistry.
145+
try await Task.sleep(for: .milliseconds(0))
146+
}
147+
148+
// Step 5: Verify that the JSClosure instances are still alive and can be called
149+
for i in 0..<numberOfSourceClosures {
150+
_ = obj["c\(i)"].function!()
151+
}
152+
}
88153
}

0 commit comments

Comments
 (0)