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
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:
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user