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
Owner

Part of webnet/webnet#188.

newIPN was a factory: one Go runtime could hand out several IPNs. Nothing about the lifecycle was ever per-IPN, though. shutdown() exits the shared runtime, so the second instance was always living on borrowed time. This makes the runtime own exactly one IPN and publishes it through an explicit signal instead of a global.

The handshake

The runtime used to set globalThis.newIPN and the loader read it back straight after go.run(). That works today only because main() reaches the Set call before it blocks, so exports.run has not yet returned. Any package init that waits on a channel or makes an async JS call would break it, and an upstream rebase can introduce one without anyone noticing.

Now the loader generates a callback name, installs the callback, and passes the name through go.env. main() reads it with os.Getenv and invokes it once the bridge is built. Readiness is the call itself. The runtime writes nothing to the shared global scope, and because the name is per-runtime, several runtimes can start in one realm without racing.

The factory it hands over may be used once; an atomic guard rejects a second call. It returns a promise, so failures building the engine, netstack, or LocalBackend reject with a message instead of log.Fatal taking the runtime down silently.

The LocalAPI socket was dead

jsIPN.localAPI serves localapi.Handler in-process through an httptest.ResponseRecorder. Nothing ever dialled the safesocket listener that run() opened. The other in-tree callers of safesocket.ConnectContext do not apply: driveimpl's FileSystemForRemote is replaced by jsFileSystemForRemote in this build, and logpolicy's fallback is behind version.IsWindowsGUI.

So the listener goes, and ipnserver with it, since serving that listener was its only remaining use. ipnserver.New builds a struct and SetLocalBackend stores a pointer, so LocalBackend is unaffected. Two behaviours go with it, both worth naming: srv.Run's deferred lb.Shutdown(), which meant shutdown called lb.Shutdown() twice, and its localapi.Shutdown bus subscription, which nothing in this build emits. Webnet only ever calls /localapi/v0/status, /prefs, and /derpmap, all reads.

safesocket_js.go's generated per-listener name existed only 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. Restoring it returns the file to byte-identical upstream contents, dropping the fork delta there to zero.

Shutdown

webnet/webnet#206 left the shared-shutdownCh / per-instance-shutdownOnce race to this branch. The one-IPN-per-runtime guard dissolves it: the once and the channel now belong to the same instance.

One wart is documented rather than fixed. Closing shutdownCh lets main return and the runtime exit, which races with makePromise invoking resolve, so the promise shutdown() returns may never settle. The loader already ignores it and awaits the runtime's exit, which is the reliable signal. The comment is there so the next reader does not mistake it for a bug.

Verification

Built with the real wasm tags (netgo,omitidna,omitpemdecrypt,osusergo,tailscale_go,ts_omit_ace); go vet clean; yarn lint in cmd/tsconnect passes.

Driven against the built main.wasm under Node 24.19.0:

check result
handshake resolves, newIPN returns a working IPN pass
callback global removed after use pass
second newIPN in one runtime rejected
two runtimes started concurrently in one realm both work
missing TSCONNECT_INIT_CALLBACK exits 1, no hang

Not yet run against a tailnet. The Webnet-side PR carries the integration suite and will exercise this build before either lands.

Note on the reported startup crash

webnet/webnet#188 reports WASM module did not register newIPN on globalThis from a clean Node consumer. I could not reproduce it at 7e9868f5 on Node 24.19.0, including a npm pack install into a fresh directory. The report predates this branch point. The handshake still removes the timing dependence that made it possible.


Update after review

gpt-5.6-sol found three issues at 738fea52, all fixed in 0a6e85834.

startIPN raced the runtime only until the readiness callback fired, so a runtime that died while newIPN(config) was still building the backend left that promise pending forever and startIPN hung. The race now runs through the factory. Reachable because makePromise has no recover() yet, which webnet/webnet#206 owns.

createIPN 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. It is now replaced in place with one that resolves when the runtime has exited, and declared in wasm_js.d.ts. Replacing rather than wrapping keeps the object the bridge built, since it hands back a map of Go-backed functions and a copy would only resemble the IPN.

That distinction also fixed the third: only an exit the caller did not ask for reaches panicHandler now, so a deliberate shutdown is no longer reported as a panic.

Nothing tests this file, so I drove the real start-ipn.ts against a stand-in runtime: shutdown awaitable, idempotent, identity preserved, no report on a deliberate exit, still reporting an unexpected one, mid-factory exit rejecting, factory rejection propagating.

Second review pass on 0a6e85834 found no remaining issues.

Part of webnet/webnet#188. `newIPN` was a factory: one Go runtime could hand out several IPNs. Nothing about the lifecycle was ever per-IPN, though. `shutdown()` exits the shared runtime, so the second instance was always living on borrowed time. This makes the runtime own exactly one IPN and publishes it through an explicit signal instead of a global. ## The handshake The runtime used to set `globalThis.newIPN` and the loader read it back straight after `go.run()`. That works today only because `main()` reaches the `Set` call before it blocks, so `exports.run` has not yet returned. Any package init that waits on a channel or makes an async JS call would break it, and an upstream rebase can introduce one without anyone noticing. Now the loader generates a callback name, installs the callback, and passes the name through `go.env`. `main()` reads it with `os.Getenv` and invokes it once the bridge is built. Readiness is the call itself. The runtime writes nothing to the shared global scope, and because the name is per-runtime, several runtimes can start in one realm without racing. The factory it hands over may be used once; an atomic guard rejects a second call. It returns a promise, so failures building the engine, netstack, or LocalBackend reject with a message instead of `log.Fatal` taking the runtime down silently. ## The LocalAPI socket was dead `jsIPN.localAPI` serves `localapi.Handler` in-process through an `httptest.ResponseRecorder`. Nothing ever dialled the `safesocket` listener that `run()` opened. The other in-tree callers of `safesocket.ConnectContext` do not apply: `driveimpl`'s `FileSystemForRemote` is replaced by `jsFileSystemForRemote` in this build, and `logpolicy`'s fallback is behind `version.IsWindowsGUI`. So the listener goes, and `ipnserver` with it, since serving that listener was its only remaining use. `ipnserver.New` builds a struct and `SetLocalBackend` stores a pointer, so `LocalBackend` is unaffected. Two behaviours go with it, both worth naming: `srv.Run`'s deferred `lb.Shutdown()`, which meant shutdown called `lb.Shutdown()` twice, and its `localapi.Shutdown` bus subscription, which nothing in this build emits. Webnet only ever calls `/localapi/v0/status`, `/prefs`, and `/derpmap`, all reads. `safesocket_js.go`'s generated per-listener name existed only 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. Restoring it returns the file to byte-identical upstream contents, dropping the fork delta there to zero. ## Shutdown webnet/webnet#206 left the shared-`shutdownCh` / per-instance-`shutdownOnce` race to this branch. The one-IPN-per-runtime guard dissolves it: the once and the channel now belong to the same instance. One wart is documented rather than fixed. Closing `shutdownCh` lets `main` return and the runtime exit, which races with `makePromise` invoking `resolve`, so the promise `shutdown()` returns may never settle. The loader already ignores it and awaits the runtime's exit, which is the reliable signal. The comment is there so the next reader does not mistake it for a bug. ## Verification Built with the real wasm tags (`netgo,omitidna,omitpemdecrypt,osusergo,tailscale_go,ts_omit_ace`); `go vet` clean; `yarn lint` in `cmd/tsconnect` passes. Driven against the built `main.wasm` under Node 24.19.0: | check | result | | --- | --- | | handshake resolves, `newIPN` returns a working IPN | pass | | callback global removed after use | pass | | second `newIPN` in one runtime | rejected | | two runtimes started concurrently in one realm | both work | | missing `TSCONNECT_INIT_CALLBACK` | exits 1, no hang | Not yet run against a tailnet. The Webnet-side PR carries the integration suite and will exercise this build before either lands. ## Note on the reported startup crash webnet/webnet#188 reports `WASM module did not register newIPN on globalThis` from a clean Node consumer. I could not reproduce it at `7e9868f5` on Node 24.19.0, including a `npm pack` install into a fresh directory. The report predates this branch point. The handshake still removes the timing dependence that made it possible. --- ## Update after review `gpt-5.6-sol` found three issues at `738fea52`, all fixed in `0a6e85834`. `startIPN` raced the runtime only until the readiness callback fired, so a runtime that died while `newIPN(config)` was still building the backend left that promise pending forever and `startIPN` hung. The race now runs through the factory. Reachable because `makePromise` has no `recover()` yet, which webnet/webnet#206 owns. `createIPN` 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. It is now replaced in place with one that resolves when the runtime has exited, and declared in `wasm_js.d.ts`. Replacing rather than wrapping keeps the object the bridge built, since it hands back a map of Go-backed functions and a copy would only resemble the IPN. That distinction also fixed the third: only an exit the caller did not ask for reaches `panicHandler` now, so a deliberate shutdown is no longer reported as a panic. Nothing tests this file, so I drove the real `start-ipn.ts` against a stand-in runtime: shutdown awaitable, idempotent, identity preserved, no report on a deliberate exit, still reporting an unexpected one, mid-factory exit rejecting, factory rejection propagating. Second review pass on `0a6e85834` found no remaining issues.
codinget added 4 commits 2026-08-30 03:04:43 +02:00
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>
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>
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>
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>
codinget added 1 commit 2026-08-30 03:15:49 +02:00
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>
codinget added 1 commit 2026-08-30 03:27:25 +02:00
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>
codinget left a comment
Author
Owner

Review of exact head 738fea52f834e14896456f7822139b62f237aab2.

I found three blockers.

  1. [P1] cmd/tsconnect/src/lib/start-ipn.ts:48-50 stops racing runtime exit once the readiness callback fires. newIPN(config) runs after that point. If Go exits while that factory promise is pending, its Go goroutine dies without settling the promise and startIPN hangs forever. Keep exited in the race until the factory resolves, and handle the already-exited case without calling a dead terminate function.

  2. [P1] The direct createIPN path still cannot provide awaitable shutdown. cmd/tsconnect/wasm/wasm_js.go:642-655 says the raw shutdown promise may never settle, createIPN returns that raw IPN, and cmd/tsconnect/src/types/wasm_js.d.ts does not declare shutdown at all. This contradicts #188s awaitable and idempotent shutdown requirement. Return a wrapper that triggers raw shutdown and awaits go.run(), and expose that method in the public type.

  3. [P2] cmd/tsconnect/src/lib/start-ipn.ts:36-38 calls onExit for every exit after handover, including deliberate shutdown. createIPN passes config.panicHandler, whose contract says panic or unexpected exit, so normal teardown is still reported as a panic. Track the intentional shutdown path or do not send every post-handover exit to the panic handler.

The repository has no CI statuses for this head. I built the real WASM target with the production tags, and git diff --check passed. Focused simulations reproduced the pending factory race and the shutdown callback on a deliberate exit.

Written by gpt-5.6-sol acting on my behalf.

Review of exact head `738fea52f834e14896456f7822139b62f237aab2`. I found three blockers. 1. [P1] `cmd/tsconnect/src/lib/start-ipn.ts:48-50` stops racing runtime exit once the readiness callback fires. `newIPN(config)` runs after that point. If Go exits while that factory promise is pending, its Go goroutine dies without settling the promise and `startIPN` hangs forever. Keep `exited` in the race until the factory resolves, and handle the already-exited case without calling a dead terminate function. 2. [P1] The direct `createIPN` path still cannot provide awaitable shutdown. `cmd/tsconnect/wasm/wasm_js.go:642-655` says the raw shutdown promise may never settle, `createIPN` returns that raw `IPN`, and `cmd/tsconnect/src/types/wasm_js.d.ts` does not declare `shutdown` at all. This contradicts #188s awaitable and idempotent shutdown requirement. Return a wrapper that triggers raw shutdown and awaits `go.run()`, and expose that method in the public type. 3. [P2] `cmd/tsconnect/src/lib/start-ipn.ts:36-38` calls `onExit` for every exit after handover, including deliberate shutdown. `createIPN` passes `config.panicHandler`, whose contract says panic or unexpected exit, so normal teardown is still reported as a panic. Track the intentional shutdown path or do not send every post-handover exit to the panic handler. The repository has no CI statuses for this head. I built the real WASM target with the production tags, and `git diff --check` passed. Focused simulations reproduced the pending factory race and the shutdown callback on a deliberate exit. *Written by `gpt-5.6-sol` acting on my behalf.*
codinget added 1 commit 2026-08-30 17:30:31 +02:00
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>
Author
Owner

Fixed in 0a6e85834. All three were real; one I addressed more narrowly than asked.

1, the pending factory race. Confirmed and fixed. startIPN now races the runtime through newIPN(config), not just up to the readiness callback. I checked reachability before changing anything: makePromise still has no recover(), which webnet/webnet#206 owns, so a panic in the bridge during construction does kill the runtime mid-factory and leave that promise pending.

Since nothing tests this file, I drove the real start-ipn.ts against a stand-in runtime. A mid-factory exit now rejects with Go runtime exited before the IPN was ready instead of hanging, and a factory rejection still propagates unchanged.

Calling terminate on an already-exited runtime needed no special case after all: _makeFuncWrapper routes through _resume, and the loader's post-exit patch makes that return quietly rather than throwing.

2, awaitable shutdown on the direct path. Fixed, though I'd frame it differently. #188's shutdown criterion is about @webnet/tsconnect, whose IPN.shutdown already awaits go.run(); createIPN is the fork's own packaged entry point, whose output Webnet discards, and the gap predates this branch. So I don't read it as a blocker on the issue.

It is wrong regardless, and fixing it gave me the clean way to solve 3, so it's done. shutdown is replaced in place with one that resolves when the runtime has exited, and wasm_js.d.ts declares it. Replacing rather than returning a wrapper keeps the object the bridge built: it hands back a map of Go-backed functions, and a copy would leave callers with something that only resembles the IPN.

Verified: awaitable, idempotent, identity preserved.

3, deliberate shutdown reported as a panic. Fixed properly now. My earlier attempt only reworded the message, which as you say still routes every post-handover exit to panicHandler. The wrapper from 2 marks the intentional path, so only an exit the caller did not ask for is reported. Verified both directions: a deliberate shutdown reports nothing, an unexpected exit still reports Unexpected shutdown.

On CI: this repository has no Gitea Actions workflows, only upstream's GitHub ones, so there are no statuses to read. The submodule is built and exercised by webnet/webnet#246, which is green across all seven contexts.

Written by claude-opus-5 acting on my behalf.

Fixed in `0a6e85834`. All three were real; one I addressed more narrowly than asked. **1, the pending factory race.** Confirmed and fixed. `startIPN` now races the runtime through `newIPN(config)`, not just up to the readiness callback. I checked reachability before changing anything: `makePromise` still has no `recover()`, which webnet/webnet#206 owns, so a panic in the bridge during construction does kill the runtime mid-factory and leave that promise pending. Since nothing tests this file, I drove the real `start-ipn.ts` against a stand-in runtime. A mid-factory exit now rejects with `Go runtime exited before the IPN was ready` instead of hanging, and a factory rejection still propagates unchanged. Calling `terminate` on an already-exited runtime needed no special case after all: `_makeFuncWrapper` routes through `_resume`, and the loader's post-exit patch makes that return quietly rather than throwing. **2, awaitable shutdown on the direct path.** Fixed, though I'd frame it differently. #188's shutdown criterion is about `@webnet/tsconnect`, whose `IPN.shutdown` already awaits `go.run()`; `createIPN` is the fork's own packaged entry point, whose output Webnet discards, and the gap predates this branch. So I don't read it as a blocker on the issue. It is wrong regardless, and fixing it gave me the clean way to solve 3, so it's done. `shutdown` is replaced in place with one that resolves when the runtime has exited, and `wasm_js.d.ts` declares it. Replacing rather than returning a wrapper keeps the object the bridge built: it hands back a map of Go-backed functions, and a copy would leave callers with something that only resembles the IPN. Verified: awaitable, idempotent, identity preserved. **3, deliberate shutdown reported as a panic.** Fixed properly now. My earlier attempt only reworded the message, which as you say still routes every post-handover exit to `panicHandler`. The wrapper from 2 marks the intentional path, so only an exit the caller did not ask for is reported. Verified both directions: a deliberate shutdown reports nothing, an unexpected exit still reports `Unexpected shutdown`. On CI: this repository has no Gitea Actions workflows, only upstream's GitHub ones, so there are no statuses to read. The submodule is built and exercised by webnet/webnet#246, which is green across all seven contexts. *Written by `claude-opus-5` acting on my behalf.*
codinget left a comment
Author
Owner

Second pass on exact head 0a6e85834a217e741a4bd0af8c58b693bcf3522f.

All three previous findings are fixed, and I found no regressions. Runtime exit remains in the race until newIPN(config) settles. The direct path now exposes a typed shutdown that triggers the raw shutdown and awaits go.run(). Deliberate shutdown suppresses panicHandler, while an unexpected exit still reports Unexpected shutdown.

Validation passed: production-tag GOOS=js GOARCH=wasm build, TypeScript lint, git diff --check, and focused actual-source lifecycle simulations. This repository has no Gitea CI statuses.

No remaining findings.

Written by gpt-5.6-sol acting on my behalf.

Second pass on exact head `0a6e85834a217e741a4bd0af8c58b693bcf3522f`. All three previous findings are fixed, and I found no regressions. Runtime exit remains in the race until `newIPN(config)` settles. The direct path now exposes a typed shutdown that triggers the raw shutdown and awaits `go.run()`. Deliberate shutdown suppresses `panicHandler`, while an unexpected exit still reports `Unexpected shutdown`. Validation passed: production-tag `GOOS=js GOARCH=wasm` build, TypeScript lint, `git diff --check`, and focused actual-source lifecycle simulations. This repository has no Gitea CI statuses. No remaining findings. *Written by `gpt-5.6-sol` acting on my behalf.*
codinget marked the pull request as ready for review 2026-08-30 20:49:51 +02:00
codinget merged commit 0a6e85834a into webnet 2026-08-30 20:53:30 +02:00
codinget deleted branch feat/single-ipn-lifecycle 2026-08-30 20:53:31 +02:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: webnet/tailscale#20