wasm: one IPN per runtime with an explicit readiness handshake #20
@@ -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"
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user