How to contribute to a project you have no idea about

Sat Feb 25 • 7 min read
Discuss
hn
reddit
twitter
Share
reddit
twitter

Bun

Recently I got really excited about Bun. It’s a new JavaScript / TypeScript runtime similar to Deno / Node. It has one advantage over other runtimes that Is very interesting for me. It’s the super quick (at least in JS world) startup time. When I first launched a small piece of code using it, I just couldn’t believe it.

When I moved from Ruby to Node I was just boggled about the fact that tests run soooooooooooo slow in Node. Writing the same business logic and testing it in both of those languages is a completely different experience. No wonder JS community hates unit testing when you have to think if you i.e. spread your tests across multiple files or not.

There’s a reason for that though. No matter how much you optimize test runners, like Vitest, Jest or Ava, your first test run (without watch) will always run extremely slow in Node because V8 startup and module resolution take ages. When you spread work across multiple processes to use all cores it takes even more resources!

runs 266 tests that SSR with react-dom 40% faster than jest can print its version number

tweet image

So if you want to have a fast test suite, you have to decrease the startup time of your VM and speed up module resolution. Also, you can optimize even more by implementing the runner in a language that is much faster than JS, which is exactly what Bun is doing under the hood.

Case

I’ve decided to give Bun a go and set up a real’ish benchmark project for it’s test framework. How fast a test suite with 400 e2e isolated http tests with database access would run.

After 3 lines of writing code and running the suite I’ve stumbled upon a problem. I tried to pass 0 as the parameter to Bun.serve to dynamically assign a port for the created http server and… I quickly realized why bun is still in beta.

// benchmark/request.spec.ts
test('e2e bun serve test', () => {
  const server = Bun.serve({ port: 0 });
  // ...request to API etc.
  server.stop();
})
$ bun wiptest
error: Uncaught (in promise)
  TypeError: Invalid port: must be > 0

The Node compatibility layer is not quite yet… compatible 😄. But rather than just drop the idea I’ve decided to get it working!

The recipe

Below I’m going to guide you through the process I went with while contributing to Bun, and how I approach any project I don’t know. And that’s most of them 😄. Hopefully it will help you gain some courage to approach new programming challenges.

0. Ask about it

If you have a possibility to ask previous contributors or maintainers about the feature / problem you’re trying to solve, do it. It’s always better to ask than to waste time on something that’s not needed / impossible.

I just went to GitHub issues and searched for "port 0" aaaand I found it. My exact problem is documented there for some time and no one tackled it yet. Maintainers didn’t mark it as unneeded, impossible etc. They just didn’t have time to implement it. Because of that I decided to give it a go.

1. Spec the feature / problem

It’s extremely important to try and narrow down the problem as much as possible. Most of us have been in a situation where we receive requests to implement something without much specification or explanation. It’s hard to work on something you don’t know how to name.

In my case it was pretty simple “Bun does not allow to pass 0 as the port to listen on. It throws an error when it shouldn’t.”. Now that I have the problem defined, I can start working on it.

2. Run the tests

Next step is always the same and it’s crucial. Documentation gets old, tickets / issues get stuck. There is nothing in the codebase as reliable as tests. If you want to know how / why / if something works, run the tests. If an app has no tests then you have to write them. That’s also why contributing to OSS with a solid test suite is much easier than to those without one. Imagine, you can asynchronously, without having to wait for a review, check if the code you changed / wrote works.

I’ve set up the dev environment according to instruction in Bun repo. And ran the test suite. Green!

If everything passes then you’re good to go. If certain tests don’t pass check if they “always go red”, because certain codebases have some well known flaky tests that are there.

# run all tests inside bun repository
$ bun run test
567 tests passed, 0 tests failed

3. Write the test

In point 0 I spec’ed our problem. Now I can write an automated spec. Turns out that’s where example.spec.ts comes from. To do that, I usually take another spec that is already there, remove everything that is not boilerplate and add logic that I want to test.

// bun/test/bun.js/bun-server.spec.ts
import { expect, test } from "bun:test";

test("Server initializes with 0 port", () => {
  const server = Bun.serve({
    fetch: () => new Response("Hello"),
    port: 0,
  });

  expect(server).toBeDefined();
  server.stop();
});
$ bun run test
567 tests passed, 1 test failed
  bun/test/bun.js/bun-server.spec.ts
  ✖ Server initializes with 0 port
  Error: Uncaught (in promise)
    TypeError: Invalid port: must be > 0

The new spec HAS TO FAIL. If it passes from the beginning then you’re doing something wrong or the bug you’re trying to fix does not exist.

From now on all of our work will be about making it pass.

4. Find and change the code

Where to search? Just do a full text search of the error that is happening or go through the stack trace.

I’ve searched for the error message I was getting and voilà, got it. The code that throws the error.

// bun/src/bun.js/api/server.zig
if (args.port == 0) {
    JSC.throwInvalidArguments(
      "Invalid port: must be > 0", .{}, global, exception
    );
}

Why is it there? No documentation left + no issues on GH. So I just removed it aaaaand the tests pass! wow. That was easy. too easy…

If we think about it for a second, the fact that I removed a line of code and all tests passed, including our new one, means that this code was not tested previously.

3 part 2. Write another test - Break the solution

If it was untested than it’s our responsibility when suggesting a change to it, to test it. So that in future when someone wants to modify it they can do it without unknown consequences (like I had to look out for). How do you do that?

You break it! Write another test and try to send a http request to this server with dynamic port, to see if it responds correctly. Aaaand it doesn’t.

// bun/test/bun.js/bun-server.spec.ts
// ...our previous test

test("Server allows connecting to server", async () => {
  const server = Bun.serve({
    fetch: () => new Response("Hello"),
    port: 0,
  });

  const response = await fetch(
    `http://localhost:${server.port}`
  );
  expect(await response.text()).toBe("Hello");
  server.stop();
});
$ bun run test
568 tests passed, 1 test failed
  bun/test/bun.js/bun-server.spec.ts
  ✖ Server allows connecting to server
    Error: Unable to connect to server at http://localhost:0

The error is different. When you retrieve the port it returns 0 rather than the port assigned by OS.

I’m sure you can guess the next step - Make the test pass.

4 part 2. Back to changing code

The port is not dynamically retrieved from the server instance, but rather it’s just what I passed to it.

// bun/src/bun.js/api/server.zig
pub fn getPort(this: *ThisServer) JSC.JSValue {
    return JSC.JSValue.jsNumber(this.config.port);
}

I need to change it to retrieve the port dynamically from OS.

Definitely a harder task, writing code in Zig, a language that is completely unknown to me. So I split the task again. Rather than trying to solve our problem of sending the real port, I decide to send ANYTHING from Zig to our js world.

When you stumble upon a problem, but it’s a small one, and you already have an automated test for it, than it’s just a matter of time before you figure it out. The test is there to shorten the feedback loop. It will take you a second to check if you did something correctly.

I’ll first return a static port, though a different one than 0 and see if my test is failing in a way that I want it to fail (this is also feedback). When the server is not running I return the config port, otherwise I return my mock port.

// bun/src/bun.js/api/server.zig
pub fn getPort(this: *ThisServer) JSC.JSValue {
    if (this.config.port == 0) {
        return JSC.JSValue.jsNumber(1234);
    }
    return JSC.JSValue.jsNumber(this.config.port);
}
$ bun run test
568 tests passed, 1 test failed
  bun/test/bun.js/bun-server.spec.ts
  ✖ Server allows connecting to server
    Error: Unable to connect to server at http://localhost:1234

And it fails successfully! This is excellent because it assures me that I’m changing the right things. Only thing left to do is to find out how can I retrieve the port from process.

I’ve looked through code and searched if anywhere the socket exposed any value. Turns out there were a few places where it was done i.e. when the socket was closing. Few copies and pastes later, compiler errors and changing my mocked solution to the new one, and I’m done. The tests pass.

// bun/src/bun.js/api/server.zig
pub fn getPort(this: *ThisServer) JSC.JSValue {
  return JSC.JSValue.jsNumber(this.config.port);
  var listener = this.listener orelse return JSC.JSValue.jsNumber(this.config.port);
  return JSC.JSValue.jsNumber(listener.getLocalPort());
}

// bun/src/deps/uws.zig
pub inline fn getLocalPort(this: *ThisApp.ListenSocket) i32 {
  if (comptime is_bindgen) {
    unreachable;
  }
  return us_socket_local_port(ssl_flag, @ptrCast(*uws.Socket, this));
}
$ bun run test
569 tests passed, 0 tests failed

PR, CR, merge, release. Done.

PR to bun merged
PR to bun merged

Summary

To contribute to any project you don’t need to know all of its ins and outs. Moreover, you will never know everything about any project unless you write every line of code in it. Even then you will forget it.

You have to be confident in the work of people creating the project, and in the test suite. If you are, then you don’t even need to know if you’re doing everything ok. The tests will fail, or CR will reject your code. And that’s ok.

Flow is simple:

  1. Run the tests - get them green
  2. Write a test that fails
  3. Change the code
  4. Think if you need to add another test
  5. If you found a test to write then go back to 1. If you can’t think of a test you’re done.

If you’re a maintainer, prioritize setting up an easy, solid test suite. Otherwise, you’re doomed with the burden of writing extremely complicated docs explaining how to write a line of code in your project without breaking it. The entry barrier is much, much lower in a project with a test suite. And that’s why I’m always trying to convince people to write tests, especially in OSS. It’s not only about the code quality, it’s also about the community. The ease to join the project.

Subscribe to my newsletter to get notified whenever I publish new articles (or use RSS)

no spam, just information about new articles, unsubscribe whenever you want

If you liked the post you might also want to read:
← Power of Many: Using Multiple LLMs in a Single Chat Interface

Discuss
hn
reddit
twitter
Share
reddit
twitter
Michal Warda
Michal Warda • Buildelprogle()HOAI @ EL Passion Hopefully you'll find something useful here