Files
tailscale/cmd/tsconnect/src/lib/start-ipn.ts
T
codingetandClaude 738fea52f8 fix(tsconnect): stop reporting a deliberate shutdown as a panic
The exit handler called onExit("Unexpected shutdown") whenever the Go
runtime exited. That was upstream's wording from when nothing could stop
the runtime, so every exit really was a panic. This fork added shutdown(),
so a clean teardown now reports itself as a crash to the panic handler
createIPN() hands to its callers.

Split the two cases. Before the IPN reaches the caller an exit is a
startup failure, and rejecting hands it back as an error rather than as a
side-channel callback. After that the caller holds the only shutdown
path, so report the exit without claiming it was unexpected.

Found in review of webnet/webnet#188.

Co-Authored-By: claude-opus-5 <noreply@anthropic.com>
2026-08-30 01:27:21 +00:00

67 lines
2.3 KiB
TypeScript

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
/**
* Starts a Go runtime and returns the single IPN it owns.
*
* The runtime does not publish its bridge on a global. It reads the name of a
* callback from its environment and invokes it once the bridge is ready, so
* this resolves on an explicit signal rather than on the Go scheduler having
* run far enough. The name is generated per runtime, so several runtimes can
* start in one page without racing each other.
*/
export async function startIPN(
go: Go,
instance: WebAssembly.Instance,
config: IPNConfig,
onExit: (reason: string) => void
): Promise<IPN> {
const name = `__tsconnectInit_${Math.random().toString(36).slice(2)}`
const globals = globalThis as Record<string, unknown>
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
// An exit before the IPN reaches the caller is a startup failure, and throwing
// hands it back as a rejection. Afterwards the caller holds the only shutdown
// path, so an exit is either that shutdown or a panic; report it either way,
// because the IPN is dead in both cases.
let handedOver = false
const exited: Promise<never> = go.run(instance).then(() => {
delete globals[name]
if (handedOver) onExit("Go runtime exited")
// Always reject: before the handover this is what fails the race below,
// and after it the race has settled, so nothing observes the rejection.
throw new Error(
handedOver
? "Go runtime exited"
: "Go runtime exited before the IPN was ready"
)
})
const [newIPN, terminate] = await Promise.race([ready, exited])
try {
const ipn = await newIPN(config)
handedOver = true
return ipn
} 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"