wasm: one IPN per runtime with an explicit readiness handshake #20

Merged
codinget merged 7 commits from feat/single-ipn-lifecycle into webnet 2026-08-30 20:53:30 +02:00
7 Commits
Author SHA1 Message Date
codingetandClaude 0a6e85834a fix(tsconnect): keep racing the runtime while the IPN is built
Two lifecycle gaps in startIPN, both found in review.

The runtime was only raced until the readiness callback fired. Building
the backend happens after that, in a Go goroutine, and if the runtime
dies partway through, that goroutine dies with it and the promise it
would have settled never settles. startIPN hung forever instead of
rejecting. Race the factory too.

createIPN also handed callers the bridge's own shutdown, whose promise
races the runtime tearing itself down and may never settle, and which
the public type did not declare at all. Replace it in place with one that
resolves when the runtime has actually exited, and declare it. Replacing
rather than wrapping keeps the object the bridge built, instead of a copy
that only looks like it.

That also gives the exit handler the distinction it was missing: only an
exit the caller did not ask for is a panic now, so a deliberate shutdown
is no longer reported as one.

Co-Authored-By: claude-opus-5 <noreply@anthropic.com>
2026-08-30 15:30:28 +00:00
codingetandClaude 738fea52f8 fix(tsconnect): stop reporting a deliberate shutdown as a panic
The exit handler called onExit("Unexpected shutdown") whenever the Go
runtime exited. That was upstream's wording from when nothing could stop
the runtime, so every exit really was a panic. This fork added shutdown(),
so a clean teardown now reports itself as a crash to the panic handler
createIPN() hands to its callers.

Split the two cases. Before the IPN reaches the caller an exit is a
startup failure, and rejecting hands it back as an error rather than as a
side-channel callback. After that the caller holds the only shutdown
path, so report the exit without claiming it was unexpected.

Found in review of webnet/webnet#188.

Co-Authored-By: claude-opus-5 <noreply@anthropic.com>
2026-08-30 01:27:21 +00:00
codingetandClaude 3c63a95446 fix(tsconnect/wasm): let the loader exit a runtime whose IPN failed
A rejected newIPN left the runtime blocked in main with nothing able to
release it: the IPN that owns shutdown was never built. The runtime, its
goroutines, and its scheduler work stayed live for a startup that failed.

Hand the loader a terminate function alongside the factory. Closing the
channel inside newIPN would not work, because main would return and the
runtime exit before makePromise delivered the rejection; leaving it to
the loader keeps the rejection first and the exit second.

jsIPN now holds that function instead of the channel, so shutdown and
startup failure release the runtime through one path.

Co-Authored-By: claude-opus-5 <noreply@anthropic.com>
2026-08-30 01:06:16 +00:00
codingetandClaude 7ca658b028 refactor(tsconnect): move the fork's own TS onto the init callback
build-pkg runs tsc and dts-bundle-generator over src/, so the demo app
and the package entry point have to follow the runtime off the global
factory or the wasm build stops working.

Both now start the runtime through a shared startIPN helper, which
installs the callback, passes its name in through go.env, and races
readiness against the runtime exiting so a startup crash rejects instead
of hanging. The panic handler is wired to that exit rather than being
attached to a floating go.run() promise.

Co-Authored-By: claude-opus-5 <noreply@anthropic.com>
2026-08-30 01:01:41 +00:00
codingetandClaude c98a03dfa5 docs(tsconnect/wasm): record why the shutdown promise cannot be awaited
Closing shutdownCh lets main return and the runtime exit, which races
with makePromise invoking resolve, so the promise shutdown() hands back
may never settle. The JS loader already ignores it and awaits the
runtime's exit instead; say so here so the next reader does not take the
unsettled promise for a bug and rewire the callers.

Also note that the once and the channel now belong to the same instance.
webnet/webnet#206 left the shared-channel race to this branch, and the
one-IPN-per-runtime guard dissolves it.

Co-Authored-By: claude-opus-5 <noreply@anthropic.com>
2026-08-30 01:00:32 +00:00
codingetandClaude 00d16d6ae2 refactor(tsconnect/wasm): drop the unreachable LocalAPI socket
jsIPN.localAPI serves localapi.Handler in-process through an
httptest.ResponseRecorder, so nothing ever dialled the safesocket
listener that run() opened. The other in-tree callers of
safesocket.ConnectContext do not apply either: driveimpl's
FileSystemForRemote is replaced by jsFileSystemForRemote in this build,
and logpolicy's fallback is behind version.IsWindowsGUI.

Remove the listener, and with it ipnserver, whose only remaining use was
serving that listener. ipnserver.New builds a struct and SetLocalBackend
stores a pointer, so dropping both leaves LocalBackend untouched. Two
behaviours go with it: srv.Run's deferred lb.Shutdown, which made
shutdown call lb.Shutdown twice, and its localapi.Shutdown bus
subscription, which nothing in this build emits.

safesocket's generated per-listener name existed so several IPNs could
share one runtime. Each runtime has its own Go heap and its own memconn
registry, so the fixed name never conflicted between runtimes, and there
is now at most one IPN in each. Restoring the fixed name returns
safesocket_js.go to its upstream contents.

Co-Authored-By: claude-opus-5 <noreply@anthropic.com>
2026-08-30 00:59:47 +00:00
codingetandClaude 24ee15e524 feat(tsconnect/wasm): hand the bridge to JS through an init callback
The runtime published its bridge by setting globalThis.newIPN and the
loader read it back immediately after go.run(). That works only because
main() happens to reach the Set call before it blocks, so any package
init that waits on a channel or makes an async JS call would leave the
loader reading a global that is not there yet.

Take the name of a JS callback from go.env instead, and invoke it once
the bridge is built. Readiness is now the call itself, the runtime writes
nothing to the shared global scope, and two runtimes in one realm cannot
collide on a name.

The callback receives a factory that may be used once. shutdown() exits
the whole Go runtime, so a second IPN here would be torn down by the
first one's shutdown; an atomic guard rejects it rather than handing back
an instance that dies unpredictably.

The factory returns a promise, so failures building the engine, netstack,
or LocalBackend reject instead of calling log.Fatal and taking the
runtime down with no explanation for the caller.

Co-Authored-By: claude-opus-5 <noreply@anthropic.com>
2026-08-30 00:57:59 +00:00