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:
@@ -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>
|
||||
|
||||
Vendored
+8
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user