Fix file transfer, signaling reliability, and DC callback races

- wireFileRecv: replace OnMessage/OnClose channel approach with pion
  Detach API so SCTP buffers data regardless of callback timing — fixes
  0-byte files caused by pion dispatching data goroutines before OnMessage
  was registered
- yaw DC: same Detach API treatment for the control channel
- connectTo: use DetachDataChannels SettingEngine, replace stale PCs
  (Connecting/Failed/Disconnected state) instead of returning early;
  isCurrent guard in OnConnectionStateChange prevents stale-PC close
  from firing OnDisconnected
- offerByOrder: gate maybeOffer on peer ID order so only one side
  creates the yaw DC and SDP offer — fixes signaling glare causing
  triple connected (verified) logs
- pendingRecvs: promote single pendingRecv slot to map[string]*recvState
  so concurrent file offers don't overwrite each other
- peer-left: stop calling resetPeer() so active file DCs aren't closed
  before data arrives
- signaling ping: wrap conn.Ping with 10s timeout context so a dead TCP
  connection is detected within 30s rather than hanging forever
- signaling read: 3-minute read deadline forces reconnect if anchor
  silently evicts the peer from the room (idle timeout)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-07-02 17:38:48 +02:00
parent 488431eaec
commit 0fe01e146c
6 changed files with 307 additions and 74 deletions

View File

@@ -41,7 +41,7 @@ function App() {
const [scanning, setScanning] = useState(false)
const [myId, setMyId] = useState<string | null>(null)
const [addIdInput, setAddIdInput] = useState('')
const [copied, setCopied] = useState(false)
const [copied, setCopied] = useState<'snip' | 'id' | null>(null)
const sessionRef = useRef<FlitSession | null>(null)
const verifiedRef = useRef(false)
@@ -218,13 +218,14 @@ function App() {
setMode('known')
}
function copyMyId() {
function copyMyId(mode: 'snip' | 'id') {
if (!myId) return
// Build a snip the other side can paste into "Add peer"
const snip = 'flit-peer:' + btoa(JSON.stringify({ id: myId })).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
navigator.clipboard.writeText(snip).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
const text = mode === 'snip'
? 'flit-peer:' + btoa(JSON.stringify({ id: myId })).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
: myId
navigator.clipboard.writeText(text).then(() => {
setCopied(mode)
setTimeout(() => setCopied(null), 2000)
})
}
@@ -308,8 +309,11 @@ function App() {
<div className="my-id-row">
<span className="my-id-label">your ID</span>
<span className="peer-id">{myId.slice(0, 16)}</span>
<button className="btn btn-sm" onClick={copyMyId}>
{copied ? 'Copied ✓' : 'Copy snip'}
<button className="btn btn-sm" onClick={() => copyMyId('id')}>
{copied === 'id' ? 'Copied ✓' : 'Copy ID'}
</button>
<button className="btn btn-sm" onClick={() => copyMyId('snip')}>
{copied === 'snip' ? 'Copied ✓' : 'Copy snip'}
</button>
</div>
)}

View File

@@ -400,7 +400,8 @@ export class PeerConn {
this.events.fileOffer?.({ peer: this.peerId, xid: m['xid'] as string, name: m['name'] as string, size: m['size'] as number })
} else if (m['type'] === 'file-accept') {
const xid = m['xid'] as string
if (this._pushQueue.has(xid)) void this._stream(xid)
const offset = typeof m['offset'] === 'string' ? parseInt(m['offset'] as string, 10) || 0 : 0
if (this._pushQueue.has(xid)) void this._stream(xid, offset)
} else if (m['type'] === 'file-cancel') {
const xid = m['xid'] as string
if (this._recv[xid]) {
@@ -427,23 +428,22 @@ export class PeerConn {
this._dc({ type: 'file-offer', name: file.name, size: file.size, xid })
}
private async _stream(xid: string) {
private async _stream(xid: string, resumeOffset = 0) {
const file = this._pushQueue.get(xid)
if (!file) return
this._pushQueue.delete(xid)
const dc = this.pc.createDataChannel(`f:${xid}`)
dc.binaryType = 'arraybuffer'
await new Promise<void>(res => { dc.onopen = () => res() })
const buf = await file.arrayBuffer()
let offset = 0
while (offset < buf.byteLength) {
// Slice from resume offset so we only transfer the remaining bytes.
const slice = resumeOffset > 0 ? file.slice(resumeOffset) : file
const buf = await slice.arrayBuffer()
let localOffset = 0
while (localOffset < buf.byteLength) {
while (dc.bufferedAmount > 1024 * 1024) await new Promise(r => setTimeout(r, 10))
dc.send(buf.slice(offset, offset + CHUNK))
offset += CHUNK
this.events.fileSendProgress?.({ peer: this.peerId, xid, name: file.name, sent: offset, total: buf.byteLength })
// Yield to the event loop every chunk so the browser can paint
// progress — without this, small files finish in one synchronous
// tick and the UI never shows intermediate state.
dc.send(buf.slice(localOffset, localOffset + CHUNK))
localOffset += CHUNK
this.events.fileSendProgress?.({ peer: this.peerId, xid, name: file.name, sent: resumeOffset + localOffset, total: file.size })
await new Promise(r => setTimeout(r, 0))
}
dc.close()