wasm: one IPN per runtime with an explicit readiness handshake #20

Merged
codinget merged 7 commits from feat/single-ipn-lifecycle into webnet 2026-08-30 20:53:30 +02:00
2 changed files with 48 additions and 19 deletions
Showing only changes of commit 0a6e85834a - Show all commits
+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,