fix(tsconnect-worker): detect configuration mismatches
CI / format (pull_request) Successful in 2m2s
CI / lint (pull_request) Successful in 2m9s
CI / install (pull_request) Successful in 7m12s
CI / typetest (pull_request) Successful in 2m14s
CI / node-tests (pull_request) Successful in 2m43s
CI / typecheck (pull_request) Successful in 2m52s
CI / browser-tests (pull_request) Failing after 4m21s

Co-Authored-By: gpt-5.6-sol <noreply@openai.com>
This commit is contained in:
2026-08-03 00:14:32 +00:00
co-authored by Codex
parent ab987f50d0
commit 07eb6577fe
11 changed files with 552 additions and 71 deletions
+1
View File
@@ -134,3 +134,4 @@ The test suite for `packages/http` was mostly generated by Claude Code, which al
- **CI — bounded Node heap (issue #136)**: Claude Code (Claude Opus 5) added a workflow-level `NODE_OPTIONS: --max-old-space-size=4096` to `.gitea/workflows/ci.yml`, so every CI job that runs Node (including the per-file workers `node --test` spawns and Turbo-invoked package scripts, which inherit the variable) fails with a legible V8 heap-limit error instead of growing until the runner's own memory limit kills the task and any jobs sharing the machine.
- **`@webnet/tsconnect` — minimal build output (issue #154)**: `gpt-5.6-sol` changed the Tailscale package build to use a temporary staging directory and copy only `main.wasm`, `build-info.json`, `wasm_exec.js`, and `cacert.pem` into `dist/`, excluding unused upstream demo bundles, source maps, styles, and package metadata from published packages and Turbo caches.
- **turbo setup - cache issue**: written collaboratively between human codinget and agent claude-opus-5 who double checked and debugged my work.
- **`@webnet/tsconnect-worker` — deterministic SharedWorker configuration handshake (issue #127)**: `gpt-5.6-sol` added normalized effective-configuration comparison, opaque per-worker configuration identities, initialization-versus-attachment metadata, and a typed mismatch error that reports field names without exposing values or silently falling back to a separate main-thread IPN. Matching and mismatching concurrent/later clients are covered in Node and Chromium/Firefox browser tests.
+87 -36
View File
@@ -37,6 +37,8 @@ import type {
DriveW2C,
DriveC2W,
WorkerConfig,
WorkerConfigField,
WorkerConnection,
FileOpsLimits,
TransferToken,
} from "./protocol.js"
@@ -97,6 +99,18 @@ function acquireLock(name: string): Promise<() => void> {
})
}
export class WorkerConfigMismatchError extends Error {
readonly activeConfigIdentity: string
readonly mismatchedFields: WorkerConfigField[]
constructor(activeConfigIdentity: string, mismatchedFields: WorkerConfigField[]) {
super(`SharedWorker configuration mismatch: ${mismatchedFields.join(", ")}`)
this.name = "WorkerConfigMismatchError"
this.activeConfigIdentity = activeConfigIdentity
this.mismatchedFields = mismatchedFields
}
}
// ── WorkerConn ────────────────────────────────────────────────────────────────
export class WorkerConn implements RawTransport, StateTransferable<TransferToken> {
@@ -517,6 +531,8 @@ export interface IpnClientHandle extends IpnClient {
readonly clientKey?: string
/** Whether cross-client state transfer is available (false on the main-thread path). */
readonly transferSupported: boolean
/** SharedWorker handshake details, or undefined on the main-thread path. */
readonly workerConnection?: WorkerConnection
run(opts?: IPNRunOptions): void
sendTransfer(
targetKey: string,
@@ -548,7 +564,7 @@ export interface IpnClientHandle extends IpnClient {
* path should use `shutdown()` on `IpnMainThreadHandle` or send an explicit
* stop message to the worker.
*/
disconnect(): void
disconnect(): Promise<void>
}
/**
@@ -562,9 +578,11 @@ export interface IpnClientHandle extends IpnClient {
export class IpnWorkerClient implements IpnClientHandle, TransferHost {
readonly store: IpnWorkerClientStore
readonly clientKey?: string
readonly workerConnection: WorkerConnection
readonly #port: MessagePort
#runOpts: IPNRunOptions | null = null
#releaseLock: (() => void) | null = null
#disconnectPromise: Promise<void> | null = null
#pending = new Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void }>()
#fileOps = false
#state: IPNState = "NoState"
@@ -572,10 +590,16 @@ export class IpnWorkerClient implements IpnClientHandle, TransferHost {
#transferHandlers = new Map<string, (t: TransferEvent) => void>()
#transferBuffer = new Map<string, Extract<W2C, { type: "transfer" }>[]>()
private constructor(port: MessagePort, store: IpnWorkerClientStore, clientKey?: string) {
private constructor(
port: MessagePort,
store: IpnWorkerClientStore,
workerConnection: WorkerConnection,
clientKey?: string,
) {
this.#port = port
this.store = store
this.clientKey = clientKey
this.workerConnection = workerConnection
this.#state = store.getState().state.state
port.onmessage = (e: MessageEvent) => this.#onMessage(e.data as W2C)
}
@@ -599,8 +623,8 @@ export class IpnWorkerClient implements IpnClientHandle, TransferHost {
* Connect to a SharedWorker running the tsconnect-worker script.
*
* @param port The `SharedWorker.port` to communicate on.
* @param config Worker and IPN configuration. Ignored if the worker is
* already running (a second tab connecting to the same worker).
* @param config Worker and IPN configuration. Must match the effective
* configuration when attaching to an existing worker.
*/
static async connect(
port: MessagePort,
@@ -611,43 +635,60 @@ export class IpnWorkerClient implements IpnClientHandle, TransferHost {
const lockReady = acquireLock(lockName)
let preloadedState: IpnState | undefined
let workerConnection: WorkerConnection | undefined
const bufferedActions: IpnAction[] = []
const bufferedTransfers: Extract<W2C, { type: "transfer" }>[] = []
await new Promise<void>((resolve, reject) => {
port.onmessage = (e: MessageEvent) => {
const msg = e.data as W2C
switch (msg.type) {
case "ready":
resolve()
break
case "initError":
reject(new Error(msg.error))
break
case "preloadState":
preloadedState = msg.state
break
case "action":
bufferedActions.push(msg.action)
break
case "transfer":
bufferedTransfers.push(msg)
break
try {
await new Promise<void>((resolve, reject) => {
port.onmessage = (e: MessageEvent) => {
const msg = e.data as W2C
switch (msg.type) {
case "ready":
workerConnection = {
configIdentity: msg.configIdentity,
initialized: msg.initialized,
attachedDuringInitialization: msg.attachedDuringInitialization,
}
resolve()
break
case "configMismatch":
reject(new WorkerConfigMismatchError(msg.activeConfigIdentity, msg.mismatchedFields))
break
case "initError":
reject(new Error(msg.error))
break
case "preloadState":
preloadedState = msg.state
break
case "action":
bufferedActions.push(msg.action)
break
case "transfer":
bufferedTransfers.push(msg)
break
}
}
}
port.start()
port.postMessage({
type: "hello",
lockName,
clientKey: opts?.clientKey,
config,
} satisfies C2W)
})
port.start()
port.postMessage({
type: "hello",
lockName,
clientKey: opts?.clientKey,
config,
} satisfies C2W)
})
if (!workerConnection) throw new Error("worker ready response omitted configuration details")
} catch (err) {
const releaseLock = await lockReady
releaseLock()
port.close()
throw err
}
const store = buildIpnStore(preloadedState)
for (const action of bufferedActions) store.dispatch(action)
const client = new IpnWorkerClient(port, store, opts?.clientKey)
const client = new IpnWorkerClient(port, store, workerConnection, opts?.clientKey)
for (const msg of bufferedTransfers) client.#onMessage(msg)
client.#releaseLock = await lockReady
return client
@@ -812,10 +853,20 @@ export class IpnWorkerClient implements IpnClientHandle, TransferHost {
/** Release this client's hold on the worker without shutting the worker down.
* The worker cleans up all resources opened by this client and shuts down
* only if no other clients remain. Safe to call after {@link shutdown}. */
disconnect(): void {
disconnect(): Promise<void> {
this.#disconnectPromise ??= this.#disconnect()
return this.#disconnectPromise
}
async #disconnect(): Promise<void> {
this.#running = false
this.#releaseLock?.()
this.#releaseLock = null
try {
if (this.#releaseLock) await this.#call("disconnectClient", [])
} finally {
this.#releaseLock?.()
this.#releaseLock = null
this.#port.close()
}
}
async shutdown(): Promise<void> {
@@ -0,0 +1,47 @@
import { suite, test } from "node:test"
import assert from "node:assert/strict"
import { comparableWorkerConfig, differentWorkerConfigFields } from "./config.js"
suite("worker configuration comparison", () => {
test("normalizes effective defaults and ignores disabled FileOps settings", () => {
const active = comparableWorkerConfig({ wasmUrl: "/main.wasm" })
const requested = comparableWorkerConfig({
wasmUrl: "/main.wasm",
stateStorage: "indexeddb",
stateDbName: "tsconnect-state",
fileOps: false,
fileOpsDir: "ignored",
fileOpsMaxFiles: 1,
})
assert.deepEqual(differentWorkerConfigFields(active, requested), [])
})
test("compares only the addressed bytes of WASM views", () => {
const active = comparableWorkerConfig({
wasmUrl: new Uint8Array([0, 1, 2, 3]).subarray(1, 3),
})
const requested = comparableWorkerConfig({ wasmUrl: new Uint8Array([1, 2]).buffer })
assert.deepEqual(differentWorkerConfigFields(active, requested), [])
})
test("reports mismatched field names without exposing their values", () => {
const active = comparableWorkerConfig({
wasmUrl: "/main.wasm",
authKey: "secret-a",
hostname: "alpha",
})
const requested = comparableWorkerConfig({
wasmUrl: "/other.wasm",
authKey: "secret-b",
hostname: "beta",
})
assert.deepEqual(differentWorkerConfigFields(active, requested), [
"wasmUrl",
"authKey",
"hostname",
])
})
})
+72
View File
@@ -0,0 +1,72 @@
import type { WorkerConfig, WorkerConfigField } from "./protocol.js"
type ComparableWorkerConfig = {
wasmUrl: string | Uint8Array
authKey?: string
controlURL?: string
hostname?: string
stateDbName?: string
stateStorage: "indexeddb" | "memory"
fileOps: boolean
fileOpsDir?: string
fileOpsMaxFiles?: number
fileOpsMaxTotalSize?: number
fileOpsMaxFileSize?: number
}
function comparableWasmUrl(value: WorkerConfig["wasmUrl"]): string | Uint8Array {
if (typeof value === "string") return value
if (ArrayBuffer.isView(value)) {
return new Uint8Array(value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength))
}
return new Uint8Array(value.slice(0))
}
export function comparableWorkerConfig(config: WorkerConfig): ComparableWorkerConfig {
const stateStorage = config.stateStorage ?? "indexeddb"
const fileOps = config.fileOps ?? false
return {
wasmUrl: comparableWasmUrl(config.wasmUrl),
authKey: config.authKey,
controlURL: config.controlURL,
hostname: config.hostname,
stateDbName:
stateStorage === "indexeddb" ? (config.stateDbName ?? "tsconnect-state") : undefined,
stateStorage,
fileOps,
fileOpsDir: fileOps ? config.fileOpsDir : undefined,
fileOpsMaxFiles: fileOps ? config.fileOpsMaxFiles : undefined,
fileOpsMaxTotalSize: fileOps ? config.fileOpsMaxTotalSize : undefined,
fileOpsMaxFileSize: fileOps ? config.fileOpsMaxFileSize : undefined,
}
}
function wasmUrlsEqual(a: string | Uint8Array, b: string | Uint8Array): boolean {
if (typeof a === "string" || typeof b === "string") return a === b
return a.length === b.length && a.every((value, index) => value === b[index])
}
export function differentWorkerConfigFields(
active: ComparableWorkerConfig,
requested: ComparableWorkerConfig,
): WorkerConfigField[] {
const fields: WorkerConfigField[] = []
if (!wasmUrlsEqual(active.wasmUrl, requested.wasmUrl)) fields.push("wasmUrl")
for (const field of [
"authKey",
"controlURL",
"hostname",
"stateDbName",
"stateStorage",
"fileOps",
"fileOpsDir",
"fileOpsMaxFiles",
"fileOpsMaxTotalSize",
"fileOpsMaxFileSize",
] as const) {
if (active[field] !== requested[field]) fields.push(field)
}
return fields
}
export type { ComparableWorkerConfig }
+2 -1
View File
@@ -1,4 +1,4 @@
import { IpnWorkerClient } from "./client.js"
import { IpnWorkerClient, WorkerConfigMismatchError } from "./client.js"
import { connectMainThread } from "./main-thread.js"
import type { IpnClientHandle } from "./client.js"
import type { WorkerConfig } from "./protocol.js"
@@ -41,6 +41,7 @@ export async function connectWithFallback(
: new SharedWorker(workerUrlOrFactory, opts?.workerOptions)
return await IpnWorkerClient.connect(sw.port, config, { clientKey: opts?.clientKey })
} catch (err) {
if (err instanceof WorkerConfigMismatchError) throw err
console.warn("tsconnect: SharedWorker path failed, falling back to main thread:", err)
}
}
+9 -1
View File
@@ -4,6 +4,7 @@ export {
WorkerTCPListener,
WorkerPacketConn,
workerDialer,
WorkerConfigMismatchError,
} from "./client.js"
export type {
IpnWorkerClientStore,
@@ -15,4 +16,11 @@ export type {
export { connectMainThread, IpnMainThreadHandle } from "./main-thread.js"
export { connectWithFallback } from "./connect.js"
export type { ConnectOptions } from "./connect.js"
export type { WorkerConfig, TransferToken, ResourceKind, ResourceMeta } from "./protocol.js"
export type {
WorkerConfig,
WorkerConfigField,
WorkerConnection,
TransferToken,
ResourceKind,
ResourceMeta,
} from "./protocol.js"
+13 -8
View File
@@ -31,11 +31,13 @@ export class IpnMainThreadHandle implements IpnClientHandle {
readonly connectionMode = "main-thread" as const
readonly transferSupported = false
readonly clientKey = undefined
readonly workerConnection = undefined
readonly store: IpnWorkerClientStore
#ipn: IPN
#fsaFileOps: FsaFileOps | undefined
#runOpts: IPNRunOptions | null = null
#releaseLock: (() => void) | null = null
#shutdownPromise: Promise<void> | null = null
#fileOps = false
#state: IPNState = "NoState"
#running = false
@@ -108,18 +110,21 @@ export class IpnMainThreadHandle implements IpnClientHandle {
if (ofList.length) opts.notifyOutgoingFiles?.(ofList as IPNOutgoingFile[])
}
disconnect(): void {
disconnect(): Promise<void> {
return this.#stop()
}
#stop(): Promise<void> {
this.#running = false
this.#releaseLock?.()
this.#releaseLock = null
this.#ipn.shutdown().catch(() => {})
this.#shutdownPromise ??= this.#ipn.shutdown().finally(() => {
this.#releaseLock?.()
this.#releaseLock = null
})
return this.#shutdownPromise
}
async shutdown(): Promise<void> {
this.#running = false
this.#releaseLock?.()
this.#releaseLock = null
await this.#ipn.shutdown()
await this.#stop()
}
login(): void {
+17 -1
View File
@@ -23,6 +23,17 @@ export type WorkerConfig = {
fileOpsMaxFileSize?: number
}
export type WorkerConfigField = keyof WorkerConfig
export type WorkerConnection = {
/** Opaque identity for the effective configuration. Stable for this worker's lifetime. */
configIdentity: string
/** Whether this client supplied the configuration that initialized the worker. */
initialized: boolean
/** Whether this client attached while that initialization was still pending. */
attachedDuringInitialization: boolean
}
export type FileOpsLimits = {
maxFiles?: number
maxTotalSize?: number
@@ -49,7 +60,12 @@ export type C2W =
// ── Main port: worker → client ──────────────────────────────────────────────
export type W2C =
| { type: "ready" }
| ({ type: "ready" } & WorkerConnection)
| {
type: "configMismatch"
activeConfigIdentity: string
mismatchedFields: WorkerConfigField[]
}
| { type: "initError"; error: string }
| { type: "preloadState"; state: IpnState }
| { type: "action"; action: IpnAction }
@@ -116,6 +116,106 @@ if (!WASM_BUILT) {
}
})
test("simultaneous clients share configuration identity and later mismatches reject", async () => {
const [pageA, pageB, pageC] = await pages(3)
try {
const connect = async ([connectUrl, workerUrl, wasmUrl, clientKey]: string[]) => {
const { connectWithFallback } = await import(connectUrl!)
const handle = await connectWithFallback(
workerUrl,
{ wasmUrl, stateStorage: "memory", hostname: "shared-config" },
{ clientKey, workerOptions: { name: "configuration-handshake" } },
)
;(globalThis as unknown as Record<string, unknown>).__h = handle
return handle.workerConnection
}
const [a, b] = await Promise.all([
pageA.evaluate(connect, [...urls(), "config-a"]),
pageB.evaluate(connect, [...urls(), "config-b"]),
])
assert.ok(a)
assert.ok(b)
assert.strictEqual(a.configIdentity, b.configIdentity)
assert.deepStrictEqual([a.initialized, b.initialized].sort(), [false, true])
const attached = a.initialized ? b : a
assert.strictEqual(attached.attachedDuringInitialization, true)
const mismatch = await pageC.evaluate(async ([connectUrl, workerUrl, wasmUrl]) => {
const { connectWithFallback } = await import(connectUrl!)
try {
await connectWithFallback(
workerUrl,
{ wasmUrl, stateStorage: "memory", hostname: "different-config" },
{ workerOptions: { name: "configuration-handshake" } },
)
return { rejected: false }
} catch (err) {
const mismatch = err as Error & {
activeConfigIdentity?: string
mismatchedFields?: string[]
}
return {
rejected: true,
name: mismatch.name,
activeConfigIdentity: mismatch.activeConfigIdentity,
mismatchedFields: mismatch.mismatchedFields,
message: mismatch.message,
}
}
}, urls())
assert.strictEqual(mismatch.rejected, true)
assert.strictEqual(mismatch.name, "WorkerConfigMismatchError")
assert.strictEqual(mismatch.activeConfigIdentity, a.configIdentity)
assert.deepStrictEqual(mismatch.mismatchedFields, ["hostname"])
assert.doesNotMatch(mismatch.message!, /shared-config|different-config/)
const stillConnected = await pageA.evaluate(() => {
const handle = (globalThis as unknown as Record<string, unknown>).__h as {
connectionMode: string
state: string
}
return { mode: handle.connectionMode, state: handle.state }
})
assert.strictEqual(stillConnected.mode, "worker")
assert.strictEqual(typeof stillConnected.state, "string")
await Promise.all([
pageA.evaluate(async () => {
const handle = (globalThis as unknown as Record<string, unknown>).__h as {
disconnect(): void
}
await handle.disconnect()
}),
pageB.evaluate(async () => {
const handle = (globalThis as unknown as Record<string, unknown>).__h as {
disconnect(): void
}
await handle.disconnect()
}),
])
const reinitialized = await pageC.evaluate(async ([connectUrl, workerUrl, wasmUrl]) => {
const { connectWithFallback } = await import(connectUrl!)
const handle = await connectWithFallback(
workerUrl,
{ wasmUrl, stateStorage: "memory", hostname: "different-config" },
{ workerOptions: { name: "configuration-handshake" } },
)
return handle.workerConnection
}, urls())
assert.ok(reinitialized)
assert.strictEqual(reinitialized.initialized, true)
assert.notStrictEqual(reinitialized.configIdentity, a.configIdentity)
} finally {
await pageA.close()
await pageB.close()
await pageC.close()
}
})
test("JSON envelope is buffered until the receiving tab registers its handler", async () => {
const [pageA, pageB] = await pages(2)
try {
+91 -13
View File
@@ -1,6 +1,12 @@
import { test, suite } from "node:test"
import assert from "node:assert/strict"
import { WorkerConn, WorkerTCPListener, WorkerPacketConn, IpnWorkerClient } from "./client.js"
import {
WorkerConn,
WorkerTCPListener,
WorkerPacketConn,
IpnWorkerClient,
WorkerConfigMismatchError,
} from "./client.js"
import type { TransferHost } from "./client.js"
import type { TransferToken } from "./protocol.js"
import { pumpStreamToPort, portToReadableStream } from "./protocol.js"
@@ -686,7 +692,14 @@ async function fakeConnect(script: FakeWorker): Promise<{
}> {
installFakeLocks()
const { port1, port2 } = new MessageChannel()
port2.onmessage = (e: MessageEvent) => script(e.data as Parameters<FakeWorker>[0], port2)
port2.onmessage = (e: MessageEvent) => {
const msg = e.data as Parameters<FakeWorker>[0]
if (msg.type === "call" && msg.method === "disconnectClient") {
port2.postMessage({ type: "return", id: msg.id, value: undefined })
return
}
script(msg, port2)
}
port2.start()
const client = await IpnWorkerClient.connect(port1, { wasmUrl: new ArrayBuffer(0) })
return { client, workerPort: port2 }
@@ -698,10 +711,17 @@ suite("IpnWorkerClient state transfer", () => {
installFakeLocks()
const { port1, port2 } = new MessageChannel()
port2.onmessage = (e: MessageEvent) => {
const msg = e.data as { type: string; clientKey?: string }
const msg = e.data as { type: string; id?: number; clientKey?: string }
if (msg.type === "hello") {
hello = msg
port2.postMessage({ type: "ready" })
port2.postMessage({
type: "ready",
configIdentity: "config-1",
initialized: true,
attachedDuringInitialization: false,
})
} else if (msg.type === "call") {
port2.postMessage({ type: "return", id: msg.id, value: undefined })
}
}
port2.start()
@@ -714,14 +734,56 @@ suite("IpnWorkerClient state transfer", () => {
)
assert.equal(client.clientKey, "K")
assert.equal(client.transferSupported, true)
assert.deepEqual(client.workerConnection, {
configIdentity: "config-1",
initialized: true,
attachedDuringInitialization: false,
})
assert.equal(hello!.clientKey, "K")
client.disconnect()
await client.disconnect()
port2.close()
})
test("configuration mismatches reject with safe structured details", async () => {
installFakeLocks()
const { port1, port2 } = new MessageChannel()
port2.onmessage = (e: MessageEvent) => {
const msg = e.data as { type: string }
if (msg.type === "hello") {
port2.postMessage({
type: "configMismatch",
activeConfigIdentity: "config-1",
mismatchedFields: ["authKey", "hostname"],
})
}
}
port2.start()
await assert.rejects(
IpnWorkerClient.connect(port1, {
wasmUrl: new ArrayBuffer(0),
authKey: "must-not-leak",
}),
(err: unknown) => {
assert.ok(err instanceof WorkerConfigMismatchError)
assert.equal(err.activeConfigIdentity, "config-1")
assert.deepEqual(err.mismatchedFields, ["authKey", "hostname"])
assert.doesNotMatch(err.message, /must-not-leak/)
return true
},
)
port2.close()
})
test("onTransfer buffers until a handler is registered", async () => {
const { client, workerPort } = await fakeConnect((msg, port) => {
if (msg.type === "hello") port.postMessage({ type: "ready" })
if (msg.type === "hello")
port.postMessage({
type: "ready",
configIdentity: "test",
initialized: true,
attachedDuringInitialization: false,
})
})
let barrierReached = false
client.onTransfer("barrier", () => {
@@ -737,7 +799,7 @@ suite("IpnWorkerClient state transfer", () => {
})
await waitFor(() => got !== null)
assert.deepEqual(got!.state, { hi: 1 })
client.disconnect()
await client.disconnect()
workerPort.close()
})
@@ -745,7 +807,12 @@ suite("IpnWorkerClient state transfer", () => {
const calls: { method?: string; args?: unknown[] }[] = []
const { client, workerPort } = await fakeConnect((msg, port) => {
if (msg.type === "hello") {
port.postMessage({ type: "ready" })
port.postMessage({
type: "ready",
configIdentity: "test",
initialized: true,
attachedDuringInitialization: false,
})
return
}
if (msg.type === "call" && msg.method === "transferSend") {
@@ -756,14 +823,19 @@ suite("IpnWorkerClient state transfer", () => {
const res = await client.sendTransfer("B", "ch", { s: 1 }, [], [])
assert.deepEqual(res, { delivered: true })
assert.deepEqual(calls[0].args, ["B", "ch", { s: 1 }, [], []])
client.disconnect()
await client.disconnect()
workerPort.close()
})
test("claim reconstructs a conn proxy carrying its resourceId", async () => {
const { client, workerPort } = await fakeConnect((msg, port) => {
if (msg.type === "hello") {
port.postMessage({ type: "ready" })
port.postMessage({
type: "ready",
configIdentity: "test",
initialized: true,
attachedDuringInitialization: false,
})
return
}
if (msg.type === "call" && msg.method === "claimResource") {
@@ -797,13 +869,19 @@ suite("IpnWorkerClient state transfer", () => {
assert.equal(conn.resourceId, 99)
assert.equal((conn as WorkerConn).localAddr, "L")
conn.close()
client.disconnect()
await client.disconnect()
workerPort.close()
})
test("onAdopt unwraps the transfer envelope", async () => {
const { client, workerPort } = await fakeConnect((msg, port) => {
if (msg.type === "hello") port.postMessage({ type: "ready" })
if (msg.type === "hello")
port.postMessage({
type: "ready",
configIdentity: "test",
initialized: true,
attachedDuringInitialization: false,
})
})
let adopted: { value: string; extra: unknown } | null = null
client.onAdopt(
@@ -822,7 +900,7 @@ suite("IpnWorkerClient state transfer", () => {
await waitFor(() => adopted !== null)
assert.equal(adopted!.value, "adopted:5")
assert.deepEqual(adopted!.extra, { redux: 1 })
client.disconnect()
await client.disconnect()
workerPort.close()
})
})
+113 -11
View File
@@ -26,6 +26,11 @@ import { pumpStreamToPort, portToReadableStream } from "./protocol.js"
import type { TransferToken } from "./protocol.js"
import { chanServe, createTransferRegistry, newChan } from "./transfers.js"
import type { AnyResource, Chan } from "./transfers.js"
import {
comparableWorkerConfig,
differentWorkerConfigFields,
type ComparableWorkerConfig,
} from "./config.js"
const sw = self as unknown as SharedWorkerGlobalScope
@@ -49,9 +54,17 @@ let nextResourceId = 0
const registry = createTransferRegistry({ onEmpty: () => maybeShutdownIpn() })
// Clients that connected before init completed, with their lock name already extracted.
const pendingClients: Array<{ port: MessagePort; lockName: string; clientKey?: string }> = []
let initState: "idle" | "pending" | "ready" | "failed" = "idle"
const pendingClients: Array<{
port: MessagePort
lockName: string
clientKey?: string
initialized: boolean
}> = []
let initState: "idle" | "pending" | "ready" | "stopping" | "failed" = "idle"
let initError = ""
let activeConfig: ComparableWorkerConfig | undefined
let activeConfigIdentity = ""
let shutdownPromise: Promise<void> | null = null
// ── Helpers ──────────────────────────────────────────────────────────────────
@@ -657,11 +670,27 @@ async function handleCall(
// ── Client cleanup ────────────────────────────────────────────────────────────
function maybeShutdownIpn(): void {
if (clients.size === 0 && !registry.hasPending() && ipn) {
ipn.shutdown().catch(() => {
if (clients.size !== 0 || registry.hasPending() || !ipn || initState !== "ready") return
const stoppedIpn = ipn
initState = "stopping"
shutdownPromise = stoppedIpn
.shutdown()
.catch(() => {
/* best-effort */
})
}
.then(() => {
if (ipn !== stoppedIpn) return
_unbindFileOps?.()
_unbindFileOps = undefined
ipn = null
store = null
fileOps = undefined
activeConfig = undefined
activeConfigIdentity = ""
initState = "idle"
shutdownPromise = null
})
}
function cleanupClient(clientId: number): void {
@@ -694,7 +723,13 @@ function cleanupClient(clientId: number): void {
// ── Client registration ───────────────────────────────────────────────────────
function registerClient(port: MessagePort, lockName: string, clientKey?: string): void {
function registerClient(
port: MessagePort,
lockName: string,
clientKey: string | undefined,
initialized: boolean,
attachedDuringInitialization: boolean,
): void {
const clientId = nextClientId++
const entry: ClientEntry = {
port,
@@ -722,11 +757,22 @@ function registerClient(port: MessagePort, lockName: string, clientKey?: string)
)
}
send(port, { type: "ready" })
send(port, {
type: "ready",
configIdentity: activeConfigIdentity,
initialized,
attachedDuringInitialization,
})
port.onmessage = (e: MessageEvent) => {
const msg = e.data as C2W
if (msg.type === "call") {
if (msg.method === "disconnectClient") {
send(port, { type: "return", id: msg.id, value: undefined })
cleanupClient(clientId)
port.close()
return
}
handleCall(port, entry, msg.id, msg.method, msg.args)
}
}
@@ -795,8 +841,30 @@ sw.onconnect = (e: MessageEvent) => {
return
}
if (initState === "stopping") await shutdownPromise
if (initState === "failed") {
send(port, { type: "initError", error: initError })
return
}
if (initState === "ready") {
registerClient(port, msg.lockName, msg.clientKey)
if (!msg.config) {
send(port, { type: "initError", error: "config required for worker connection" })
return
}
const mismatchedFields = differentWorkerConfigFields(
activeConfig!,
comparableWorkerConfig(msg.config),
)
if (mismatchedFields.length > 0) {
send(port, {
type: "configMismatch",
activeConfigIdentity,
mismatchedFields,
})
return
}
registerClient(port, msg.lockName, msg.clientKey, false, false)
return
}
@@ -805,12 +873,25 @@ sw.onconnect = (e: MessageEvent) => {
send(port, { type: "initError", error: "init config required for first connection" })
return
}
pendingClients.push({ port, lockName: msg.lockName, clientKey: msg.clientKey })
activeConfig = comparableWorkerConfig(msg.config)
activeConfigIdentity = crypto.randomUUID()
pendingClients.push({
port,
lockName: msg.lockName,
clientKey: msg.clientKey,
initialized: true,
})
const localConfig = msg.config
try {
await init(localConfig)
for (const pending of pendingClients) {
registerClient(pending.port, pending.lockName, pending.clientKey)
registerClient(
pending.port,
pending.lockName,
pending.clientKey,
pending.initialized,
!pending.initialized,
)
}
} catch (err) {
const error = errMsg(err)
@@ -821,7 +902,28 @@ sw.onconnect = (e: MessageEvent) => {
pendingClients.length = 0
} else {
// initState === "pending": another tab is already initialising; queue.
pendingClients.push({ port, lockName: msg.lockName, clientKey: msg.clientKey })
if (!msg.config) {
send(port, { type: "initError", error: "config required for worker connection" })
return
}
const mismatchedFields = differentWorkerConfigFields(
activeConfig!,
comparableWorkerConfig(msg.config),
)
if (mismatchedFields.length > 0) {
send(port, {
type: "configMismatch",
activeConfigIdentity,
mismatchedFields,
})
return
}
pendingClients.push({
port,
lockName: msg.lockName,
clientKey: msg.clientKey,
initialized: false,
})
}
}