uniffi JS bindings should accept POJOs in the constructors and perform instance checking on arguments.
Categories
(Toolkit :: UniFFI Bindings, defect)
Tracking
()
Tracking | Status | |
---|---|---|
firefox124 | --- | fixed |
People
(Reporter: markh, Assigned: markh)
References
Details
Attachments
(1 file)
STR:
- comment out https://searchfox.org/mozilla-central/rev/c130c69b7b863d5e28ab9524b65c27c7a9507c48/services/sync/modules/engines/tabs.sys.mjs#78 and https://searchfox.org/mozilla-central/rev/c130c69b7b863d5e28ab9524b65c27c7a9507c48/services/sync/modules/engines/tabs.sys.mjs#418
- Run the services/sync/tests/unit/test_tab_quickwrite.js sync test.
Expected:
- should work;
inactive
is declared in tabs.udl as having a default value so should be able to be omitted everywhere a uniffi tab object is created
Actual:
Test fails with stack:
0:04.47 pid:84660 1705939863934 Sync.Engine.Tabs WARN quicksync sync failed: TypeError: remoteTabs[0].inactive: undefined(resource://gre/modules/UniFFI.sys.mjs:21:5) JS Stack trace: UniFFITypeError@UniFFI.sys.mjs:21:5
0:04.47 pid:84660 checkType@RustTabs.sys.mjs:236:19
0:04.47 pid:84660 checkType@RustTabs.sys.mjs:1046:30
0:04.47 pid:84660 checkType/<@RustTabs.sys.mjs:1321:49
0:04.47 pid:84660 checkType@RustTabs.sys.mjs:1319:15
0:04.47 pid:84660 functionCall@RustTabs.sys.mjs:743:57
0:04.47 pid:84660 setLocalTabs@RustTabs.sys.mjs:757:20
0:04.47 pid:84660 prepareTheBridge@tabs.sys.mjs:74:27
0:04.47 pid:84660 _do_main@head.js:245:6
0:04.47 pid:84660 _execute_test@head.js:596:5
0:04.47 pid:84660 @-e:1:1
which looks a little like uniffi's JS bindings ignoring default values?
Assignee | ||
Comment 1•1 year ago
•
|
||
It turns out the problem here was actually that uniffi allows a POJO - ie, we are calling, roughly:
setTab({url: "http://...", icon: "" })
instead of:
setTab(new RemoteTabRecord("http://...", ""))
The generated code works in both cases (any type with the correct property names will work) - but fails because only the constructor handles default values. IMO, it's fairly clear that the first example above should fail with a TypeError, forcing the second (right?)
BUT - this still isn't ideal when considering defaults (or even records with many fields). Consider suggest - it ends up generating:
export class RemoteSettingsConfig {
constructor(serverUrl = null,bucketName = null,collectionName) {
which is really quite useless for defaults - the only way the default values here could be leveraged would be to call it like new RemoteSettingsConfig(undefined, undefined, "the-collection")
which seems very unergonomic.
An alternative would be a breaking change where instead we generate:
export class RemoteSettingsConfig {
constructor({serverUrl = null, bucketName = null, collectionName}) {
which would mean the consumer can call new RemoteSettingsConfig({ collectionName: "the-collection" })
but the downside is that all consumers of all structs must use this object syntax when constructing the record. The upsides are that args can be specified in any order, all arguments are self-documenting (no guessing what that 5th string param is!) and default values become saner and also self-documenting.
This will be a breaking change for suggest and quite possibly tabs/sync15, but it should be easy to fix and I'm leaning towards this being the right thing to do but I'm really not sure.
Ben, Lina, thoughts?
Comment 2•1 year ago
•
|
||
An alternative would be a breaking change where instead we generate:
export class RemoteSettingsConfig { constructor({serverUrl = null, bucketName = null, collectionName}) {
which would mean the consumer can call
new RemoteSettingsConfig({ collectionName: "the-collection" })
I like this alternative, despite the downsides!
One other idea could be to lean into treating all records as POJOs in the JS bindings—so when a function takes a record as an argument, we can use a combination of destructuring and defaults to generate a signature like:
export class RemoteSettingsConfig {
constructor({serverUrl = null, bucketName = null, collectionName} = {}) {
// ...
}
}
Or:
storeTab({ title, url_history, icon, last_used, inactive = false } = {}) {
// Check the types of `title`, `url_history`, `icon`, etc. here.
}
This might complicate lowering, and wouldn't play nicely with giving structs methods, but I think it could work for solving the defaults problem, and would also be more consistent with how dictionaries work in WebIDL.
WDYT?
Assignee | ||
Comment 3•1 year ago
|
||
This might complicate lowering, and wouldn't play nicely with giving structs methods, but I think it could work for solving the defaults problem, and would also be more consistent with how dictionaries work in WebIDL.
Would this work for recursive types? The actual function takes an array of tabs. Optional/nullable params would also be a challenge?
Assignee | ||
Comment 4•1 year ago
|
||
lol - see also https://github.com/mozilla/application-services/commit/b324e64a3d80521a824793fcce04252f881a83df#diff-2b8b87dbc5a7d1bcc0eb3fd4d9adbc6dc24bee4243409d161ceef341e5b45df6 - the order of the fields in the UDL changed, so JS will be broken on the next vendor anyway.
Comment 5•1 year ago
|
||
(In reply to Lina Butler [:lina] from comment #2)
An alternative would be a breaking change where instead we generate:
export class RemoteSettingsConfig { constructor({serverUrl = null, bucketName = null, collectionName}) {
which would mean the consumer can call
new RemoteSettingsConfig({ collectionName: "the-collection" })
I like this alternative, despite the downsides!
Also +1 from me.
One other idea could be to lean into treating all records as POJOs in the JS bindings—so when a function takes a record as an argument, we can use a combination of destructuring and defaults to generate a signature like:
export class RemoteSettingsConfig { constructor({serverUrl = null, bucketName = null, collectionName} = {}) { // ... } }
This also seems nice. I think that optional/nullable could be dealt with, since JS differentiates undefined
vs null
.
It kind of seems like a choice between strong typing and structural/duct typing. This makes sense because modern JS exists somewhere in the middle of the two. I personally could get behind either option.
Comment 6•1 year ago
•
|
||
:markh and I chatted a bit more on Slack about this, and I wanted to capture the context here!
Would this work for recursive types? The actual function takes an array of tabs.
I think we could still use POJOs for records in recursive types! In JS, the default value of a function argument is used if the caller omits the argument entirely, or if it explicitly passes undefined
. We could follow that same definition for passing records from JS to Rust: if a record field is omitted or explicitly set to undefined
, and has a default, then Rust will receive the default field.
Our JS bindings generate a handful of classes for records: (1), a class for the record itself (RemoteTabRecord
), (2) an FfiConverter
class for the type (FfiConverterTypeRemoteTabRecord
), and (3) various FfiConverter
specializations for any composite types (FfiConverter{Map, Sequence, Optional}...
; here's FfiConverterSequenceTypeRemoteTabRecord
) that contain the record.
To use POJOs for records, I'm suggesting we could nix (1), but keep (2) and (3). So, for example, FfiConverterTypeRemoteTabRecord
would now look like:
export class FfiConverterTypeRemoteTabRecord extends FfiConverterArrayBuffer {
static read(dataStream) {
return {
title: FfiConverterString.read(dataStream),
urlHistory: FfiConverterSequencestring.read(dataStream),
icon: FfiConverterOptionalstring.read(dataStream),
lastUsed: FfiConverterI64.read(dataStream),
inactive: FfiConverterBool.read(dataStream),
};
}
static write(dataStream, value) {
try {
FfiConverterString.checkType(value.title);
} catch (e) {
// ...
}
// ...
let inactive;
if (typeof value.inactive == "undefined") {
inactive = false;
} else {
try {
FfiConverterBool.checkType(value.inactive);
} catch (e) {
// ...
}
inactive = value.inactive;
}
// ...
FfiConverterString.write(dataStream, value.title);
// ...
FfiConverterBool.write(dataStream, inactive);
}
// ...
}
Since the specialized FfiConverter
s in (3) all delegate to (2), I think this could all work as expected. This would also work for bare record arguments (that is, a UniFFI function or method that takes a record directly, instead of a sequence, optional, or map containing a record); in this case, we'd use the type's FfiConverter
(2) directly, instead of the composite converter (3).
:markh did point out that passing POJOs around makes it harder to see the shape of the record, and which fields are required and which are defaulted, without looking at the code. It also leans into the duck typed nature of JS, which might not be the direction we want.
On the other hand, I wonder if we could make the case that this is "just" how JS works, and we're being more consistent by blessing POJOs as the way to pass records from JS to Rust. It might be worth pointing out that we can already pass POJOs, and they just happen to work for the most part...except when you want a default. Finally, there could be a nice migration path here to ECMAScript (Edit: I spoke too soon 😬 ECMAScript record types are deeply immutable, and can't store objects—we wouldn't be able to use them for UniFFI records, because a UniFFI record can contain an object / interface).Record
types (bug 1658309)
Comment 7•1 year ago
|
||
Oops, I was writing my comment at the same time you replied, :bdk! 😂
I really like your final point:
It kind of seems like a choice between strong typing and structural/duct typing. This makes sense because modern JS exists somewhere in the middle of the two. I personally could get behind either option.
I know I just wrote a super long comment going into the details of how we could make duck typing work, but I'm not at all tied to that approach! Keeping the record class (1), and changing it to take an object of options like :markh suggested in comment 1, would also sound great to me.
Assignee | ||
Comment 8•1 year ago
|
||
Like Ben, I think I could get behind either option. How do we make a decision? :) It sounds like Lina is leaning towards POJO - is that correct? If so, I'd be fine with considering that the "deciding" vote and just do it?
ISTM it would be great to do this sooner rather than later, just to avoid suggest needing to take that breaking change I mentioned above on the next vendor. WDYT?
Assignee | ||
Comment 9•1 year ago
|
||
OTOH, going the pojo route probably closes the door on this, whereas the short term of sticking with explicit objects leaves POJOs on the table for the future. In that regard, we should probably consider the POJO route riskier?
Comment 10•1 year ago
|
||
How do we make a decision? :) It sounds like Lina is leaning towards POJO - is that correct? If so, I'd be fine with considering that the "deciding" vote and just do it?
Yes—let's do POJOs!
Assignee | ||
Comment 11•1 year ago
|
||
I've got some hacks already in my tree, so let me see what I can come up with stealing from Lina's sketch
Assignee | ||
Comment 12•1 year ago
|
||
Assignee | ||
Comment 13•1 year ago
|
||
FWIW, after thinking about this some more, Ben, Lina and I agreed to move away from "POJOs everywhere" back to "constructors take POJOs" and to add instanceof
checks when records are used. This is slightly more verbose and not quite as pretty, but does offer better error checking in the face of confusion about param orders etc.
Updated•1 year ago
|
Assignee | ||
Updated•1 year ago
|
Comment 14•1 year ago
|
||
Comment 15•1 year ago
|
||
Backed out for causing quicksuggest failures.
- backout: https://hg.mozilla.org/integration/autoland/rev/0cb1cb869761baae745081466c878e32a7d83888
- push: https://treeherder.mozilla.org/jobs?repo=autoland&group_state=expanded&revision=ef78defe4be28b2a2132131c89ce251d13dc3c75
- failure log:
- TEST-UNEXPECTED-FAIL | browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_dynamicWikipedia.js | xpcshell return code: 0
- TEST-UNEXPECTED-FAIL | browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest.js | Uncaught exception in setup bound - at resource:///modules/urlbar/private/SuggestBackendRust.sys.mjs:174 - SyntaxError: missing ) after argument list
- TEST-UNEXPECTED-FAIL | browser/base/content/test/static/browser_parsable_script.js | Script error reading jar:file:///Z:/task_170735171785657/build/application/firefox/browser/omni.ja!/modules/urlbar/private/SuggestBackendRust.sys.mjs: SyntaxError: missing ) after argument list -
- TEST-UNEXPECTED-ERROR | /builds/worker/checkouts/gecko/browser/components/urlbar/private/SuggestBackendRust.sys.mjs:174:27 | Parsing error: Unexpected token : (eslint)
Assignee | ||
Comment 16•1 year ago
|
||
Sorry about that - new try at https://treeherder.mozilla.org/jobs?repo=try&revision=ff8faa83910a972fa99ac8ed3978ff4d9f4d76b5&selectedTaskRun=LX14ZfnZQ2m2fTxw3-tgIg.0
Comment 17•1 year ago
|
||
Comment 18•1 year ago
|
||
bugherder |
Description
•