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:
@@ -19,14 +19,12 @@ export async function startIPN(
|
|||||||
const name = `__tsconnectInit_${Math.random().toString(36).slice(2)}`
|
const name = `__tsconnectInit_${Math.random().toString(36).slice(2)}`
|
||||||
const globals = globalThis as Record<string, unknown>
|
const globals = globalThis as Record<string, unknown>
|
||||||
|
|
||||||
const ready = new Promise<(config: IPNConfig) => Promise<IPN>>(
|
const ready = new Promise<[NewIPN, Terminate]>((resolve) => {
|
||||||
(resolve) => {
|
globals[name] = (newIPN: NewIPN, terminate: Terminate) => {
|
||||||
globals[name] = (newIPN: (config: IPNConfig) => Promise<IPN>) => {
|
|
||||||
delete globals[name]
|
delete globals[name]
|
||||||
resolve(newIPN)
|
resolve([newIPN, terminate])
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
)
|
|
||||||
|
|
||||||
go.env[INIT_CALLBACK_ENV] = name
|
go.env[INIT_CALLBACK_ENV] = name
|
||||||
const exited = go.run(instance).then(() => {
|
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")
|
throw new Error("Go runtime exited before the IPN was ready")
|
||||||
})
|
})
|
||||||
|
|
||||||
const newIPN = await Promise.race([ready, exited])
|
const [newIPN, terminate] = await Promise.race([ready, exited])
|
||||||
return newIPN(config)
|
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. */
|
/** Must match initCallbackEnv in wasm_js.go. */
|
||||||
const INIT_CALLBACK_ENV = "TSCONNECT_INIT_CALLBACK"
|
const INIT_CALLBACK_ENV = "TSCONNECT_INIT_CALLBACK"
|
||||||
|
|||||||
@@ -85,8 +85,11 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
shutdownCh := make(chan struct{})
|
shutdownCh := make(chan struct{})
|
||||||
|
var terminateOnce sync.Once
|
||||||
|
terminate := func() { terminateOnce.Do(func() { close(shutdownCh) }) }
|
||||||
|
|
||||||
var claimed atomic.Bool
|
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) {
|
return makePromise(func() (any, error) {
|
||||||
if len(args) != 1 {
|
if len(args) != 1 {
|
||||||
return nil, errors.New("newIPN takes exactly one argument")
|
return nil, errors.New("newIPN takes exactly one argument")
|
||||||
@@ -96,16 +99,27 @@ func main() {
|
|||||||
if !claimed.CompareAndSwap(false, true) {
|
if !claimed.CompareAndSwap(false, true) {
|
||||||
return nil, errors.New("this WASM runtime already has an IPN; start another runtime instead")
|
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
|
// 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.
|
// Go runtime (and all its goroutines) can be collected by the JS engine.
|
||||||
<-shutdownCh
|
<-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)
|
netns.SetEnabled(false)
|
||||||
|
|
||||||
var store ipn.StateStore
|
var store ipn.StateStore
|
||||||
@@ -221,7 +235,7 @@ func newIPN(jsConfig js.Value, shutdownCh chan struct{}) (map[string]any, error)
|
|||||||
hostname: hostname,
|
hostname: hostname,
|
||||||
logID: logid,
|
logID: logid,
|
||||||
funnelPorts: make(map[uint16]*funnelListenerEntry),
|
funnelPorts: make(map[uint16]*funnelListenerEntry),
|
||||||
shutdownCh: shutdownCh,
|
terminate: terminate,
|
||||||
}
|
}
|
||||||
lb.SetTCPHandlerForFunnelFlow(jsIPN.handleFunnelTCP)
|
lb.SetTCPHandlerForFunnelFlow(jsIPN.handleFunnelTCP)
|
||||||
|
|
||||||
@@ -416,7 +430,7 @@ type jsIPN struct {
|
|||||||
funnelMu sync.Mutex
|
funnelMu sync.Mutex
|
||||||
funnelPorts map[uint16]*funnelListenerEntry
|
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
|
shutdownOnce sync.Once
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -627,16 +641,15 @@ func (i *jsIPN) logout() {
|
|||||||
|
|
||||||
// shutdown tears down the backend and lets main return, which exits the whole
|
// 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
|
// Go runtime. Callers should await the runtime's exit rather than the promise
|
||||||
// returned here: closing shutdownCh races with makePromise resolving, so the
|
// returned here: terminating races with makePromise resolving, so the promise
|
||||||
// promise may never settle. There is exactly one IPN per runtime, so the once
|
// may never settle.
|
||||||
// and the channel belong to the same instance and a second call is a no-op.
|
|
||||||
func (i *jsIPN) shutdown() js.Value {
|
func (i *jsIPN) shutdown() js.Value {
|
||||||
return makePromise(func() (any, error) {
|
return makePromise(func() (any, error) {
|
||||||
i.shutdownOnce.Do(func() {
|
i.shutdownOnce.Do(func() {
|
||||||
if i.lb != nil {
|
if i.lb != nil {
|
||||||
i.lb.Shutdown()
|
i.lb.Shutdown()
|
||||||
}
|
}
|
||||||
close(i.shutdownCh)
|
i.terminate()
|
||||||
})
|
})
|
||||||
return nil, nil
|
return nil, nil
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user