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
2 changed files with 41 additions and 19 deletions
Showing only changes of commit 3c63a95446 - Show all commits
+18 -9
View File
@@ -19,14 +19,12 @@ export async function startIPN(
const name = `__tsconnectInit_${Math.random().toString(36).slice(2)}`
const globals = globalThis as Record<string, unknown>
const ready = new Promise<(config: IPNConfig) => Promise<IPN>>(
(resolve) => {
globals[name] = (newIPN: (config: IPNConfig) => Promise<IPN>) => {
delete globals[name]
resolve(newIPN)
}
const ready = new Promise<[NewIPN, Terminate]>((resolve) => {
globals[name] = (newIPN: NewIPN, terminate: Terminate) => {
delete globals[name]
resolve([newIPN, terminate])
}
)
})
go.env[INIT_CALLBACK_ENV] = name
const exited = go.run(instance).then(() => {
@@ -35,9 +33,20 @@ export async function startIPN(
throw new Error("Go runtime exited before the IPN was ready")
})
const newIPN = await Promise.race([ready, exited])
return newIPN(config)
const [newIPN, terminate] = await Promise.race([ready, exited])
try {
return await newIPN(config)
} catch (err) {
// Nothing was built, so nothing can shut the runtime down. Exit it here and
// wait for it, or the page keeps a blocked runtime for a failed startup.
terminate()
await exited.catch(() => {})
throw err
}
}
type NewIPN = (config: IPNConfig) => Promise<IPN>
type Terminate = () => void
/** Must match initCallbackEnv in wasm_js.go. */
const INIT_CALLBACK_ENV = "TSCONNECT_INIT_CALLBACK"
+23 -10
View File
@@ -85,8 +85,11 @@ func main() {
}
shutdownCh := make(chan struct{})
var terminateOnce sync.Once
terminate := func() { terminateOnce.Do(func() { close(shutdownCh) }) }
var claimed atomic.Bool
callback.Invoke(js.FuncOf(func(this js.Value, args []js.Value) any {
newIPNFn := js.FuncOf(func(this js.Value, args []js.Value) any {
return makePromise(func() (any, error) {
if len(args) != 1 {
return nil, errors.New("newIPN takes exactly one argument")
@@ -96,16 +99,27 @@ func main() {
if !claimed.CompareAndSwap(false, true) {
return nil, errors.New("this WASM runtime already has an IPN; start another runtime instead")
}
return newIPN(args[0], shutdownCh)
return newIPN(args[0], terminate)
})
}))
})
// A failed newIPN leaves the runtime blocked below with nothing to shut it
// down, so the loader gets a way to exit it. Leaving that to the loader
// keeps the ordering right: closing shutdownCh here would let main return
// before makePromise had delivered the rejection.
terminateFn := js.FuncOf(func(this js.Value, args []js.Value) any {
terminate()
return nil
})
callback.Invoke(newIPNFn, terminateFn)
// Block until shutdown() is called on the IPN, then let main return so the
// Go runtime (and all its goroutines) can be collected by the JS engine.
<-shutdownCh
}
func newIPN(jsConfig js.Value, shutdownCh chan struct{}) (map[string]any, error) {
func newIPN(jsConfig js.Value, terminate func()) (map[string]any, error) {
netns.SetEnabled(false)
var store ipn.StateStore
@@ -221,7 +235,7 @@ func newIPN(jsConfig js.Value, shutdownCh chan struct{}) (map[string]any, error)
hostname: hostname,
logID: logid,
funnelPorts: make(map[uint16]*funnelListenerEntry),
shutdownCh: shutdownCh,
terminate: terminate,
}
lb.SetTCPHandlerForFunnelFlow(jsIPN.handleFunnelTCP)
@@ -416,7 +430,7 @@ type jsIPN struct {
funnelMu sync.Mutex
funnelPorts map[uint16]*funnelListenerEntry
shutdownCh chan struct{} // closed by shutdown() to unblock main()
terminate func() // unblocks main() so the Go runtime can exit
shutdownOnce sync.Once
}
@@ -627,16 +641,15 @@ func (i *jsIPN) logout() {
// shutdown tears down the backend and lets main return, which exits the whole
// Go runtime. Callers should await the runtime's exit rather than the promise
// returned here: closing shutdownCh races with makePromise resolving, so the
// promise may never settle. There is exactly one IPN per runtime, so the once
// and the channel belong to the same instance and a second call is a no-op.
// returned here: terminating races with makePromise resolving, so the promise
// may never settle.
func (i *jsIPN) shutdown() js.Value {
return makePromise(func() (any, error) {
i.shutdownOnce.Do(func() {
if i.lb != nil {
i.lb.Shutdown()
}
close(i.shutdownCh)
i.terminate()
})
return nil, nil
})