feat(tsconnect/wasm): add shutdown() to jsIPN
Expose a shutdown() method on the JS-side IPN object that stops the LocalBackend, closes the safesocket listener (which unblocks srv.Run), and signals main() to return so the Go runtime exits cleanly. This allows the host environment (Node.js process or browser service worker) to terminate normally once the Tailscale WASM module is no longer needed, instead of being kept alive indefinitely by open handles, goroutines, or the Go runtime's blocking main goroutine. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit was merged in pull request #12.
This commit is contained in:
@@ -66,19 +66,20 @@ import (
|
|||||||
var ControlURL = ipn.DefaultControlURL
|
var ControlURL = ipn.DefaultControlURL
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
shutdownCh := make(chan struct{})
|
||||||
js.Global().Set("newIPN", js.FuncOf(func(this js.Value, args []js.Value) any {
|
js.Global().Set("newIPN", js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||||
if len(args) != 1 {
|
if len(args) != 1 {
|
||||||
log.Fatal("Usage: newIPN(config)")
|
log.Fatal("Usage: newIPN(config)")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return newIPN(args[0])
|
return newIPN(args[0], shutdownCh)
|
||||||
}))
|
}))
|
||||||
// Keep Go runtime alive, otherwise it will be shut down before newIPN gets
|
// Block until shutdown() is called on the IPN, then let main return so the
|
||||||
// called.
|
// Go runtime (and all its goroutines) can be collected by the JS engine.
|
||||||
<-make(chan bool)
|
<-shutdownCh
|
||||||
}
|
}
|
||||||
|
|
||||||
func newIPN(jsConfig js.Value) map[string]any {
|
func newIPN(jsConfig js.Value, shutdownCh chan struct{}) map[string]any {
|
||||||
netns.SetEnabled(false)
|
netns.SetEnabled(false)
|
||||||
|
|
||||||
var store ipn.StateStore
|
var store ipn.StateStore
|
||||||
@@ -179,6 +180,7 @@ func newIPN(jsConfig js.Value) map[string]any {
|
|||||||
hostname: hostname,
|
hostname: hostname,
|
||||||
logID: logid,
|
logID: logid,
|
||||||
funnelPorts: make(map[uint16]*funnelListenerEntry),
|
funnelPorts: make(map[uint16]*funnelListenerEntry),
|
||||||
|
shutdownCh: shutdownCh,
|
||||||
}
|
}
|
||||||
lb.SetTCPHandlerForFunnelFlow(jsIPN.handleFunnelTCP)
|
lb.SetTCPHandlerForFunnelFlow(jsIPN.handleFunnelTCP)
|
||||||
|
|
||||||
@@ -361,6 +363,9 @@ func newIPN(jsConfig js.Value) map[string]any {
|
|||||||
"suggestExitNode": js.FuncOf(func(this js.Value, args []js.Value) any {
|
"suggestExitNode": js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||||
return jsIPN.suggestExitNode()
|
return jsIPN.suggestExitNode()
|
||||||
}),
|
}),
|
||||||
|
"shutdown": js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||||
|
return jsIPN.shutdown()
|
||||||
|
}),
|
||||||
"localAPI": js.FuncOf(func(this js.Value, args []js.Value) any {
|
"localAPI": js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||||
if len(args) < 2 {
|
if len(args) < 2 {
|
||||||
log.Printf("Usage: localAPI(method, path[, body])")
|
log.Printf("Usage: localAPI(method, path[, body])")
|
||||||
@@ -387,6 +392,12 @@ type jsIPN struct {
|
|||||||
|
|
||||||
funnelMu sync.Mutex
|
funnelMu sync.Mutex
|
||||||
funnelPorts map[uint16]*funnelListenerEntry
|
funnelPorts map[uint16]*funnelListenerEntry
|
||||||
|
|
||||||
|
// ln is the safesocket listener created by run(); stored here so shutdown
|
||||||
|
// can close it and unblock srv.Run.
|
||||||
|
ln net.Listener
|
||||||
|
shutdownCh chan struct{} // closed by shutdown() to unblock main()
|
||||||
|
shutdownOnce sync.Once
|
||||||
}
|
}
|
||||||
|
|
||||||
// funnelListenerEntry is the per-port state for routing Funnel connections to a listenTLS listener.
|
// funnelListenerEntry is the per-port state for routing Funnel connections to a listenTLS listener.
|
||||||
@@ -594,14 +605,17 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
go func() {
|
ln, err := safesocket.Listen("")
|
||||||
ln, err := safesocket.Listen("")
|
if err != nil {
|
||||||
if err != nil {
|
log.Fatalf("safesocket.Listen: %v", err)
|
||||||
log.Fatalf("safesocket.Listen: %v", err)
|
}
|
||||||
}
|
i.ln = ln
|
||||||
|
|
||||||
err = i.srv.Run(context.Background(), ln)
|
go func() {
|
||||||
log.Fatalf("ipnserver.Run exited: %v", err)
|
err := i.srv.Run(context.Background(), ln)
|
||||||
|
if err != nil && !errors.Is(err, net.ErrClosed) {
|
||||||
|
log.Fatalf("ipnserver.Run exited: %v", err)
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -620,6 +634,17 @@ func (i *jsIPN) logout() {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (i *jsIPN) shutdown() js.Value {
|
||||||
|
return makePromise(func() (any, error) {
|
||||||
|
i.shutdownOnce.Do(func() {
|
||||||
|
i.lb.Shutdown()
|
||||||
|
i.ln.Close()
|
||||||
|
close(i.shutdownCh)
|
||||||
|
})
|
||||||
|
return nil, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (i *jsIPN) ssh(host, username string, termConfig js.Value) map[string]any {
|
func (i *jsIPN) ssh(host, username string, termConfig js.Value) map[string]any {
|
||||||
jsSSHSession := &jsSSHSession{
|
jsSSHSession := &jsSSHSession{
|
||||||
jsIPN: i,
|
jsIPN: i,
|
||||||
|
|||||||
Reference in New Issue
Block a user