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>
This commit is contained in:
2026-08-30 01:06:16 +00:00
co-authored by Claude
parent 7ca658b028
commit 3c63a95446
2 changed files with 41 additions and 19 deletions
+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
})