Skip to content

Optimize bin by replacing node_modules/.bin/tsgo with a symlink#4211

Draft
jakebailey wants to merge 5 commits into
mainfrom
jabaile/optimize-bin
Draft

Optimize bin by replacing node_modules/.bin/tsgo with a symlink#4211
jakebailey wants to merge 5 commits into
mainfrom
jabaile/optimize-bin

Conversation

@jakebailey
Copy link
Copy Markdown
Member

@jakebailey jakebailey commented Jun 5, 2026

Bun uses a trick to replace package manager bin shims with a symlink to the actual binary.

This can greatly improve startup performance, at a terrible cost: a postinstall script.

I tried this for fun and it works, but, I don't 100% know if it's a good idea.

Would close #2836.

@RyanCavanaugh RyanCavanaugh added this to the Possible Improvement milestone Jun 5, 2026
@JoostK
Copy link
Copy Markdown

JoostK commented Jun 5, 2026

at a terrible cost: a postinstall script.

would the tsgo Node wrapper be able to attempt this trick as it is being executed, after it has spawned the native binary?

@jakebailey
Copy link
Copy Markdown
Member Author

jakebailey commented Jun 5, 2026

Theoretically yes, however, it won't work on Windows I don't think (executables cannot be deleted while running), and IIRC package managers don't really expect that packages modify themselves after install for caching reasons?

But, it's possible. Maybe since it's Node running the script, it's mutable on Windows. And maybe modifying it after the fact into a symlink is okay? (I am trying to remember if dprint does this...)

@jakebailey
Copy link
Copy Markdown
Member Author

dprint modifies at first run, so that may be possible! https://app.unpkg.com/dprint@0.54.0/files/bin.cjs

@JoostK
Copy link
Copy Markdown

JoostK commented Jun 5, 2026

I don't see sprint doing that there.

Perhaps any open file handles can be avoided by spawning another process, then letting the wrapper die.

Another pitfall is how this would block the use of execve, unless it is attempted earlier but that would hurt startup latency consistently in case the replacement doesn't succeed... not great.

@jakebailey
Copy link
Copy Markdown
Member Author

It does:

    const resolvedExePath = require("./install_api.cjs").runInstall();
    runDprintExe(resolvedExePath);

That being said, I seem to recall some goofiness here on Windows too where dprint had to do some tmp stuff, but I am not seeing it.

Another pitfall is how this would block the use of execve, unless it is attempted earlier but that would hurt startup latency consistently in case the replacement doesn't succeed... not great.

You mean as in users who want to directly exec /.bin/tsgo? I don't think that matters, as even our JS has a shebang, so it's going to work, swapped or not.

@JoostK
Copy link
Copy Markdown

JoostK commented Jun 5, 2026

It does:

    const resolvedExePath = require("./install_api.cjs").runInstall();

    runDprintExe(resolvedExePath);

Ah yes, missed that as I'm on mobile and didn't look past the require.

That being said, I seem to recall some goofiness here on Windows too where dprint had to do some tmp stuff, but I am not seeing it.

Another pitfall is how this would block the use of execve, unless it is attempted earlier but that would hurt startup latency consistently in case the replacement doesn't succeed... not great.

You mean as in users who want to directly exec /.bin/tsgo? I don't think that matters, as even our JS has a shebang, so it's going to work, swapped or not.

I meant the process.execve in the tsgo.js script itself. That can only remain if the replacement attempt happens before it. If the replacement fails, however, that'd then incur the overhead for each execution, impacting the spawning of the native binary.

@jakebailey
Copy link
Copy Markdown
Member Author

Part of this PR is that the bin entry of package.json moves to tsgo; the js file isn't used except those who manually run it. That name can't be replaced without it being broken on Windows, I don't think.

But I'm not sure I'm understanding the situation you're describing, since tsgo.js always runs the real binary, not the symlink.

@jakebailey
Copy link
Copy Markdown
Member Author

Seems to work!

@JoostK
Copy link
Copy Markdown

JoostK commented Jun 6, 2026

I was hoping that the wrapper could first spawn native tsgo, then try to optimize.

In situations where the bin optimization fails, there's now always the cost of paying for optimizeBin() for each individual tsgo execution (through the wrapper). Moving the optimization attempt after spawning native tsgo would avoid that, but that cannot work with the current use of process.execve, as that doesn't fork (leaving no code to run to perform the optimization).

Perhaps this is moot if the bottleneck is Node startup or if optimizeBin introduces negligible overhead, and it's only relevant in setups where the binary replacement fails anyway.

@JoostK
Copy link
Copy Markdown

JoostK commented Jun 6, 2026

but that cannot work with the current use of process.execve, as that doesn't fork (leaving no code to run to perform the optimization).

Coming back to this, this could be done if the wrapper invokes tsgo with smth like --optimize-bin=path/node_modules/.bin/tsgo, moving the optimization into the native binary itself.

Probably not worth it.

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.

Cache getExePath() for performance reasons

3 participants