socketduct 0.1.24 → 0.1.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/client/multiplexed-transport-set.js +20 -2
- package/src/reverse/client.js +44 -1
- package/src/reverse/hub.js +10 -1
- package/src/reverse/runtime.js +11 -2
- package/test/integration/reverse-route-class.test.js +68 -0
- package/test/unit/client/multiplexed-transport-set.test.js +31 -0
package/package.json
CHANGED
|
@@ -44,6 +44,7 @@ const GATEWAY_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{1,63}$/
|
|
|
44
44
|
* @property {import('../logging/structured-logger.js').DiagnosticLogger} [logger] - Optional privacy-safe structured logger shared by the set and its members.
|
|
45
45
|
* @typedef {object} MemberConnectionStatus
|
|
46
46
|
* @property {string} physicalState - Member lifecycle.
|
|
47
|
+
* @property {boolean} [gatewayUnavailable] - Hub recently reported no selectable gateway for this member's route class.
|
|
47
48
|
* @property {number} connectionGeneration - Current authenticated generation.
|
|
48
49
|
* @property {number} physicalConnects - Authenticated physical generations.
|
|
49
50
|
* @property {number} reconnectAttempts - Physical reconnect attempts.
|
|
@@ -569,17 +570,34 @@ export class MultiplexedSocketductTransportSet extends EventEmitter {
|
|
|
569
570
|
return this.#closePromise
|
|
570
571
|
}
|
|
571
572
|
|
|
572
|
-
/**
|
|
573
|
+
/**
|
|
574
|
+
* Selects the next ready member, preferring members whose gateway the hub has
|
|
575
|
+
* not recently reported unavailable. When every ready member carries a
|
|
576
|
+
* gateway-unavailable mark, any ready member may still be selected so a stale
|
|
577
|
+
* mark can never wedge the set.
|
|
578
|
+
* @returns {{member: TransportMember, previousCursor: number} | undefined}
|
|
579
|
+
*/
|
|
573
580
|
#selectReadyMember() {
|
|
574
581
|
if (this.#closed) throw closedError()
|
|
575
582
|
const previousCursor = this.#cursor
|
|
583
|
+
/** @type {{member: TransportMember, index: number} | undefined} */
|
|
584
|
+
let fallback
|
|
576
585
|
for (let offset = 0; offset < this.#members.length; offset += 1) {
|
|
577
586
|
const index = (this.#cursor + offset) % this.#members.length
|
|
578
587
|
const member = /** @type {TransportMember} */ (this.#members[index])
|
|
579
|
-
|
|
588
|
+
const status = member.connection.status()
|
|
589
|
+
if (status.physicalState !== "ready") continue
|
|
590
|
+
if (status.gatewayUnavailable === true) {
|
|
591
|
+
fallback ??= {member, index}
|
|
592
|
+
continue
|
|
593
|
+
}
|
|
580
594
|
this.#cursor = (index + 1) % this.#members.length
|
|
581
595
|
return {member, previousCursor}
|
|
582
596
|
}
|
|
597
|
+
if (fallback !== undefined) {
|
|
598
|
+
this.#cursor = (fallback.index + 1) % this.#members.length
|
|
599
|
+
return {member: fallback.member, previousCursor}
|
|
600
|
+
}
|
|
583
601
|
return undefined
|
|
584
602
|
}
|
|
585
603
|
|
package/src/reverse/client.js
CHANGED
|
@@ -32,6 +32,7 @@ import {attachDisconnectAttribution, createDisconnectAttribution, parseDisconnec
|
|
|
32
32
|
const DEFAULT_STREAM_BUFFER_BYTES = 4 * 1024 * 1024
|
|
33
33
|
const DEFAULT_CONNECTION_BUFFER_BYTES = 32 * 1024 * 1024
|
|
34
34
|
const MANIFEST_PAGE_STREAMS = 64
|
|
35
|
+
const GATEWAY_UNAVAILABLE_TTL_MS = 5_000
|
|
35
36
|
|
|
36
37
|
/**
|
|
37
38
|
* @typedef {object} ReverseGatewayConnectionOptions
|
|
@@ -168,6 +169,7 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
168
169
|
/** @type {number} */ #spoolBytes = 0
|
|
169
170
|
/** @type {number} */ #spoolCleanupFailures = 0
|
|
170
171
|
/** @type {Set<Promise<void>>} */ #spoolCleanupTasks = new Set()
|
|
172
|
+
/** @type {number | undefined} - Wall-clock time of the last hub-reported gateway unavailability. */ #gatewayUnavailableSince
|
|
171
173
|
/** @type {import('./disconnect-attribution.js').DisconnectAttribution | undefined} */ #disconnectAttribution
|
|
172
174
|
|
|
173
175
|
/** @param {ReverseGatewayConnectionOptions} options - Hub route and safety policy. */
|
|
@@ -352,12 +354,41 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
352
354
|
return socket
|
|
353
355
|
}
|
|
354
356
|
|
|
357
|
+
/**
|
|
358
|
+
* Records that the hub just reported no selectable gateway for this member's
|
|
359
|
+
* route class, so the transport set can prefer members that can actually open.
|
|
360
|
+
* @param {string} code - Hub reset code.
|
|
361
|
+
* @returns {void}
|
|
362
|
+
*/
|
|
363
|
+
#markGatewayUnavailable(code) {
|
|
364
|
+
const firstMark = this.#gatewayUnavailableSince === undefined
|
|
365
|
+
this.#gatewayUnavailableSince = Date.now()
|
|
366
|
+
if (firstMark) {
|
|
367
|
+
logDiagnostic(this.#options.logger, "reverse_client.gateway_unavailable", {
|
|
368
|
+
code,
|
|
369
|
+
generation: this.#generation,
|
|
370
|
+
...this.#routeIdentityFields()
|
|
371
|
+
})
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Whether the last hub-reported gateway state for this member was unavailable
|
|
377
|
+
* and the report is still fresh. A stale report never pins the member out.
|
|
378
|
+
* @returns {boolean} True while a fresh hub unavailability report is active.
|
|
379
|
+
*/
|
|
380
|
+
#gatewayUnavailable() {
|
|
381
|
+
return this.#gatewayUnavailableSince !== undefined
|
|
382
|
+
&& Date.now() - this.#gatewayUnavailableSince < GATEWAY_UNAVAILABLE_TTL_MS
|
|
383
|
+
}
|
|
384
|
+
|
|
355
385
|
status() {
|
|
356
386
|
/** @type {Record<string, number>} */
|
|
357
387
|
const streamStates = {}
|
|
358
388
|
for (const stream of this.#streams.values()) streamStates[stream.state] = (streamStates[stream.state] ?? 0) + 1
|
|
359
389
|
return {
|
|
360
390
|
physicalState: this.#state,
|
|
391
|
+
gatewayUnavailable: this.#gatewayUnavailable(),
|
|
361
392
|
connectionGeneration: this.#generation,
|
|
362
393
|
virtualStreams: this.#streams.size,
|
|
363
394
|
streamStates,
|
|
@@ -623,6 +654,8 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
623
654
|
const selection = this.#routeIdentityFields()
|
|
624
655
|
if (detail.generation !== generation || ("gatewayId" in selection ? detail.gatewayId !== selection.gatewayId || Object.hasOwn(detail, "routeClass") : detail.routeClass !== selection.routeClass || Object.hasOwn(detail, "gatewayId"))) throw fatalError("GATEWAY_SELECTION_FAILED")
|
|
625
656
|
if (typeof detail.resumed !== "boolean") throw fatalError("GATEWAY_SELECTION_FAILED")
|
|
657
|
+
if (detail.selectable === true) this.#gatewayUnavailableSince = undefined
|
|
658
|
+
else if (detail.selectable === false) this.#markGatewayUnavailable("GATEWAY_SELECT_UNAVAILABLE")
|
|
626
659
|
selected = true
|
|
627
660
|
if (detail.resumed) {
|
|
628
661
|
clearTimeout(timer)
|
|
@@ -745,9 +778,16 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
745
778
|
const detail = decodeV3Control(frame.payload)
|
|
746
779
|
if (stream.state !== "opening" || frame.sequence !== 0 || detail.identifier !== stream.identifier || !validPeerWindow(detail.initialWindow)) throw new Error("Invalid reverse OPENED response")
|
|
747
780
|
stream.sendWindowLimit = Math.min(stream.sendWindowLimit, detail.initialWindow)
|
|
748
|
-
stream.sendWindow = Math.min(stream.sendWindow,
|
|
781
|
+
stream.sendWindow = Math.min(stream.sendWindow, detail.initialWindow)
|
|
749
782
|
stream.state = stream.localEnded ? "half-closed" : "open"
|
|
750
783
|
stream.opened = true
|
|
784
|
+
if (this.#gatewayUnavailableSince !== undefined) {
|
|
785
|
+
this.#gatewayUnavailableSince = undefined
|
|
786
|
+
logDiagnostic(this.#options.logger, "reverse_client.gateway_available", {
|
|
787
|
+
generation: this.#generation,
|
|
788
|
+
...this.#routeIdentityFields()
|
|
789
|
+
})
|
|
790
|
+
}
|
|
751
791
|
logDiagnostic(this.#options.logger, "virtual_stream.opened", {
|
|
752
792
|
streamId: stream.id,
|
|
753
793
|
generation: this.#generation,
|
|
@@ -801,6 +841,9 @@ export class ReverseGatewayConnection extends EventEmitter {
|
|
|
801
841
|
const detail = decodeV3Control(frame.payload)
|
|
802
842
|
const code = typeof detail.code === "string" ? detail.code : "STREAM_RESET"
|
|
803
843
|
stream.state = "reset"
|
|
844
|
+
if (code === "GATEWAY_UNAVAILABLE" || code === "GATEWAY_DISCONNECTED" || code === "GATEWAY_REPLACED") {
|
|
845
|
+
this.#markGatewayUnavailable(code)
|
|
846
|
+
}
|
|
804
847
|
const error = attachDisconnectAttribution(new SocketductStreamError("Reverse gateway stream was reset", code), validHubAttribution(detail) ?? (code === "GATEWAY_DISCONNECTED" ? this.#disconnectAttribution : undefined))
|
|
805
848
|
stream.socket._receiveError(error)
|
|
806
849
|
return
|
package/src/reverse/hub.js
CHANGED
|
@@ -49,6 +49,7 @@ const MANIFEST_PAGE_STREAMS = 64
|
|
|
49
49
|
* @property {(input: {principal: Principal, gatewayId: string}) => boolean} authorizeGateway - Stable gateway identity binding.
|
|
50
50
|
* @property {(input: {principal: Principal, gatewayId: string}) => boolean} authorizeClientGateway - Client-to-gateway selection policy.
|
|
51
51
|
* @property {(input: {principal: Principal, routeClass: string}) => boolean} [authorizeClientRouteClass] - Client-to-route-class selection policy.
|
|
52
|
+
* @property {(input: {principal: Principal, routeClass: string}) => string[]} [resolveRouteClassGateways] - All configured gateways for a client route class, used to announce route-class availability at selection time.
|
|
52
53
|
* @property {(input: {principal: Principal, gatewayId: string, target: Target}) => boolean} authorizeOpen - Hub-side route policy.
|
|
53
54
|
* @property {(input: {principal: Principal, routeClass: string, target: Target}) => string[]} [resolveOpenGateways] - Eligible exact gateways for a route-class OPEN.
|
|
54
55
|
* @property {string[]} [knownGatewayIds] - Configured IDs that may be temporarily unavailable.
|
|
@@ -480,7 +481,15 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
|
|
|
480
481
|
if (clientOwner.gatewayId !== undefined || (clientOwner.routeClass !== undefined && clientOwner.routeClass !== request.routeClass)) throw protocolError("GATEWAY_SELECTION_MISMATCH")
|
|
481
482
|
physical.routeClass = request.routeClass
|
|
482
483
|
clientOwner.routeClass = request.routeClass
|
|
483
|
-
|
|
484
|
+
const configuredGatewayIds = options.resolveRouteClassGateways?.({principal: physical.principal, routeClass: request.routeClass})
|
|
485
|
+
let selectable
|
|
486
|
+
if (Array.isArray(configuredGatewayIds) && configuredGatewayIds.length > 0) {
|
|
487
|
+
selectable = configuredGatewayIds.some((gatewayId) => {
|
|
488
|
+
const owner = gateways.get(gatewayId)
|
|
489
|
+
return owner !== undefined && owner.physical !== undefined && !owner.physical.socket.destroyed && !owner.physical.closing && !owner.physical.resumeRequired
|
|
490
|
+
})
|
|
491
|
+
}
|
|
492
|
+
await sendControl(physical, V3FrameType.GATEWAY_SELECTED, 0, physical.generation, {routeClass: request.routeClass, generation: physical.generation, resumed: physical.resumeRequired, ...(selectable === undefined ? {} : {selectable})})
|
|
484
493
|
armResumeTimeout(physical)
|
|
485
494
|
}
|
|
486
495
|
|
package/src/reverse/runtime.js
CHANGED
|
@@ -27,7 +27,15 @@ export async function startReverseHubRuntime(config, logger) {
|
|
|
27
27
|
gatewayIds.push(route.gatewayId)
|
|
28
28
|
routeClassGateways.set(key, gatewayIds)
|
|
29
29
|
}
|
|
30
|
-
const routeClassRows = new Set(routeClassRoutes.map((route) => `${formatRouteClassRoute(route.username, route.routeClass, {host: route.host, port: route.port})}
|
|
30
|
+
const routeClassRows = new Set(routeClassRoutes.map((route) => `${formatRouteClassRoute(route.username, route.routeClass, {host: route.host, port: route.port})}\\0${route.gatewayId}`))
|
|
31
|
+
/** @type {Map<string, string[]>} - Every configured gateway for a (username, routeClass) pair, independent of target. */
|
|
32
|
+
const routeClassGatewayUnions = new Map()
|
|
33
|
+
for (const route of routeClassRoutes) {
|
|
34
|
+
const key = `${route.username}\\0${route.routeClass}`
|
|
35
|
+
const gatewayIds = routeClassGatewayUnions.get(key) ?? []
|
|
36
|
+
if (!gatewayIds.includes(route.gatewayId)) gatewayIds.push(route.gatewayId)
|
|
37
|
+
routeClassGatewayUnions.set(key, gatewayIds)
|
|
38
|
+
}
|
|
31
39
|
if (bindings.size !== config.reverseGatewayBindings.length) throw new Error("SOCKETDUCT_REVERSE_GATEWAY_BINDINGS contains a duplicate gateway ID")
|
|
32
40
|
if (exactRouteKeys.size !== exactRoutes.length) throw new Error("SOCKETDUCT_REVERSE_ROUTES contains a duplicate route")
|
|
33
41
|
if (routeClassRows.size !== routeClassRoutes.length) throw new Error("SOCKETDUCT_REVERSE_ROUTE_CLASS_ROUTES contains a duplicate route")
|
|
@@ -47,7 +55,8 @@ export async function startReverseHubRuntime(config, logger) {
|
|
|
47
55
|
authorizeGateway: ({principal, gatewayId}) => bindings.get(gatewayId) === principal.username,
|
|
48
56
|
authorizeClientGateway: ({principal, gatewayId}) => exactSelections.has(`${principal.username}\0${gatewayId}`),
|
|
49
57
|
...(knownRouteClasses.length === 0 ? {} : {
|
|
50
|
-
authorizeClientRouteClass: ({principal, routeClass}) => routeClassSelections.has(`${principal.username}\
|
|
58
|
+
authorizeClientRouteClass: ({principal, routeClass}) => routeClassSelections.has(`${principal.username}\u0000${routeClass}`),
|
|
59
|
+
resolveRouteClassGateways: ({principal, routeClass}) => routeClassGatewayUnions.get(`${principal.username}\u0000${routeClass}`) ?? [],
|
|
51
60
|
resolveOpenGateways: ({principal, routeClass, target}) => [...(routeClassGateways.get(formatRouteClassRoute(principal.username, routeClass, target)) ?? [])]
|
|
52
61
|
}),
|
|
53
62
|
authorizeOpen: ({principal, gatewayId, target}) => authorizedRoutes.has(formatRoute(principal.username, gatewayId, target)),
|
|
@@ -380,3 +380,71 @@ test("a backpressured gateway does not block DATA or a new OPEN for another rout
|
|
|
380
380
|
streamA.destroy()
|
|
381
381
|
streamB.destroy()
|
|
382
382
|
})
|
|
383
|
+
|
|
384
|
+
test("hub announces route-class availability at selection and the client exposes gateway unavailability", async (t) => {
|
|
385
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-route-class-selectable-"))
|
|
386
|
+
t.after(() => rm(root, {recursive: true, force: true}))
|
|
387
|
+
const certificates = generateTestCertificates(root)
|
|
388
|
+
const targetA = await createEchoTarget("a:")
|
|
389
|
+
t.after(() => targetA.close())
|
|
390
|
+
|
|
391
|
+
const hub = await startReverseGatewayHub({
|
|
392
|
+
tokens: new StaticTokenStore([
|
|
393
|
+
{username: "home-a", token: "gateway-a-token"},
|
|
394
|
+
{username: "tensorbuzz", token: "client-token"}
|
|
395
|
+
]),
|
|
396
|
+
tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile},
|
|
397
|
+
knownGatewayIds: ["server-a-starlink"],
|
|
398
|
+
knownRouteClasses: ["starlink"],
|
|
399
|
+
authorizeGateway: ({principal, gatewayId}) => principal.username === "home-a" && gatewayId === "server-a-starlink",
|
|
400
|
+
authorizeClientGateway: () => false,
|
|
401
|
+
authorizeClientRouteClass: ({principal, routeClass}) => principal.username === "tensorbuzz" && routeClass === "starlink",
|
|
402
|
+
resolveRouteClassGateways: () => ["server-a-starlink"],
|
|
403
|
+
resolveOpenGateways: ({routeClass, target}) => (routeClass === "starlink" && target.host === targetA.host ? ["server-a-starlink"] : []),
|
|
404
|
+
authorizeOpen: ({gatewayId, target}) => gatewayId === "server-a-starlink" && target.port === targetA.port
|
|
405
|
+
})
|
|
406
|
+
t.after(() => hub.close())
|
|
407
|
+
const tls = {caFile: certificates.caCertFile, servername: "localhost"}
|
|
408
|
+
|
|
409
|
+
const client = new ReverseGatewayConnection({
|
|
410
|
+
routeClass: "starlink",
|
|
411
|
+
connectionId: "route_class_selectable_client",
|
|
412
|
+
hub: {host: "127.0.0.1", port: hub.port, token: "client-token"},
|
|
413
|
+
tls
|
|
414
|
+
})
|
|
415
|
+
t.after(() => client.close())
|
|
416
|
+
|
|
417
|
+
// No gateway registered for the route class yet: the hub must announce selectable=false
|
|
418
|
+
// and the client must surface the member as gateway-unavailable before any open is attempted.
|
|
419
|
+
await client.connect()
|
|
420
|
+
assert.equal(client.status().physicalState, "ready")
|
|
421
|
+
assert.equal(client.status().gatewayUnavailable, true)
|
|
422
|
+
|
|
423
|
+
// Once the gateway registers, a replacement selection announces selectable=true and clears the mark.
|
|
424
|
+
const gatewayA = new OutboundReverseGateway({
|
|
425
|
+
gatewayId: "server-a-starlink",
|
|
426
|
+
connectionId: "selectable_gateway_a",
|
|
427
|
+
hub: {host: "127.0.0.1", port: hub.port, token: "gateway-a-token"},
|
|
428
|
+
tls,
|
|
429
|
+
authorizeTarget: (target) => target.host === targetA.host && target.port === targetA.port,
|
|
430
|
+
reconnectDelayMs: 10,
|
|
431
|
+
maxReconnectDelayMs: 20
|
|
432
|
+
})
|
|
433
|
+
t.after(() => gatewayA.close())
|
|
434
|
+
await gatewayA.start()
|
|
435
|
+
await waitUntil(() => hub.status().healthyGateways === 1, "gateway A registration")
|
|
436
|
+
|
|
437
|
+
const replacement = new ReverseGatewayConnection({
|
|
438
|
+
routeClass: "starlink",
|
|
439
|
+
connectionId: "selectable_client_replacement",
|
|
440
|
+
hub: {host: "127.0.0.1", port: hub.port, token: "client-token"},
|
|
441
|
+
tls
|
|
442
|
+
})
|
|
443
|
+
t.after(() => replacement.close())
|
|
444
|
+
await replacement.connect()
|
|
445
|
+
assert.equal(replacement.status().gatewayUnavailable, false)
|
|
446
|
+
const stream = replacement.createConnection({host: targetA.host, port: targetA.port})
|
|
447
|
+
await onceEvent(stream, "connect")
|
|
448
|
+
assert.equal(await exchange(stream, "probe"), "a:probe")
|
|
449
|
+
stream.destroy()
|
|
450
|
+
})
|
|
@@ -71,6 +71,7 @@ class FakeMember extends EventEmitter {
|
|
|
71
71
|
this.state = "configured"
|
|
72
72
|
this.generation = 0
|
|
73
73
|
this.physicalConnects = 0
|
|
74
|
+
this.gatewayUnavailable = false
|
|
74
75
|
/** @type {Promise<void>} */
|
|
75
76
|
this.connectPromise = new Promise((resolve, reject) => {
|
|
76
77
|
this.resolveConnect = resolve
|
|
@@ -135,6 +136,7 @@ class FakeMember extends EventEmitter {
|
|
|
135
136
|
status() {
|
|
136
137
|
return {
|
|
137
138
|
physicalState: this.state,
|
|
139
|
+
gatewayUnavailable: this.gatewayUnavailable,
|
|
138
140
|
connectionGeneration: this.generation,
|
|
139
141
|
physicalConnects: this.physicalConnects,
|
|
140
142
|
reconnectAttempts: this.state === "reconnecting" ? 1 : 0,
|
|
@@ -524,6 +526,35 @@ test("25 and 100 continuously healthy opens have exact deterministic distributio
|
|
|
524
526
|
}
|
|
525
527
|
})
|
|
526
528
|
|
|
529
|
+
test("opens skip a ready member whose gateway the hub reported unavailable", async () => {
|
|
530
|
+
const {set, members} = fixture()
|
|
531
|
+
const started = set.start()
|
|
532
|
+
members.forEach((member) => member.ready())
|
|
533
|
+
await started
|
|
534
|
+
at(members, 0).gatewayUnavailable = true
|
|
535
|
+
for (let offset = 0; offset < 12; offset += 1) {
|
|
536
|
+
const socket = set.createConnection({host: "target.internal", port: 443})
|
|
537
|
+
assert.equal(gatewayOf(socket) === "starlink", false)
|
|
538
|
+
}
|
|
539
|
+
assert.deepEqual(set.status().members.map((member) => member.assignments), [0, 6, 6])
|
|
540
|
+
at(members, 0).gatewayUnavailable = false
|
|
541
|
+
const recovered = set.createConnection({host: "target.internal", port: 443})
|
|
542
|
+
assert.equal(gatewayOf(recovered), "starlink")
|
|
543
|
+
await set.close()
|
|
544
|
+
})
|
|
545
|
+
|
|
546
|
+
test("a stale gateway-unavailable mark cannot wedge the set when every ready member is marked", async () => {
|
|
547
|
+
const {set, members} = fixture()
|
|
548
|
+
const started = set.start()
|
|
549
|
+
members.forEach((member) => member.ready())
|
|
550
|
+
await started
|
|
551
|
+
members.forEach((member) => { member.gatewayUnavailable = true })
|
|
552
|
+
const socket = set.createConnection({host: "target.internal", port: 443}, (error) => assert.equal(error, null))
|
|
553
|
+
assert.equal(socket.gatewayId !== undefined, true)
|
|
554
|
+
assert.equal(set.status().pendingOpens, 0)
|
|
555
|
+
await set.close()
|
|
556
|
+
})
|
|
557
|
+
|
|
527
558
|
test("all-members reconnecting queues FIFO opens until one ready member recovers", async () => {
|
|
528
559
|
const {set, members} = fixture()
|
|
529
560
|
const started = set.start()
|