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 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
* run far enough. The name is generated per runtime, so several runtimes can
* 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(
go: Go,
@@ -28,35 +32,52 @@ export async function startIPN(
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(() => {
// Only an exit the caller did not ask for is worth reporting. Before the
// handover every exit is a startup failure and rejecting hands it back as an
// error; afterwards, only an exit that shutdown() did not cause is a panic.
let stopping = false
const exited: Promise<void> = 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"
)
if (!stopping) onExit("Unexpected shutdown")
})
// Reject alongside it, so an exit during startup fails the awaits below
// instead of leaving them pending forever.
const failed: Promise<never> = exited.then(() => {
throw new Error("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 {
const ipn = await newIPN(config)
handedOver = true
return ipn
// Keep racing the runtime: building the backend runs in a Go goroutine, and
// if the runtime dies partway that goroutine dies with it and its promise
// never settles.
ipn = await Promise.race([newIPN(config), failed])
} 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.
// Calling terminate on an already-exited runtime does nothing.
stopping = true
terminate()
await exited.catch(() => {})
await exited
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>
+8
View File
@@ -11,6 +11,14 @@ declare global {
run(callbacks: IPNCallbacks): void
login(): 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(
host: string,
username: string,