@threadedstack/wmd-web 0.1.18 → 0.1.20

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/src/net/stack.js CHANGED
@@ -57,6 +57,22 @@ const HALF_OPEN_IDLE_MS = 10 * 60_000
57
57
  const SYNACK_GIVE_UP_MS = 60_000 // Linux: tcp_synack_retries (5), about 63 s
58
58
  const GUEST_CLOSED_IDLE_MS = 120_000 // the guest's FIN is in, the remote says nothing: its kernel forgot it (FIN_WAIT2, 60 s)
59
59
  const MAX_PENDING = 32 // handshakes not finished at once (more get a RST), so abandoned ones cannot take every relay slot
60
+ // D-551: npm's CDN ends (FIN) a connection whose TLS handshake is not done 15 s after the connect, and the relay
61
+ // connects when the guest's SYN comes. A slow guest (WebKit) can miss that before its ClientHello. A remote that
62
+ // ends a connection nothing has crossed, REDIAL_MIN_MS or more after it opened, is dialled once more instead.
63
+ const REDIAL_MIN_MS = 5_000
64
+ // D-552: a guest that runs many TLS handshakes at once (pnpm opens 16) splits its CPU among them, and in a slow guest
65
+ // (WebKit) none ends inside npm's 15 s. At most TLS_HANDSHAKES run at once: a ClientHello waits here for its turn, then
66
+ // goes out on a stream dialled again when the one it has is TLS_FRESH_MS old. A remote that ends a stream before it
67
+ // answers the ClientHello gets the hello once more on a new stream. A handshake holds its turn at most TLS_TURN_MS.
68
+ const TLS_HANDSHAKES = 4
69
+ const TLS_FRESH_MS = 3_000
70
+ const TLS_TURN_MS = 20_000
71
+ const TLS_HELLO_MAX = 16 << 10 // bytes kept for a replay
72
+ const T_NONE = 0 // no guest bytes yet
73
+ const T_WAIT = 1 // a ClientHello waits for a turn
74
+ const T_TURN = 2 // the handshake has a turn
75
+ const T_DONE = 3 // past the handshake, or not TLS
60
76
  const INTERNAL_ZONE = 'wmd.internal'
61
77
 
62
78
  const P_CONNECTING = 1 // waiting for the relay's CONNECTED
@@ -122,6 +138,14 @@ class Conn {
122
138
  this.lastRemote = 0 // the last data or end from the relay
123
139
  this.bytesIn = 0 // guest -> relay
124
140
  this.bytesOut = 0 // relay -> guest
141
+ this.redialAt = 0 // D-551: when the stream was dialled again (0: not); its answer is pending while redialing
142
+ this.redialing = false
143
+ this.dialAt = 0 // when the relay stream the guest's bytes go to connected
144
+ this.tls = T_NONE // D-552
145
+ this.tlsAt = 0 // when the handshake got its turn
146
+ this.hello = null // the guest's bytes sent before the remote answered (a replay sends them again)
147
+ this.helloBytes = 0
148
+ this.helloReplayed = false
125
149
  }
126
150
  inflight() {
127
151
  return seqDiff(this.sndNxt, this.sndUna)
@@ -154,6 +178,9 @@ export class NetStack {
154
178
  this.peers = o.peers || null // W56: the switch's guests (peer.js)
155
179
  this.now = o.now || (() => performance.now())
156
180
  this.maxConns = o.maxConns ?? 256
181
+ this.tlsMax = o.tlsHandshakes ?? TLS_HANDSHAKES
182
+ this.tlsTurns = new Set() // D-552: handshakes with a turn
183
+ this.tlsWait = [] // and those waiting for one, oldest first
157
184
  this.log = o.log || (() => {})
158
185
  this.conns = new Map() // key -> Conn
159
186
  this.byId = new Map() // relay stream id -> Conn
@@ -473,6 +500,7 @@ export class NetStack {
473
500
  /** The relay's answer to a connect. */
474
501
  onConnected(id, code, credit, detail) {
475
502
  const c = this.byId.get(id)
503
+ if (c?.redialing) return this.#redialed(c, code, credit, detail)
476
504
  if (!c || c.phase !== P_CONNECTING) {
477
505
  if (!c && code === E.OK) this.relay.abort(id) // a connect that came back after we gave up on it
478
506
  return
@@ -494,7 +522,7 @@ export class NetStack {
494
522
  if (this.stats.connectMs.length > 100) this.stats.connectMs.shift()
495
523
  c.relayCredit = credit
496
524
  c.phase = P_SYNACK
497
- c.synAckAt = this.now()
525
+ c.synAckAt = c.dialAt = this.now()
498
526
  c.sndNxt = seqAdd(c.iss, 1)
499
527
  this.#synAck(c)
500
528
  }
@@ -615,6 +643,8 @@ export class NetStack {
615
643
 
616
644
  /** Send held guest bytes to the relay as credit allows, in messages of up to MAX_DATA (one per segment cost a WebSocket message each). */
617
645
  #flushToRelay(c) {
646
+ if (c.redialing) return // D-551: the new stream has no answer yet; #redialed flushes
647
+ if (c.tls !== T_DONE && c.via === this.relay && !this.#tlsGate(c)) return
618
648
  while (c.toRelay.length && c.relayCredit > 0) {
619
649
  const n = Math.min(c.toRelayBytes, c.relayCredit, MAX_DATA)
620
650
  let part
@@ -637,6 +667,13 @@ export class NetStack {
637
667
  }
638
668
  c.toRelayBytes -= n
639
669
  c.relayCredit -= n
670
+ if (c.tls === T_TURN && !c.bytesOut && c.hello) {
671
+ // past the cap the hello is not kept at all (a replay with a gap would be garbage)
672
+ if (c.helloBytes + n <= TLS_HELLO_MAX) {
673
+ c.hello.push(part.slice())
674
+ c.helloBytes += n
675
+ } else c.hello = null
676
+ }
640
677
  c.via.data(c.id, part)
641
678
  }
642
679
  if (c.guestFin && !c.toRelay.length && !c.relayEndSent) {
@@ -676,16 +713,119 @@ export class NetStack {
676
713
  onRelayEnd(id) {
677
714
  const c = this.byId.get(id)
678
715
  if (!c) return
716
+ if (this.#redial(c) || this.#tlsReplay(c)) return
679
717
  c.remoteEnd = true
680
718
  c.lastRemote = this.now()
681
719
  this.wantsSend.add(c)
682
720
  this.#trySend(c)
683
721
  }
684
722
 
723
+ /** D-551: the remote ended a stream nothing has crossed, a while after it opened: dial it again, once, and say nothing to the guest. False when it does not qualify. */
724
+ #redial(c) {
725
+ if (c.redialAt || c.via !== this.relay || c.phase !== P_OPEN || c.bytesOut || c.bytesIn - c.toRelayBytes || c.guestFin) return false
726
+ if (this.now() - c.dialAt < REDIAL_MIN_MS) return false
727
+ this.stats.redials = (this.stats.redials || 0) + 1
728
+ this.#dialAgain(c, 'ended unused')
729
+ return true
730
+ }
731
+
732
+ /** A new relay stream for c, the old one aborted; the guest hears nothing. Its bytes wait in toRelay until #redialed. */
733
+ #dialAgain(c, why) {
734
+ this.relay.abort(c.id)
735
+ this.byId.delete(c.id)
736
+ c.id = this.nextId++
737
+ this.byId.set(c.id, c)
738
+ c.relayCredit = 0
739
+ c.redialing = true
740
+ c.redialAt = this.now()
741
+ this.#event({ ev: 'redial', why, to: `${u32ToIp(c.rip)}:${c.rport}`, afterMs: Math.round(c.redialAt - c.dialAt) })
742
+ this.relay.connect(c.id, u32ToIp(c.rip), c.rport)
743
+ }
744
+
745
+ /** D-552: may the guest's bytes go to the relay now? Sorts a stream's first bytes into TLS or not, and holds a ClientHello until it has a turn. */
746
+ #tlsGate(c) {
747
+ if (c.tls === T_TURN) {
748
+ // the guest's bytes after the server's first flight end its part of the handshake
749
+ if (c.bytesOut && c.toRelayBytes) this.#tlsDone(c)
750
+ return true
751
+ }
752
+ if (c.tls === T_WAIT) return false
753
+ if (!c.toRelayBytes) return true
754
+ const h = c.toRelay[0]
755
+ if (c.bytesOut || h[0] !== 0x16 || (h.length > 1 && h[1] !== 0x03)) {
756
+ c.tls = T_DONE // the remote spoke first, or not a TLS handshake record
757
+ return true
758
+ }
759
+ if (this.tlsTurns.size >= this.tlsMax) {
760
+ c.tls = T_WAIT
761
+ this.tlsWait.push(c)
762
+ this.stats.tlsWaits = (this.stats.tlsWaits || 0) + 1
763
+ return false
764
+ }
765
+ return this.#tlsStart(c)
766
+ }
767
+
768
+ /** The handshake gets a turn. False when its stream is dialled again first (#redialed sends the hello). */
769
+ #tlsStart(c) {
770
+ c.tls = T_TURN
771
+ c.tlsAt = this.now()
772
+ c.hello = []
773
+ c.helloBytes = 0
774
+ this.tlsTurns.add(c)
775
+ if (!c.redialing && c.phase === P_OPEN && c.bytesIn === c.toRelayBytes && this.now() - c.dialAt >= TLS_FRESH_MS) {
776
+ this.stats.tlsFresh = (this.stats.tlsFresh || 0) + 1
777
+ this.#dialAgain(c, 'fresh for a ClientHello')
778
+ return false
779
+ }
780
+ return true
781
+ }
782
+
783
+ #tlsDone(c) {
784
+ c.tls = T_DONE
785
+ c.hello = null
786
+ if (this.tlsTurns.delete(c)) this.#tlsNext()
787
+ }
788
+
789
+ /** Turns free: the oldest waiting hellos go. */
790
+ #tlsNext() {
791
+ while (this.tlsTurns.size < this.tlsMax && this.tlsWait.length) {
792
+ const c = this.tlsWait.shift()
793
+ if (c.tls !== T_WAIT || this.conns.get(c.key) !== c) continue // closed, or its 4-tuple reused by a new connection
794
+ if (this.#tlsStart(c)) this.#flushToRelay(c)
795
+ }
796
+ }
797
+
798
+ /** D-552: the remote ended or reset a stream before it answered the guest's ClientHello: send the hello once more on a new stream. */
799
+ #tlsReplay(c) {
800
+ if (c.tls !== T_TURN || c.bytesOut || c.helloReplayed || !c.hello || !c.helloBytes || c.guestFin || c.via !== this.relay) return false
801
+ c.helloReplayed = true
802
+ c.toRelay.unshift(...c.hello)
803
+ c.toRelayBytes += c.helloBytes
804
+ c.hello = []
805
+ c.helloBytes = 0
806
+ this.stats.tlsReplays = (this.stats.tlsReplays || 0) + 1
807
+ this.#dialAgain(c, 'replay the ClientHello')
808
+ return true
809
+ }
810
+
811
+ #redialed(c, code, credit, detail) {
812
+ c.redialing = false
813
+ if (code !== E.OK) return this.#close(c, `redial: ${E_NAMES[code] || code}${detail ? ` (${detail})` : ''}`, false)
814
+ c.dialAt = this.now()
815
+ c.relayCredit = credit
816
+ this.#flushToRelay(c)
817
+ if (this.#rcvWnd(c) > c.lastWndSent) {
818
+ c.needAck = true
819
+ this.acks.add(c)
820
+ }
821
+ }
822
+
685
823
  onRelayAbort(id, code, reason) {
686
824
  const c = this.byId.get(id)
687
825
  if (!c) return
826
+ if (c.redialing) return this.#redialed(c, code || E.GONE, 0, reason)
688
827
  if (c.phase === P_CONNECTING) return this.onConnected(id, code || E.GONE, 0, reason)
828
+ if (this.#tlsReplay(c)) return
689
829
  this.#event({ ev: 'abort', to: `${u32ToIp(c.rip)}:${c.rport}`, why: E_NAMES[code] || code, reason })
690
830
  this.#close(c, `relay: ${E_NAMES[code] || code} ${reason || ''}`.trim(), false)
691
831
  }
@@ -765,6 +905,12 @@ export class NetStack {
765
905
  this.byId.delete(c.id)
766
906
  this.wantsSend.delete(c)
767
907
  this.acks.delete(c)
908
+ if (c.tls === T_TURN) this.#tlsDone(c)
909
+ else if (c.tls === T_WAIT) {
910
+ c.tls = T_DONE
911
+ const i = this.tlsWait.indexOf(c)
912
+ if (i >= 0) this.tlsWait.splice(i, 1)
913
+ }
768
914
  }
769
915
 
770
916
  /** After a batch of input: one ACK per connection that needs one. */
@@ -782,6 +928,12 @@ export class NetStack {
782
928
  tick() {
783
929
  const t = this.now()
784
930
  for (const c of [...this.conns.values()]) {
931
+ if (c.redialing && t - c.redialAt > CONNECT_TIMEOUT_MS) {
932
+ this.relay.abort(c.id)
933
+ this.#redialed(c, E.TIMEOUT, 0, 'no answer from the relay')
934
+ continue
935
+ }
936
+ if (c.tls === T_TURN && t - c.tlsAt > TLS_TURN_MS) this.#tlsDone(c) // a slow handshake goes on without its turn
785
937
  if (c.phase === P_CONNECTING) {
786
938
  if (t - c.openedAt > CONNECT_TIMEOUT_MS) {
787
939
  c.via.abort(c.id)
@@ -11,6 +11,7 @@
11
11
  // copy is the layer handed to the converter Worker); a Blob is read one entry at a time, so the whole tar is
12
12
  // never in memory at once.
13
13
 
14
+ import { blobBytes } from '../blob.js'
14
15
  import { createConverter } from './converter.js'
15
16
  import { RegistryError, sha256 } from './hub.js'
16
17
  import { CONVERTER } from './pull.js'
@@ -126,7 +127,7 @@ function source(input) {
126
127
  if (input instanceof Uint8Array) return { size: input.length, read: async (off, len) => input.subarray(off, off + len) }
127
128
  if (input instanceof ArrayBuffer) return source(new Uint8Array(input))
128
129
  if (typeof Blob !== 'undefined' && input instanceof Blob) {
129
- return { size: input.size, read: async (off, len) => new Uint8Array(await input.slice(off, off + len).arrayBuffer()) }
130
+ return { size: input.size, read: (off, len) => blobBytes(input.slice(off, off + len)) }
130
131
  }
131
132
  throw new TypeError('loadImageTar: the tar as a Uint8Array, ArrayBuffer or Blob')
132
133
  }
package/wmdpack.wasm CHANGED
Binary file