package common import ( "context" cr "crypto/rand" "encoding/json" "errors" "fmt" "io" "math/rand" "net" "oc-discovery/conf" "slices" "strings" "sync" "time" oclib "cloud.o-forge.io/core/oc-lib" "github.com/libp2p/go-libp2p/core/host" "github.com/libp2p/go-libp2p/core/network" pp "github.com/libp2p/go-libp2p/core/peer" "github.com/libp2p/go-libp2p/core/protocol" ) type LongLivedStreamRecordedService[T interface{}] struct { *LongLivedPubSubService StreamRecords map[protocol.ID]map[pp.ID]*StreamRecord[T] StreamMU sync.RWMutex maxNodesConn int // AfterHeartbeat is an optional hook called after each successful heartbeat update. // The indexer sets it to republish the embedded signed record to the DHT. AfterHeartbeat func(pid pp.ID) // AfterDelete is called after gc() evicts an expired peer, outside the lock. // name and did may be empty if the HeartbeatStream had no metadata. AfterDelete func(pid pp.ID, name string, did string) } func NewStreamRecordedService[T interface{}](h host.Host, maxNodesConn int) *LongLivedStreamRecordedService[T] { service := &LongLivedStreamRecordedService[T]{ LongLivedPubSubService: NewLongLivedPubSubService(h), StreamRecords: map[protocol.ID]map[pp.ID]*StreamRecord[T]{}, maxNodesConn: maxNodesConn, } go service.StartGC(30 * time.Second) // Garbage collection is needed on every Map of Long-Lived Stream... it may be a top level redesigned go service.Snapshot(1 * time.Hour) return service } func (ix *LongLivedStreamRecordedService[T]) StartGC(interval time.Duration) { go func() { t := time.NewTicker(interval) defer t.Stop() for range t.C { ix.gc() } }() } func (ix *LongLivedStreamRecordedService[T]) gc() { ix.StreamMU.Lock() now := time.Now().UTC() if ix.StreamRecords[ProtocolHeartbeat] == nil { ix.StreamRecords[ProtocolHeartbeat] = map[pp.ID]*StreamRecord[T]{} ix.StreamMU.Unlock() return } streams := ix.StreamRecords[ProtocolHeartbeat] fmt.Println(StaticNatives, StaticIndexers, streams) type gcEntry struct { pid pp.ID name string did string } var evicted []gcEntry for pid, rec := range streams { if now.After(rec.HeartbeatStream.Expiry) || now.Sub(rec.HeartbeatStream.UptimeTracker.LastSeen) > 2*rec.HeartbeatStream.Expiry.Sub(now) { name, did := "", "" if rec.HeartbeatStream != nil { name = rec.HeartbeatStream.Name did = rec.HeartbeatStream.DID } evicted = append(evicted, gcEntry{pid, name, did}) for _, sstreams := range ix.StreamRecords { if sstreams[pid] != nil { delete(sstreams, pid) } } } } ix.StreamMU.Unlock() if ix.AfterDelete != nil { for _, e := range evicted { ix.AfterDelete(e.pid, e.name, e.did) } } } func (ix *LongLivedStreamRecordedService[T]) Snapshot(interval time.Duration) { go func() { logger := oclib.GetLogger() t := time.NewTicker(interval) defer t.Stop() for range t.C { infos := ix.snapshot() for _, inf := range infos { logger.Info().Msg(" -> " + inf.DID) } } }() } // -------- Snapshot / Query -------- func (ix *LongLivedStreamRecordedService[T]) snapshot() []*StreamRecord[T] { ix.StreamMU.Lock() defer ix.StreamMU.Unlock() out := make([]*StreamRecord[T], 0, len(ix.StreamRecords)) for _, streams := range ix.StreamRecords { for _, stream := range streams { out = append(out, stream) } } return out } func (ix *LongLivedStreamRecordedService[T]) HandleHeartbeat(s network.Stream) { logger := oclib.GetLogger() defer s.Close() dec := json.NewDecoder(s) for { ix.StreamMU.Lock() if ix.StreamRecords[ProtocolHeartbeat] == nil { ix.StreamRecords[ProtocolHeartbeat] = map[pp.ID]*StreamRecord[T]{} } streams := ix.StreamRecords[ProtocolHeartbeat] streamsAnonym := map[pp.ID]HeartBeatStreamed{} for k, v := range streams { streamsAnonym[k] = v } ix.StreamMU.Unlock() pid, hb, err := CheckHeartbeat(ix.Host, s, dec, streamsAnonym, &ix.StreamMU, ix.maxNodesConn) if err != nil { // Stream-level errors (EOF, reset, closed) mean the connection is gone // — exit so the goroutine doesn't spin forever on a dead stream. // Metric/policy errors (score too low, too many connections) are transient // — those are also stream-terminal since the stream carries one session. if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) || strings.Contains(err.Error(), "reset") || strings.Contains(err.Error(), "closed") || strings.Contains(err.Error(), "too many connections") { logger.Info().Err(err).Msg("heartbeat stream terminated, closing handler") return } logger.Warn().Err(err).Msg("heartbeat check failed, retrying on same stream") continue } ix.StreamMU.Lock() // if record already seen update last seen if rec, ok := streams[*pid]; ok { rec.DID = hb.DID if rec.HeartbeatStream == nil { rec.HeartbeatStream = hb.Stream } rec.HeartbeatStream = hb.Stream if rec.HeartbeatStream.UptimeTracker == nil { rec.HeartbeatStream.UptimeTracker = &UptimeTracker{ FirstSeen: time.Now().UTC(), LastSeen: time.Now().UTC(), } } logger.Info().Msg("A new node is updated : " + pid.String()) } else { hb.Stream.UptimeTracker = &UptimeTracker{ FirstSeen: time.Now().UTC(), LastSeen: time.Now().UTC(), } streams[*pid] = &StreamRecord[T]{ DID: hb.DID, HeartbeatStream: hb.Stream, } logger.Info().Msg("A new node is subscribed : " + pid.String()) } ix.StreamMU.Unlock() // Let the indexer republish the embedded signed record to the DHT. if ix.AfterHeartbeat != nil { ix.AfterHeartbeat(*pid) } } } func CheckHeartbeat(h host.Host, s network.Stream, dec *json.Decoder, streams map[pp.ID]HeartBeatStreamed, lock *sync.RWMutex, maxNodes int) (*pp.ID, *Heartbeat, error) { if len(h.Network().Peers()) >= maxNodes { return nil, nil, fmt.Errorf("too many connections, try another indexer") } var hb Heartbeat if err := dec.Decode(&hb); err != nil { return nil, nil, err } _, bpms, _ := getBandwidthChallengeRate(h, s.Conn().RemotePeer(), MinPayloadChallenge+int(rand.Float64()*(MaxPayloadChallenge-MinPayloadChallenge))) { pid, err := pp.Decode(hb.PeerID) if err != nil { return nil, nil, err } upTime := float64(0) isFirstHeartbeat := true lock.Lock() if rec, ok := streams[pid]; ok && rec.GetUptimeTracker() != nil { upTime = rec.GetUptimeTracker().Uptime().Hours() / float64(time.Since(TimeWatcher).Hours()) isFirstHeartbeat = false } lock.Unlock() diversity := getDiversityRate(h, hb.IndexersBinded) fmt.Println(upTime, bpms, diversity) hb.ComputeIndexerScore(upTime, bpms, diversity) // First heartbeat: uptime is always 0 so the score ceiling is 60, below the // steady-state threshold of 75. Use a lower admission threshold so new peers // can enter and start accumulating uptime. Subsequent heartbeats must meet // the full threshold once uptime is tracked. minScore := float64(50) if isFirstHeartbeat { minScore = 40 } fmt.Println(hb.Score, minScore) if hb.Score < minScore { return nil, nil, errors.New("not enough trusting value") } hb.Stream = &Stream{ Name: hb.Name, DID: hb.DID, Stream: s, Expiry: time.Now().UTC().Add(2 * time.Minute), } // here is the long-lived bidirectionnal heart bit. return &pid, &hb, err } } func getDiversityRate(h host.Host, peers []string) float64 { peers, _ = checkPeers(h, peers) diverse := []string{} for _, p := range peers { ip, err := ExtractIP(p) if err != nil { fmt.Println("NO IP", p, err) continue } div := ip.Mask(net.CIDRMask(24, 32)).String() if !slices.Contains(diverse, div) { diverse = append(diverse, div) } } if len(diverse) == 0 || len(peers) == 0 { return 1 } return float64(len(diverse) / len(peers)) } func checkPeers(h host.Host, peers []string) ([]string, []string) { concretePeer := []string{} ips := []string{} for _, p := range peers { ad, err := pp.AddrInfoFromString(p) if err != nil { continue } if PeerIsAlive(h, *ad) { concretePeer = append(concretePeer, p) if ip, err := ExtractIP(p); err == nil { ips = append(ips, ip.Mask(net.CIDRMask(24, 32)).String()) } } } return concretePeer, ips } const MaxExpectedMbps = 100.0 const MinPayloadChallenge = 512 const MaxPayloadChallenge = 2048 const BaseRoundTrip = 400 * time.Millisecond // getBandwidthChallengeRate opens a dedicated ProtocolBandwidthProbe stream to // remotePeer, sends a random payload, reads the echo, and computes throughput. // Using a separate stream avoids mixing binary data on the JSON heartbeat stream // and ensures the echo handler is actually running on the remote side. func getBandwidthChallengeRate(h host.Host, remotePeer pp.ID, payloadSize int) (bool, float64, error) { payload := make([]byte, payloadSize) if _, err := cr.Read(payload); err != nil { return false, 0, err } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() s, err := h.NewStream(ctx, remotePeer, ProtocolBandwidthProbe) if err != nil { return false, 0, err } defer s.Reset() s.SetDeadline(time.Now().Add(10 * time.Second)) start := time.Now() if _, err = s.Write(payload); err != nil { return false, 0, err } s.CloseWrite() // Half-close the write side so the handler's io.Copy sees EOF and stops. // Read the echo. response := make([]byte, payloadSize) if _, err = io.ReadFull(s, response); err != nil { return false, 0, err } duration := time.Since(start) maxRoundTrip := BaseRoundTrip + (time.Duration(payloadSize) * (100 * time.Millisecond)) mbps := float64(payloadSize*8) / duration.Seconds() / 1e6 if duration > maxRoundTrip || mbps < 5.0 { return false, float64(mbps / MaxExpectedMbps), nil } return true, float64(mbps / MaxExpectedMbps), nil } type UptimeTracker struct { FirstSeen time.Time LastSeen time.Time } func (u *UptimeTracker) Uptime() time.Duration { return time.Since(u.FirstSeen) } func (u *UptimeTracker) IsEligible(min time.Duration) bool { return u.Uptime() >= min } type StreamRecord[T interface{}] struct { DID string HeartbeatStream *Stream Record T } func (s *StreamRecord[T]) GetUptimeTracker() *UptimeTracker { if s.HeartbeatStream == nil { return nil } return s.HeartbeatStream.UptimeTracker } type Stream struct { Name string `json:"name"` DID string `json:"did"` Stream network.Stream Expiry time.Time `json:"expiry"` UptimeTracker *UptimeTracker } func (s *Stream) GetUptimeTracker() *UptimeTracker { return s.UptimeTracker } func NewStream[T interface{}](s network.Stream, did string, record T) *Stream { return &Stream{ DID: did, Stream: s, Expiry: time.Now().UTC().Add(2 * time.Minute), } } type ProtocolStream map[protocol.ID]map[pp.ID]*Stream func (ps ProtocolStream) Get(protocol protocol.ID) map[pp.ID]*Stream { if ps[protocol] == nil { ps[protocol] = map[pp.ID]*Stream{} } return ps[protocol] } func (ps ProtocolStream) Add(protocol protocol.ID, peerID *pp.ID, s *Stream) error { if ps[protocol] == nil { ps[protocol] = map[pp.ID]*Stream{} } if peerID != nil { if s != nil { ps[protocol][*peerID] = s } else { return errors.New("unable to add stream : stream missing") } } return nil } func (ps ProtocolStream) Delete(protocol protocol.ID, peerID *pp.ID) { if streams, ok := ps[protocol]; ok { if peerID != nil && streams[*peerID] != nil { streams[*peerID].Stream.Close() delete(streams, *peerID) } else { for _, s := range ps { for _, v := range s { v.Stream.Close() } } delete(ps, protocol) } } } const ( ProtocolPublish = "/opencloud/record/publish/1.0" ProtocolGet = "/opencloud/record/get/1.0" ) var TimeWatcher time.Time var StaticIndexers map[string]*pp.AddrInfo = map[string]*pp.AddrInfo{} var StreamMuIndexes sync.RWMutex var StreamIndexers ProtocolStream = ProtocolStream{} // indexerHeartbeatNudge allows replenishIndexersFromNative to trigger an immediate // heartbeat tick after adding new entries to StaticIndexers, without waiting up // to 20s for the regular ticker. Buffered(1) so the sender never blocks. var indexerHeartbeatNudge = make(chan struct{}, 1) // NudgeIndexerHeartbeat signals the indexer heartbeat goroutine to fire immediately. func NudgeIndexerHeartbeat() { select { case indexerHeartbeatNudge <- struct{}{}: default: // nudge already pending, skip } } func ConnectToIndexers(h host.Host, minIndexer int, maxIndexer int, myPID pp.ID, recordFn ...func() json.RawMessage) error { TimeWatcher = time.Now().UTC() logger := oclib.GetLogger() // If native addresses are configured, get the indexer pool from the native mesh, // then start the long-lived heartbeat goroutine toward those indexers. if conf.GetConfig().NativeIndexerAddresses != "" { if err := ConnectToNatives(h, minIndexer, maxIndexer, myPID); err != nil { return err } // Step 2: start the long-lived heartbeat goroutine toward the indexer pool. // replaceStaticIndexers/replenishIndexersFromNative update the map in-place // so this single goroutine follows all pool changes automatically. logger.Info().Msg("[native] step 2 — starting long-lived heartbeat to indexer pool") SendHeartbeat(context.Background(), ProtocolHeartbeat, conf.GetConfig().Name, h, StreamIndexers, StaticIndexers, &StreamMuIndexes, 20*time.Second, recordFn...) return nil } addresses := strings.Split(conf.GetConfig().IndexerAddresses, ",") if len(addresses) > maxIndexer { addresses = addresses[0:maxIndexer] } StreamMuIndexes.Lock() for _, indexerAddr := range addresses { ad, err := pp.AddrInfoFromString(indexerAddr) if err != nil { logger.Err(err) continue } StaticIndexers[indexerAddr] = ad } indexerCount := len(StaticIndexers) StreamMuIndexes.Unlock() SendHeartbeat(context.Background(), ProtocolHeartbeat, conf.GetConfig().Name, h, StreamIndexers, StaticIndexers, &StreamMuIndexes, 20*time.Second, recordFn...) // your indexer is just like a node for the next indexer. if indexerCount < minIndexer { return errors.New("you run a node without indexers... your gonna be isolated.") } return nil } func AddStreamProtocol(ctx *context.Context, protoS ProtocolStream, h host.Host, proto protocol.ID, id pp.ID, mypid pp.ID, force bool, onStreamCreated *func(network.Stream)) ProtocolStream { logger := oclib.GetLogger() if onStreamCreated == nil { f := func(s network.Stream) { protoS[proto][id] = &Stream{ Stream: s, Expiry: time.Now().UTC().Add(2 * time.Minute), } } onStreamCreated = &f } f := *onStreamCreated if mypid > id || force { if ctx == nil { c := context.Background() ctx = &c } if protoS[proto] == nil { protoS[proto] = map[pp.ID]*Stream{} } if protoS[proto][id] != nil { protoS[proto][id].Expiry = time.Now().Add(2 * time.Minute) } else { logger.Info().Msg("NEW STREAM Generated" + fmt.Sprintf("%v", proto) + " " + id.String()) s, err := h.NewStream(*ctx, id, proto) if err != nil { panic(err.Error()) } f(s) } } return protoS } type Heartbeat struct { Name string `json:"name"` Stream *Stream `json:"stream"` DID string `json:"did"` PeerID string `json:"peer_id"` Timestamp int64 `json:"timestamp"` IndexersBinded []string `json:"indexers_binded"` Score float64 // Record carries a fresh signed PeerRecord (JSON) so the receiving indexer // can republish it to the DHT without an extra round-trip. // Only set by nodes (not indexers heartbeating other indexers). Record json.RawMessage `json:"record,omitempty"` } func (hb *Heartbeat) ComputeIndexerScore(uptimeHours float64, bpms float64, diversity float64) { hb.Score = ((0.3 * uptimeHours) + (0.3 * bpms) + (0.4 * diversity)) * 100 } type HeartbeatInfo []struct { Info []byte `json:"info"` } const ProtocolHeartbeat = "/opencloud/heartbeat/1.0" // ProtocolBandwidthProbe is a dedicated short-lived stream used exclusively // for bandwidth/latency measurement. The handler echoes any bytes it receives. // All nodes and indexers register this handler so peers can measure them. const ProtocolBandwidthProbe = "/opencloud/probe/1.0" // HandleBandwidthProbe echoes back everything written on the stream, then closes. // It is registered by all participants so the measuring side (the heartbeat receiver) // can open a dedicated probe stream and read the round-trip latency + throughput. func HandleBandwidthProbe(s network.Stream) { defer s.Close() s.SetDeadline(time.Now().Add(10 * time.Second)) io.Copy(s, s) // echo every byte back to the sender } // SendHeartbeat starts a goroutine that sends periodic heartbeats to peers. // recordFn, when provided, is called on each tick and its output is embedded in // the heartbeat as a fresh signed PeerRecord so the receiving indexer can // republish it to the DHT without an extra round-trip. // Pass no recordFn (or nil) for indexer→indexer / native heartbeats. func SendHeartbeat(ctx context.Context, proto protocol.ID, name string, h host.Host, ps ProtocolStream, peers map[string]*pp.AddrInfo, mu *sync.RWMutex, interval time.Duration, recordFn ...func() json.RawMessage) { logger := oclib.GetLogger() // isIndexerHB is true when this goroutine drives the indexer heartbeat. // isNativeHB is true when it drives the native heartbeat. isIndexerHB := mu == &StreamMuIndexes isNativeHB := mu == &StreamNativeMu var recFn func() json.RawMessage if len(recordFn) > 0 { recFn = recordFn[0] } go func() { logger.Info().Str("proto", string(proto)).Int("peers", len(peers)).Msg("heartbeat started") t := time.NewTicker(interval) defer t.Stop() // doTick sends one round of heartbeats to the current peer snapshot. doTick := func() { // Build the heartbeat payload — snapshot current indexer addresses. StreamMuIndexes.RLock() addrs := make([]string, 0, len(StaticIndexers)) for addr := range StaticIndexers { addrs = append(addrs, addr) } StreamMuIndexes.RUnlock() hb := Heartbeat{ Name: name, PeerID: h.ID().String(), Timestamp: time.Now().UTC().Unix(), IndexersBinded: addrs, } if recFn != nil { hb.Record = recFn() } // Snapshot the peer list under a read lock so we don't hold the // write lock during network I/O. if mu != nil { mu.RLock() } snapshot := make([]*pp.AddrInfo, 0, len(peers)) for _, ix := range peers { snapshot = append(snapshot, ix) } if mu != nil { mu.RUnlock() } for _, ix := range snapshot { wasConnected := h.Network().Connectedness(ix.ID) == network.Connected if err := sendHeartbeat(ctx, h, proto, ix, hb, ps, interval*time.Second); err != nil { // Step 3: heartbeat failed — remove from pool and trigger replenish. logger.Info().Str("peer", ix.ID.String()).Str("proto", string(proto)).Msg("[native] step 3 — heartbeat failed, removing peer from pool") // Remove the dead peer and clean up its stream. // mu already covers ps when isIndexerHB (same mutex), so one // lock acquisition is sufficient — no re-entrant double-lock. if mu != nil { mu.Lock() } if ps[proto] != nil { if s, ok := ps[proto][ix.ID]; ok { if s.Stream != nil { s.Stream.Close() } delete(ps[proto], ix.ID) } } lostAddr := "" for addr, ad := range peers { if ad.ID == ix.ID { lostAddr = addr delete(peers, addr) break } } need := conf.GetConfig().MinIndexer - len(peers) remaining := len(peers) if mu != nil { mu.Unlock() } logger.Info().Int("remaining", remaining).Int("min", conf.GetConfig().MinIndexer).Int("need", need).Msg("[native] step 3 — pool state after removal") // Step 4: ask the native for the missing indexer count. if isIndexerHB && conf.GetConfig().NativeIndexerAddresses != "" { if need < 1 { need = 1 } logger.Info().Int("need", need).Msg("[native] step 3→4 — triggering replenish") go replenishIndexersFromNative(h, need) } // Native heartbeat failed — find a replacement native. // Case 1: if the dead native was also serving as an indexer, evict it // from StaticIndexers immediately without waiting for the indexer HB tick. if isNativeHB { logger.Info().Str("addr", lostAddr).Msg("[native] step 3 — native heartbeat failed, triggering native replenish") if lostAddr != "" && conf.GetConfig().NativeIndexerAddresses != "" { StreamMuIndexes.Lock() if _, wasIndexer := StaticIndexers[lostAddr]; wasIndexer { delete(StaticIndexers, lostAddr) if s := StreamIndexers[ProtocolHeartbeat]; s != nil { if stream, ok := s[ix.ID]; ok { if stream.Stream != nil { stream.Stream.Close() } delete(s, ix.ID) } } idxNeed := conf.GetConfig().MinIndexer - len(StaticIndexers) StreamMuIndexes.Unlock() if idxNeed < 1 { idxNeed = 1 } logger.Info().Str("addr", lostAddr).Msg("[native] dead native evicted from indexer pool, triggering replenish") go replenishIndexersFromNative(h, idxNeed) } else { StreamMuIndexes.Unlock() } } go replenishNativesFromPeers(h, lostAddr, proto) } } else { // Case 2: native-as-indexer reconnected after a restart. // If the peer was disconnected before this tick and the heartbeat just // succeeded (transparent reconnect), the native may have restarted with // blank state (responsiblePeers empty). Evict it from StaticIndexers and // re-request an assignment so the native re-tracks us properly and // runOffloadLoop can eventually migrate us to real indexers. if !wasConnected && isIndexerHB && conf.GetConfig().NativeIndexerAddresses != "" { StreamNativeMu.RLock() isNativeIndexer := false for _, ad := range StaticNatives { if ad.ID == ix.ID { isNativeIndexer = true break } } StreamNativeMu.RUnlock() if isNativeIndexer { if mu != nil { mu.Lock() } if ps[proto] != nil { if s, ok := ps[proto][ix.ID]; ok { if s.Stream != nil { s.Stream.Close() } delete(ps[proto], ix.ID) } } reconnectedAddr := "" for addr, ad := range peers { if ad.ID == ix.ID { reconnectedAddr = addr delete(peers, addr) break } } idxNeed := conf.GetConfig().MinIndexer - len(peers) if mu != nil { mu.Unlock() } if idxNeed < 1 { idxNeed = 1 } logger.Info().Str("addr", reconnectedAddr).Str("peer", ix.ID.String()).Msg( "[native] native-as-indexer reconnected after restart — evicting and re-requesting assignment") go replenishIndexersFromNative(h, idxNeed) } } logger.Debug().Str("peer", ix.ID.String()).Str("proto", string(proto)).Msg("[native] step 2 — heartbeat sent ok") } } } for { select { case <-t.C: doTick() case <-indexerHeartbeatNudge: if isIndexerHB { logger.Info().Msg("[native] step 2 — nudge received, heartbeating new indexers immediately") doTick() } case <-nativeHeartbeatNudge: if isNativeHB { logger.Info().Msg("[native] native nudge received, heartbeating replacement native immediately") doTick() } case <-ctx.Done(): return } } }() } type ProtocolInfo struct { PersistantStream bool WaitResponse bool TTL time.Duration } func TempStream(h host.Host, ad pp.AddrInfo, proto protocol.ID, did string, streams ProtocolStream, pts map[protocol.ID]*ProtocolInfo, mu *sync.RWMutex) (ProtocolStream, error) { expiry := 2 * time.Second if pts[proto] != nil { expiry = pts[proto].TTL } ctxTTL, _ := context.WithTimeout(context.Background(), expiry) if h.Network().Connectedness(ad.ID) != network.Connected { if err := h.Connect(ctxTTL, ad); err != nil { return streams, err } } if streams[proto] != nil && streams[proto][ad.ID] != nil { return streams, nil } else if s, err := h.NewStream(ctxTTL, ad.ID, proto); err == nil { mu.Lock() if streams[proto] == nil { streams[proto] = map[pp.ID]*Stream{} } mu.Unlock() time.AfterFunc(expiry, func() { mu.Lock() delete(streams[proto], ad.ID) mu.Unlock() }) mu.Lock() streams[proto][ad.ID] = &Stream{ DID: did, Stream: s, Expiry: time.Now().UTC().Add(expiry), } mu.Unlock() return streams, nil } else { return streams, err } } func sendHeartbeat(ctx context.Context, h host.Host, proto protocol.ID, p *pp.AddrInfo, hb Heartbeat, ps ProtocolStream, interval time.Duration) error { logger := oclib.GetLogger() if ps[proto] == nil { ps[proto] = map[pp.ID]*Stream{} } streams := ps[proto] pss, exists := streams[p.ID] ctxTTL, cancel := context.WithTimeout(ctx, 3*interval) defer cancel() // Connect si nécessaire if h.Network().Connectedness(p.ID) != network.Connected { if err := h.Connect(ctxTTL, *p); err != nil { logger.Err(err) return err } exists = false // on devra recréer le stream } // Crée le stream si inexistant ou fermé if !exists || pss.Stream == nil { logger.Info().Msg("New Stream engaged as Heartbeat " + fmt.Sprintf("%v", proto) + " " + p.ID.String()) s, err := h.NewStream(ctx, p.ID, proto) if err != nil { logger.Err(err) return err } pss = &Stream{ Stream: s, Expiry: time.Now().UTC().Add(2 * time.Minute), } streams[p.ID] = pss } // Envoie le heartbeat ss := json.NewEncoder(pss.Stream) err := ss.Encode(&hb) if err != nil { pss.Stream.Close() pss.Stream = nil // recréera au prochain tick return err } pss.Expiry = time.Now().UTC().Add(2 * time.Minute) return nil }