Skip to content
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

Introduce TypeList, always export Dependencies as well #221

Merged
merged 8 commits into from
Feb 1, 2024

Conversation

NyxCode
Copy link
Collaborator

@NyxCode NyxCode commented Feb 1, 2024

No description provided.

@NyxCode NyxCode changed the base branch from main to e2e February 1, 2024 14:51
@NyxCode
Copy link
Collaborator Author

NyxCode commented Feb 1, 2024

@escritorio-gustavo Here's a proof of concept of the TypeList.
Most notably, I've only added a new method TS::dependency_types() -> impl TypeList here, but I haven't touched the old TS::dependencies() -> Vec<Dependency> yet. I want to run a few tests first, and see if the output of dependency_types() is equivalent to the old dependencies()

ts-rs/src/lib.rs Outdated Show resolved Hide resolved
@NyxCode
Copy link
Collaborator Author

NyxCode commented Feb 1, 2024

Just to illustrate what this would enable:

fn export_dependencies<T: TS>() {
    struct Exporter;
    impl Visitor for Exporter {
        fn visit<T: TS>(&mut self) {
            T::export();
        }
    }

    T::dependency_types().for_each(&mut Exporter);
}

@escritorio-gustavo
Copy link
Contributor

fn export_dependencies<T: TS>() {
   struct Exporter;
   impl Visitor for Exporter {
       fn visit<T: TS>(&mut self) {
           T::export();
       }
   }

   T::dependency_types().for_each(&mut Exporter);
}

This looks awesome! Absolutely love it!
I'm heading out right now for the next 2 hours, but when I get back I'll pull this branch to get a better read

@NyxCode
Copy link
Collaborator Author

NyxCode commented Feb 1, 2024

I am very suprised that that's the case, but it seems that dependencies() already gives the same output as dependency_types().
I was sure I fucked something up somewhere, but it seems like I didnt..

I ran that check over most of the test suite, and added a few contrived tests as well, but I cant manage to break it.

ts-rs/src/lib.rs Outdated
Comment on lines 299 to 314
fn dependencies() -> Vec<Dependency> where Self: 'static {
use crate::typelist::TypeVisitor;

let mut deps: Vec<Dependency> = vec![];
struct Visit<'a>(&'a mut Vec<Dependency>);
impl<'a> TypeVisitor for Visit<'a> {
fn visit<T: TS + 'static + ?Sized>(&mut self) {
if let Some(dep) = Dependency::from_ty::<T>() {
self.0.push(dep);
}
}
}
Self::dependency_types().for_each(&mut Visit(&mut deps));

/// `true` if this is a transparent type, e.g tuples or a list.
deps
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I now completely removed the generation for TS::dependencies() from the macro crate.
I kept the dependencies() function here for now though, with a default implementation (that's not overwritten anywhere), which converts the new TypeList into the old Vec<Dependency>.

Comment on lines 23 to 26
impl TypeList for () {
fn contains<C: Sized>(self) -> bool { false }
fn for_each(self, _: &mut impl TypeVisitor) {}
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

An empty TypeList is just (). That's why TS::dependency_types() -> impl TypeList can have a default implementation with an empty body, since that evaluates to ()

Copy link
Contributor

Choose a reason for hiding this comment

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

Nice!

Comment on lines 28 to 36
impl<T> TypeList for (PhantomData<T>,) where T: TS + 'static + ?Sized {
fn contains<C: Sized + 'static>(self) -> bool {
TypeId::of::<C>() == TypeId::of::<T>()
}

fn for_each(self, v: &mut impl TypeVisitor) {
v.visit::<T>();
}
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

A TypeList with just one element is represented by the tuple (PhantomData<T>,).

Comment on lines 38 to 47
impl<A, B> TypeList for (A, B) where A: TypeList, B: TypeList {
fn contains<C: Sized + 'static>(self) -> bool {
self.0.contains::<C>() || self.1.contains::<C>()
}

fn for_each(self, v: &mut impl TypeVisitor) {
self.0.for_each(v);
self.1.for_each(v);
}
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Finally, here is where the induction / recursion is happening:
For any two TypeLists A and B, (A, B) is a TypeList as well.

Copy link
Contributor

Choose a reason for hiding this comment

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

That's honestly the most clever use of the type system I've seen! Especially given how Rust usually doesn't like recursive data structures

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

😆 I think I've done much more insane things. For example, you can do real arithmetic in in the type system alone

Copy link
Contributor

Choose a reason for hiding this comment

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

Comment on lines 32 to 38
/// Exports `T` to the file specified by the `#[ts(export_to = ..)]` attribute.
/// Additionally, all dependencies of `T` will be exported as well.
/// TODO: This might cause a race condition:
/// If two types `A` and `B` are `#[ts(export)]` and depend on type `C`,
/// then both tests for exporting `A` and `B` will try to write `C` to `C.ts`.
/// Since rust, by default, executes tests in paralell, this might cause `C.ts` to be corrupted.
pub(crate) fn export_type_with_dependencies<T: TS + ?Sized + 'static>() -> Result<(), ExportError> {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I have now added this function, which exports T and recursively all of its dependencies.

The next step here should probably be to actually cause a race condition, and then figure out how to fix it (maybe by putting a static MUTEX: Mutex<()> in TS? Or run the tests with RUST_TEST_THREADS=1 to disable paralell execution?)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Oh yeah - that fixed both E2E tests ^^

Copy link
Contributor

Choose a reason for hiding this comment

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

Oh yeah that's gonna be a hard one to handle! Especially since the compiler, which usually protects us from race conditions, can't do much for us here in test land

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Interestingly enough, I cant get it to fail, even with 16 types.
I think one easy solution might be to first write to a temp file, and then rename it. Gotta look into that, but I think renaming is atomic on all platforms.

Copy link
Contributor

Choose a reason for hiding this comment

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

Oh yeah - that fixed both E2E tests ^^

What fixed it? The mutex or the RUST_TEST_THREADS?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Nothing! I just tried to provoke the error, by exporting lots of types and setting RUST_TEST_THREADS=16, but nothing broke. Maybe that's because the files are relatively small, and get written in just one syscall. I'll try exporting larger types, maybe that'll break it.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yeah, idk. I made the types now 17MB large with 2 million numbers in them. cargo test still refuses to corrupt them..

@escritorio-gustavo
Copy link
Contributor

I am very suprised that that's the case, but it seems that dependencies() already gives the same output as dependency_types().
I was sure I fucked something up somewhere, but it seems like I didnt..

I ran that check over most of the test suite, and added a few contrived tests as well, but I cant manage to break it.

I too refuse to believe my code is ever working first time xD

@escritorio-gustavo
Copy link
Contributor

It didn't fail CI! Is this somehow just thread safe? lol

@NyxCode
Copy link
Collaborator Author

NyxCode commented Feb 1, 2024

It didn't fail CI! Is this somehow just thread safe? lol

Really curious about this as well. My theory is the following

  • Thread 1 creates the file (File::create) and starts writing to it
  • Thread 2 creates the file (File::create), but to the OS, it's a completely different inode.
  • Thread 1 continues to write to the old file handle, not disturbing thread 2

It's hard to find a definite resource on this.

@escritorio-gustavo
Copy link
Contributor

It's hard to find a definite resource on this.

Yeah, I'm trying to read on std::fs::write but I can't find anything in a similar situation, where the threads aren't coordinated by a single centralized place

@NyxCode
Copy link
Collaborator Author

NyxCode commented Feb 1, 2024

An easy fix for the concurrent write issue would be to change export_type_to:

pub(crate) fn export_type_to<T: TS + ?Sized + 'static, P: AsRef<Path>>(
    path: P,
) -> Result<(), ExportError> {
    static FILE_LOCK: Mutex<()> = Mutex::new(());
    
    // ...

    let lock = FILE_LOCK.lock().unwrap();
    std::fs::write(path.as_ref(), buffer)?;
    drop(lock);

    Ok(())
}

That lock would be shared by every file export, so no two files would be written concurrently ever.

@NyxCode
Copy link
Collaborator Author

NyxCode commented Feb 1, 2024

Though that issue is purely theoretical right now, so we could just do it & see if any1 encounters an issue - idk.

@escritorio-gustavo
Copy link
Contributor

An easy fix for the concurrent write issue would be to change export_type_to:

pub(crate) fn export_type_to<T: TS + ?Sized + 'static, P: AsRef<Path>>(
    path: P,
) -> Result<(), ExportError> {
    static FILE_LOCK: Mutex<()> = Mutex::new(());
    
    // ...

    let lock = FILE_LOCK.lock().unwrap();
    std::fs::write(path.as_ref(), buffer)?;
    drop(lock);

    Ok(())
}

That lock would be shared by every file export, so no two files would be written concurrently ever.

Wait, this works? I always thought the mutex had to be shared between threads (hence the whole Arc<Mutex<T>> thing people like to talk about).

@escritorio-gustavo
Copy link
Contributor

Or am I just missing something?
As far as I can tell every thread is gonna run export_type_to on its own right? So each thread will have its own Mutex, wihch won't be shared

@NyxCode
Copy link
Collaborator Author

NyxCode commented Feb 1, 2024

Or am I just missing something? As far as I can tell every thread is gonna run export_type_to on its own right? So each thread will have its own Mutex, wihch won't be shared

No, I don't think that's right. Because it's static, there is only one mutex somewhere in memory.
Because that is the case, you could do

static SOMETHING: u32 = 125;
let reference: &'static u32 = &SOMETHING;

The reference can be static because the static lives forever.

Now, I had to look that up, but that the function export_type_to is generic doesn't change that:

A static item defined in a generic scope (for example in a blanket or default implementation) will result in exactly one static item being defined, as if the static definition was pulled out of the current scope into the module. There will not be one item per monomorphization.

@NyxCode
Copy link
Collaborator Author

NyxCode commented Feb 1, 2024

You often see Arc<Mutex> because the Mutex does not live forever normally, when not in a static. So the Arc is there to reference-count and clean the mutex when it's no longer used.
Besides using static, an other way to get rid of the Arc is by leaking it:

let x: &'static Mutex<()> = Box::leak(Box::new(Mutex::new(())));

Then, just like the static, you've got a &'static reference to it, which you can pass to every thread you want.

@escritorio-gustavo
Copy link
Contributor

No, I don't think that's right. Because it's static, there is only one mutex somewhere in memory.

Oh now I get it! And this is definetly correct, I set up a small test to check with a function that sleeps and 20 tests calling it with and without the Mutex and also switching static with let

With static the tests really do wait for each other

@escritorio-gustavo
Copy link
Contributor

In this test I also created a file with 20 paragraphs of lorem ipsum. Even without the Mutex I couldn't cause it to corrupt the file either

@escritorio-gustavo
Copy link
Contributor

escritorio-gustavo commented Feb 1, 2024

Okay, I've got the following:

#[cfg(test)]
fn do_it() {
    std::fs::write(PATH, CONTENT).unwrap();
}
#[test]
fn write_1() {
    for i in 0..10 {std::thread::spawn(|| do_it());}
}

#[test]
fn write_2() {
    for i in 0..10 {std::thread::spawn(|| do_it());}
}

#[test]
fn write_3() {
    for i in 0..10 {std::thread::spawn(|| do_it());}
}

// ...

#[test]
fn write_20() {
    for i in 0..10 {std::thread::spawn(|| do_it());}
}

const PATH: &str = "./foo.txt";
const CONTENT: &str = "VERY LONG STRING";

@escritorio-gustavo
Copy link
Contributor

This is producing an empty file

@NyxCode
Copy link
Collaborator Author

NyxCode commented Feb 1, 2024

Alright, awesome! That makes me feel less bad putting a Mutex in there!
Though I think it's remarkable that it took 200 threads to get it to break.

@escritorio-gustavo
Copy link
Contributor

Hold up, the way I did it there breaks, but when I join the threads, it's fine

#[test]
fn write_1() {
    let mut v = vec![];
    for i in 0..10 {
        v.push(std::thread::spawn(|| do_it()));
    }
    for i in v {
        i.join();
    }
}

@escritorio-gustavo
Copy link
Contributor

Sitll even if std::fs::write happens to be thread safe when writing the same data to the same file, that's not explicitly documented to be the case and we probably shouldn't rely on it

@@ -2,5 +2,5 @@ use ts_rs::TS;

#[derive(TS)]
pub struct Crate1 {
pub x: i32
pub x: [[[i32; 128]; 128]; 128],
Copy link
Contributor

@escritorio-gustavo escritorio-gustavo Feb 1, 2024

Choose a reason for hiding this comment

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

I like how the fact that this generates stupidly large TS types ended up being useful lol

@NyxCode NyxCode changed the title [Proof of Concept] Typelist Introduce TypeList, always export Dependencies as well Feb 1, 2024
@NyxCode NyxCode marked this pull request as ready for review February 1, 2024 19:51
@NyxCode NyxCode linked an issue Feb 1, 2024 that may be closed by this pull request
@NyxCode
Copy link
Collaborator Author

NyxCode commented Feb 1, 2024

From my side this is ready for merge!
I'd like to aditionally expand the E2E tests a bit, but i'll do that in an other PR.
So maybe you could take a last look, and if everything's alright merge this.

Again, huge thanks for exploring the problem space with me.
It's actually fun maintaining OS stuff if there's some1 to do it with ❤️

@escritorio-gustavo
Copy link
Contributor

It really is! I love to have someone to about this kinda stuff with!

@escritorio-gustavo
Copy link
Contributor

escritorio-gustavo commented Feb 1, 2024

I think it's ready for merging as well, though we have to remember to also merge #218 after this, as this is going to the e2e branch

@NyxCode
Copy link
Collaborator Author

NyxCode commented Feb 1, 2024

Alright! A bit akward, but whatever ^^

@escritorio-gustavo escritorio-gustavo merged commit 8eb28e5 into e2e Feb 1, 2024
8 checks passed
@escritorio-gustavo escritorio-gustavo deleted the typelist branch February 1, 2024 20:01
@NyxCode
Copy link
Collaborator Author

NyxCode commented Feb 3, 2024

Something which I completely forgot to pay attention to are self-referential structs!
I've ran some tests (7.1.1 vs master), and this is how the behavious changed:

this: Box<Self> on 7.1.1 on master
as-is
#[ts(flatten)] ❌ (runtime)
stack overflow
❌ (compiler)
"overflow evaluating the requirement"
#[ts(inline)] ❌ (runtime)
stack overflow
❌ (compiler)
"overflow evaluating the requirement"
#[ts(type = "any", flatten)] ❌ (macro)
"... is not compatible ..."
❌ (macro)
"... is not compatible ..."
#[ts(type = "any", inline)] ⚠️
works, but shouldn't
⚠️
works, but shouldn't

So what failed before with a stack overflow now fails at compile time - That's pretty cool, i think!

error[E0275]: overflow evaluating the requirement `<T as TS>::{opaque#0} == _`

error: aborting due to previous error

For more information about this error, try `rustc --explain E0275`.
error: could not compile `ts-rs` (test "self_referential") due to 2 previous errors

Also, I realized that both 7.1.1 and master accept #[ts(type = "..", inline)] - As far as I can tell, inline never does anything when used together with type, right? So maybe we should emit an error for this, like we do for #[ts(type = "..", flatten)]

@NyxCode
Copy link
Collaborator Author

NyxCode commented Feb 3, 2024

Found something interesting:

#[derive(TS)]
struct X<T> {
    a: T,
    b: Vec<X<(i32, T)>>
}

fails to compile with

thread 'rustc' has overflowed its stack
error: could not compile `ts-rs` (test "generics")

Tested both on 7.1.1 and master, so this is not a regression we introduces (recently). Might be interesting to find out why it's happening though. In a perfect world, it should compile and product

type X<T> = {
  a: T,
  b: Array<X<[number, T]>>,
} 

Will open an issue to keep track of this.

@escritorio-gustavo
Copy link
Contributor

So what failed before with a stack overflow now fails at compile time - That's pretty cool, i think!

Agreed, if something is not gonna work it's far better to know about it at compile time

Also, I realized that both 7.1.1 and master accept #[ts(type = "..", inline)] - As far as I can tell, inline never does anything when used together with type, right? So maybe we should emit an error for this, like we do for #[ts(type = "..", flatten)]

Yeah, #[ts(type = "...", inline)] doesn't really make sense, we should emit an error for it

Comment on lines +102 to +106
// Lock to make sure only one file will be written at a time.
// In the future, it might make sense to replace this with something more clever to only prevent
// two threads from writing the **same** file concurrently.
static FILE_LOCK: Mutex<()> = Mutex::new(());

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@escritorio-gustavo Regarding the lock we introduced here:

I've experimented with replacing this with a static EXPORTED: Mutex<Option<HashSet<PathBuf>>> to track which files were written, and to skip an export if the file was already written to.

The case in our test suite where this is most helpfull is generics.rs, where 30 distinct types are being exported while 37 unnecesarry exports are being skipped.

That being said, I don't think we actually need to do that. generics.rs ran in 50ms before, and runs in 32ms after this (though with much higher variance). That seems plenty fast.

Copy link
Contributor

Choose a reason for hiding this comment

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

It definitely seems fast enough as is, but if we can reduce the amount of fs writes without any negative impact, I think it's a good idea

Copy link
Contributor

@escritorio-gustavo escritorio-gustavo Apr 12, 2024

Choose a reason for hiding this comment

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

Maybe push that into #295 as a part of the test performance improvements? That PR is more focused on improving build time, but improving runtime is also great!

@escritorio-gustavo escritorio-gustavo mentioned this pull request Apr 12, 2024
12 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

generating invalid TypeScript import path with using Cargo workspace
2 participants