fix(tsconnect): keep racing the runtime while the IPN is built

Two lifecycle gaps in startIPN, both found in review.

The runtime was only raced until the readiness callback fired. Building
the backend happens after that, in a Go goroutine, and if the runtime
dies partway through, that goroutine dies with it and the promise it
would have settled never settles. startIPN hung forever instead of
rejecting. Race the factory too.

createIPN also handed callers the bridge's own shutdown, whose promise
races the runtime tearing itself down and may never settle, and which
the public type did not declare at all. Replace it in place with one that
resolves when the runtime has actually exited, and declare it. Replacing
rather than wrapping keeps the object the bridge built, instead of a copy
that only looks like it.

That also gives the exit handler the distinction it was missing: only an
exit the caller did not ask for is a panic now, so a deliberate shutdown
is no longer reported as one.

Co-Authored-By: claude-opus-5 <noreply@anthropic.com>
This commit was merged in pull request #20.
This commit is contained in:
2026-08-30 15:30:28 +00:00
co-authored by Claude
parent 738fea52f8
commit 0a6e85834a
2 changed files with 48 additions and 19 deletions
+40 -19
View File
@@ -9,6 +9,10 @@
* this resolves on an explicit signal rather than on the Go scheduler having * 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 * run far enough. The name is generated per runtime, so several runtimes can
* start in one page without racing each other. * start in one page without racing each other.
*
* The returned IPN replaces the bridge's shutdown() with one that resolves when
* the runtime has actually exited. The raw promise cannot be awaited: it races
* with the runtime tearing itself down, so it may never settle.
*/ */
export async function startIPN( export async function startIPN(
go: Go, go: Go,
@@ -28,35 +32,52 @@ export async function startIPN(
go.env[INIT_CALLBACK_ENV] = name go.env[INIT_CALLBACK_ENV] = name
// An exit before the IPN reaches the caller is a startup failure, and throwing // Only an exit the caller did not ask for is worth reporting. Before the
// hands it back as a rejection. Afterwards the caller holds the only shutdown // handover every exit is a startup failure and rejecting hands it back as an
// path, so an exit is either that shutdown or a panic; report it either way, // error; afterwards, only an exit that shutdown() did not cause is a panic.
// because the IPN is dead in both cases. let stopping = false
let handedOver = false const exited: Promise<void> = go.run(instance).then(() => {
const exited: Promise<never> = go.run(instance).then(() => {
delete globals[name] delete globals[name]
if (handedOver) onExit("Go runtime exited") if (!stopping) onExit("Unexpected shutdown")
// Always reject: before the handover this is what fails the race below, })
// and after it the race has settled, so nothing observes the rejection. // Reject alongside it, so an exit during startup fails the awaits below
throw new Error( // instead of leaving them pending forever.
handedOver const failed: Promise<never> = exited.then(() => {
? "Go runtime exited" throw new Error("Go runtime exited before the IPN was ready")
: "Go runtime exited before the IPN was ready"
)
}) })
const [newIPN, terminate] = await Promise.race([ready, exited]) const [newIPN, terminate] = await Promise.race([ready, failed])
let ipn: IPN
try { try {
const ipn = await newIPN(config) // Keep racing the runtime: building the backend runs in a Go goroutine, and
handedOver = true // if the runtime dies partway that goroutine dies with it and its promise
return ipn // never settles.
ipn = await Promise.race([newIPN(config), failed])
} catch (err) { } catch (err) {
// Nothing was built, so nothing can shut the runtime down. Exit it here and // 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. // wait for it, or the page keeps a blocked runtime for a failed startup.
// Calling terminate on an already-exited runtime does nothing.
stopping = true
terminate() terminate()
await exited.catch(() => {}) await exited
throw err throw err
} }
// Replace shutdown in place rather than wrapping the object: the bridge hands
// back a plain map of Go-backed functions, and copying it would leave the
// caller with something that only looks like the IPN.
const rawShutdown = ipn.shutdown.bind(ipn)
ipn.shutdown = async () => {
stopping = true
try {
void rawShutdown()
} catch {
// The runtime may already be gone, in which case there is nothing to ask
// and the await below returns immediately.
}
await exited
}
return ipn
} }
type NewIPN = (config: IPNConfig) => Promise<IPN> type NewIPN = (config: IPNConfig) => Promise<IPN>
+8
View File
@@ -11,6 +11,14 @@ declare global {
run(callbacks: IPNCallbacks): void run(callbacks: IPNCallbacks): void
login(): void login(): void
logout(): void logout(): void
/**
* Tears down the backend and exits the Go runtime that owns this IPN.
*
* The promise the bridge returns races with the runtime exiting and may
* never settle; startIPN replaces it with one that resolves when the
* runtime has actually gone.
*/
shutdown(): Promise<void>
ssh( ssh(
host: string, host: string,
username: string, username: string,