diff --git a/cmd/traffic/main.go b/cmd/traffic/main.go new file mode 100644 index 00000000..c49875d5 --- /dev/null +++ b/cmd/traffic/main.go @@ -0,0 +1,19 @@ +package main + +import ( + "os" + + "github.com/named-data/ndnd/tools" + "github.com/spf13/cobra" +) + +func main() { + root := &cobra.Command{ + Use: "ndnd-traffic", + Short: "NDN traffic generator", + } + root.AddCommand(tools.CmdTraffic()) + if err := root.Execute(); err != nil { + os.Exit(1) + } +} diff --git a/dv/config/config.go b/dv/config/config.go index 30427bfa..9da73ee7 100644 --- a/dv/config/config.go +++ b/dv/config/config.go @@ -6,6 +6,7 @@ import ( "time" enc "github.com/named-data/ndnd/std/encoding" + "github.com/named-data/ndnd/std/ndn" mgmt "github.com/named-data/ndnd/std/ndn/mgmt_2022" ) @@ -42,6 +43,11 @@ type Config struct { // List of permanent neighbors. Neighbors []Neighbor `json:"neighbors"` + // Pre-built KeyChain (bypasses KeyChainUri if set). + KeyChain ndn.KeyChain `json:"-"` + // Pre-built Store (bypasses internal store creation if set). + Store ndn.Store `json:"-"` + // Parsed Global Prefix networkNameN enc.Name // Parsed Router Prefix diff --git a/dv/dv/advert_data.go b/dv/dv/advert_data.go index eaf6b939..db40c11e 100644 --- a/dv/dv/advert_data.go +++ b/dv/dv/advert_data.go @@ -33,7 +33,7 @@ func (a *advertModule) generate() { a.objDir.Evict(a.dv.client) // Notify neighbors with sync for new advertisement - go a.sendSyncInterest() + a.dv.GoFunc(func() { a.sendSyncInterest() }) } // (AI GENERATED DESCRIPTION): Fetches a neighbor’s DV advertisement Data packet (retrying on error) and invokes `dataHandler` with its content when the neighbor’s boot time and sequence number match the expected values. @@ -56,14 +56,14 @@ func (a *advertModule) dataFetch(nName enc.Name, bootTime uint64, seqNo uint64) a.dv.client.Consume(advName, func(state ndn.ConsumeState) { if err := state.Error(); err != nil { log.Warn(a, "Failed to fetch advertisement", "name", state.Name(), "err", err) - time.AfterFunc(1*time.Second, func() { + a.dv.engine.Timer().Schedule(1*time.Second, func() { a.dataFetch(nName, bootTime, seqNo) }) return } // Process the advertisement - go a.dataHandler(nName, seqNo, state.Content()) + a.dv.GoFunc(func() { a.dataHandler(nName, seqNo, state.Content()) }) }) } @@ -92,5 +92,5 @@ func (a *advertModule) dataHandler(nName enc.Name, seqNo uint64, data enc.Wire) // Update the local advertisement list ns.Advert = advert - go a.dv.updateRib(ns) + a.dv.GoFunc(func() { a.dv.updateRib(ns) }) } diff --git a/dv/dv/advert_sync.go b/dv/dv/advert_sync.go index 5291a244..9d2778b0 100644 --- a/dv/dv/advert_sync.go +++ b/dv/dv/advert_sync.go @@ -143,7 +143,7 @@ func (a *advertModule) OnSyncInterest(args ndn.InterestHandlerArgs, active bool) } // Process the state vector - go a.onStateVector(params.StateVector, args.IncomingFaceId.Unwrap(), active) + a.dv.GoFunc(func() { a.onStateVector(params.StateVector, args.IncomingFaceId.Unwrap(), active) }) }, }) } @@ -197,13 +197,13 @@ func (a *advertModule) onStateVector(sv *spec_svs.StateVector, faceId uint64, ac ns.AdvertBoot = entry.BootstrapTime ns.AdvertSeq = entry.SeqNo - time.AfterFunc(10*time.Millisecond, func() { // debounce + a.dv.engine.Timer().Schedule(10*time.Millisecond, func() { // debounce a.dataFetch(node.Name, entry.BootstrapTime, entry.SeqNo) }) } // Update FIB if needed if fibDirty { - go a.dv.updateFib() + a.dv.GoFunc(a.dv.updateFib) } } diff --git a/dv/dv/router.go b/dv/dv/router.go index 59b7ce41..bda99ff3 100644 --- a/dv/dv/router.go +++ b/dv/dv/router.go @@ -7,6 +7,7 @@ import ( "github.com/named-data/ndnd/dv/config" "github.com/named-data/ndnd/dv/nfdc" "github.com/named-data/ndnd/dv/table" + "github.com/named-data/ndnd/fw/core" enc "github.com/named-data/ndnd/std/encoding" "github.com/named-data/ndnd/std/log" "github.com/named-data/ndnd/std/ndn" @@ -37,6 +38,19 @@ type Router struct { // single mutex for all operations mutex sync.Mutex + // GoFunc dispatches work asynchronously. + // In production, this launches a goroutine. + // In simulation, this schedules via the simulation clock. + GoFunc func(func()) + + // NowFunc returns current time. Defaults to time.Now. + // In simulation, returns simulation clock time. + NowFunc func() time.Time + + // AfterFunc schedules f after d. Returns cancel function. + // Defaults to time.AfterFunc wrapper. + AfterFunc func(time.Duration, func()) func() + // channel to stop the DV stop chan bool // heartbeat for outgoing Advertisements @@ -70,12 +84,29 @@ func NewRouter(config *config.Config, engine ndn.Engine) (*Router, error) { return nil, err } - // Create packet store - store := storage.NewMemoryStore() + // Create packet store (use pre-built store if provided) + var store ndn.Store + if config.Store != nil { + store = config.Store + } else { + store = storage.NewMemoryStore() + } // Create security configuration var trust *sec.TrustConfig = nil - if config.KeyChainUri == "insecure" { + if config.KeyChain != nil { + // Use pre-built keychain (e.g. from simulation trust setup) + schema, err := trust_schema.NewLvsSchema(config.SchemaBytes()) + if err != nil { + return nil, err + } + anchors := config.TrustAnchorNames() + trust, err = sec.NewTrustConfig(config.KeyChain, schema, anchors) + if err != nil { + return nil, err + } + trust.UseDataNameFwHint = true + } else if config.KeyChainUri == "insecure" { log.Warn(nil, "Security is disabled - insecure mode") } else { kc, err := keychain.NewKeyChain(config.KeyChainUri, store) @@ -98,25 +129,34 @@ func NewRouter(config *config.Config, engine ndn.Engine) (*Router, error) { // Create the DV router dv := &Router{ - engine: engine, - config: config, - trust: trust, - client: object.NewClient(engine, store, trust), - nfdc: nfdc.NewNfdMgmtThread(engine), - mutex: sync.Mutex{}, + engine: engine, + config: config, + trust: trust, + client: object.NewClient(engine, store, trust), + nfdc: nfdc.NewNfdMgmtThread(engine), + mutex: sync.Mutex{}, + GoFunc: func(f func()) { go f() }, + NowFunc: core.Now, + AfterFunc: func(d time.Duration, f func()) func() { + t := time.AfterFunc(d, f) + return func() { t.Stop() } + }, } - // Initialize advertisement module + // Initialize advertisement module. + // NOTE: bootTime must NOT be computed here — NowFunc may still point to + // the real wall clock at construction time. For production Start() and + // simulation Init(), bootTime is computed lazily via dv.NowFunc() at the + // start of those methods, after any override (e.g. SimDvRouter) is in place. + // createPrefixTable() is also deferred for the same reason: it embeds + // bootTime into pfxSvs, which would then compare incoming BootstrapTimes + // against the wrong clock. dv.advert = advertModule{ - dv: dv, - bootTime: uint64(time.Now().Unix()), - seq: 0, - objDir: storage.NewMemoryFifoDir(32), // keep last few advertisements + dv: dv, + seq: 0, + objDir: storage.NewMemoryFifoDir(32), // keep last few advertisements } - // Create prefix table - dv.createPrefixTable() - // Create DV tables dv.neighbors = table.NewNeighborTable(config, dv.nfdc) dv.rib = table.NewRib(config) @@ -135,6 +175,11 @@ func (dv *Router) Start() (err error) { log.Info(dv, "Starting DV router", "version", utils.NDNdVersion) defer log.Info(dv, "Stopped DV router") + // Lazily initialize bootTime and prefix table so that any NowFunc + // override (e.g. from simulation) is in effect before pfxSvs is created. + dv.advert.bootTime = max(uint64(dv.NowFunc().Unix()), 1) + dv.createPrefixTable() + // Initialize channels dv.stop = make(chan bool, 1) @@ -153,7 +198,7 @@ func (dv *Router) Start() (err error) { defer dv.client.Stop() // Start management thread - go dv.nfdc.Start() + dv.GoFunc(func() { dv.nfdc.Start() }) defer dv.nfdc.Stop() // Configure face @@ -194,6 +239,71 @@ func (dv *Router) Stop() { dv.stop <- true } +// Init initializes the DV router without blocking. +// This is the simulation-compatible variant of Start() — it performs all +// setup (faces, handlers, sync groups, initial advertisement) but returns +// immediately instead of entering a ticker-driven select loop. +// The caller is responsible for periodically calling RunHeartbeat() and +// RunDeadcheck() using the simulation clock. +func (dv *Router) Init() error { + log.Info(dv, "Initializing DV router (sim)", "version", utils.NDNdVersion) + + // Lazily initialize bootTime and prefix table so that the NowFunc + // override installed by SimDvRouter (clock.Now) is used instead of the + // wall clock. If bootTime were set at NewRouter() time, it would carry + // the real Unix timestamp (~1773989593 in 2026), which SVS on every peer + // would reject as "far future" because the simulation clock starts near + // the Unix epoch. + dv.advert.bootTime = max(uint64(dv.NowFunc().Unix()), 1) + dv.createPrefixTable() + + // Start object client + dv.client.Start() + + // Register interest handlers + if err := dv.register(); err != nil { + return err + } + + // Start sync groups + dv.pfxSvs.Start() + + // Add self to the RIB and make initial advertisement + dv.rib.Set(dv.config.RouterName(), dv.config.RouterName(), 0) + dv.advert.generate() + + // Initialize prefix table + dv.pfx.Reset() + + return nil +} + +// RunHeartbeat sends a sync Interest to all neighbors. +// In production this is driven by time.Ticker; in simulation it is +// called by the simulation clock scheduler. +func (dv *Router) RunHeartbeat() { + dv.advert.sendSyncInterest() +} + +// RunDeadcheck checks for dead neighbors and prunes routes. +// In production this is driven by time.Ticker; in simulation it is +// called by the simulation clock scheduler. +func (dv *Router) RunDeadcheck() { + dv.checkDeadNeighbors() +} + +// Cleanup tears down the DV router (simulation variant of deferred cleanup in Start). +func (dv *Router) Cleanup() { + dv.pfxSvs.Stop() + dv.client.Stop() + log.Info(dv, "Cleaned up DV router (sim)") +} + +// Nfdc returns the NFD management thread. +func (dv *Router) Nfdc() *nfdc.NfdMgmtThread { + return dv.nfdc +} + // Configure the face to forwarder. func (dv *Router) configureFace() (err error) { // Enable local fields on face. This includes incoming face indication. @@ -215,7 +325,7 @@ func (dv *Router) register() (err error) { // Advertisement Sync (active) err = dv.engine.AttachHandler(dv.config.AdvertisementSyncActivePrefix(), func(args ndn.InterestHandlerArgs) { - go dv.advert.OnSyncInterest(args, true) + dv.GoFunc(func() { dv.advert.OnSyncInterest(args, true) }) }) if err != nil { return err @@ -224,7 +334,7 @@ func (dv *Router) register() (err error) { // Advertisement Sync (passive) err = dv.engine.AttachHandler(dv.config.AdvertisementSyncPassivePrefix(), func(args ndn.InterestHandlerArgs) { - go dv.advert.OnSyncInterest(args, false) + dv.GoFunc(func() { dv.advert.OnSyncInterest(args, false) }) }) if err != nil { return err @@ -233,7 +343,7 @@ func (dv *Router) register() (err error) { // Router management err = dv.engine.AttachHandler(dv.config.MgmtPrefix(), func(args ndn.InterestHandlerArgs) { - go dv.mgmtOnInterest(args) + dv.GoFunc(func() { dv.mgmtOnInterest(args) }) }) if err != nil { return err @@ -355,6 +465,9 @@ func (dv *Router) createPrefixTable() { Client: dv.client, GroupPrefix: dv.config.PrefixTableGroupPrefix(), BootTime: dv.advert.bootTime, + GoFunc: func(f func()) { dv.GoFunc(f) }, + NowFunc: func() time.Time { return dv.NowFunc() }, + AfterFunc: func(d time.Duration, f func()) func() { return dv.AfterFunc(d, f) }, }, Snapshot: &ndn_sync.SnapshotNodeLatest{ Client: dv.client, @@ -373,5 +486,7 @@ func (dv *Router) createPrefixTable() { if _, _, err := dv.pfxSvs.Publish(w); err != nil { log.Error(dv, "Failed to publish prefix table update", "err", err) } + }, table.PrefixTableOptions{ + NowFunc: dv.NowFunc, }) } diff --git a/dv/dv/table_algo.go b/dv/dv/table_algo.go index a69668d5..48001487 100644 --- a/dv/dv/table_algo.go +++ b/dv/dv/table_algo.go @@ -62,7 +62,7 @@ func (dv *Router) updateRib(ns *table.NeighborState) { // If advert changed, increment sequence number if dirty { - go dv.postUpdateRib() + dv.GoFunc(dv.postUpdateRib) } } @@ -87,7 +87,7 @@ func (dv *Router) checkDeadNeighbors() { } if dirty { - go dv.postUpdateRib() + dv.GoFunc(dv.postUpdateRib) } } @@ -129,6 +129,16 @@ func (dv *Router) updateFib() { Append(router.Name()...) register(proute, fes, 0) + // Add entry for the router's DV advertisement data namespace. + // advert_data.dataFetch() consumes names under + // /localhop//DV/ADV// and expects forwarding to + // follow the same nexthops as the router itself. + advRoute := enc.LOCALHOP. + Append(router.Name()...). + Append(enc.NewKeywordComponent("DV")). + Append(enc.NewKeywordComponent("ADV")) + register(advRoute, fes, 0) + // Add entries to all prefixes announced by this router for _, prefix := range dv.pfx.GetRouter(router.Name()).Prefixes { // Use the same nexthop entries as the exit router itself @@ -162,6 +172,12 @@ func (dv *Router) updatePrefixSubs() { log.Info(dv, "Router is now reachable", "name", router.Name()) dv.pfxSubs[hash] = router.Name() + table.NotifyRouterReachable(table.RouterReachableEvent{ + At: dv.NowFunc(), + NodeRouter: dv.config.RouterName(), + ReachableRouter: router.Name(), + }) + dv.pfxSvs.SubscribePublisher(router.Name(), func(sp sync.SvsPub) { dv.mutex.Lock() defer dv.mutex.Unlock() @@ -169,7 +185,7 @@ func (dv *Router) updatePrefixSubs() { // Both snapshots and normal data are handled the same way if dirty := dv.pfx.Apply(sp.Content); dirty { // Update the local fib if prefix table changed - go dv.updateFib() // expensive + dv.GoFunc(dv.updateFib) // expensive } }) } diff --git a/dv/nfdc/nfdc.go b/dv/nfdc/nfdc.go index 611a7dc1..456e8d93 100644 --- a/dv/nfdc/nfdc.go +++ b/dv/nfdc/nfdc.go @@ -23,6 +23,9 @@ type NfdMgmtThread struct { channel chan NfdMgmtCmd // stop the management thread stop chan bool + // If true, Exec() runs commands synchronously instead of queuing. + // Used in simulation where goroutines are not available. + Synchronous bool } // (AI GENERATED DESCRIPTION): Creates a new NfdMgmtThread with the given ndn.Engine, initializing its command channel (buffered with 4096) and stop channel for thread control. @@ -67,7 +70,16 @@ func (m *NfdMgmtThread) Stop() { } // (AI GENERATED DESCRIPTION): Queues a management command to the NfdMgmtThread by sending it through its command channel. +// In synchronous mode, the command is executed immediately on the caller's thread. func (m *NfdMgmtThread) Exec(mgmt_cmd NfdMgmtCmd) { + if m.Synchronous { + _, err := m.engine.ExecMgmtCmd(mgmt_cmd.Module, mgmt_cmd.Cmd, mgmt_cmd.Args) + if err != nil { + log.Error(m, "Forwarder command failed (sync)", "err", err, + "module", mgmt_cmd.Module, "cmd", mgmt_cmd.Cmd) + } + return + } m.channel <- mgmt_cmd } diff --git a/dv/table/neighbor_table.go b/dv/table/neighbor_table.go index 7a3f53f8..ffa48660 100644 --- a/dv/table/neighbor_table.go +++ b/dv/table/neighbor_table.go @@ -6,6 +6,7 @@ import ( "github.com/named-data/ndnd/dv/config" "github.com/named-data/ndnd/dv/nfdc" "github.com/named-data/ndnd/dv/tlv" + "github.com/named-data/ndnd/fw/core" enc "github.com/named-data/ndnd/std/encoding" "github.com/named-data/ndnd/std/log" mgmt "github.com/named-data/ndnd/std/ndn/mgmt_2022" @@ -80,7 +81,7 @@ func (nt *NeighborTable) Add(name enc.Name) *NeighborState { AdvertSeq: 0, Advert: nil, - lastSeen: time.Now(), + lastSeen: core.Now(), faceId: 0, } nt.neighbors[name.Hash()] = neighbor @@ -107,7 +108,7 @@ func (nt *NeighborTable) GetAll() []*NeighborState { // (AI GENERATED DESCRIPTION): Determines whether a neighbor is considered dead by comparing the time elapsed since its last seen timestamp to the router’s configured dead interval. func (ns *NeighborState) IsDead() bool { - return time.Since(ns.lastSeen) > ns.nt.config.RouterDeadInterval() + return core.Now().Sub(ns.lastSeen) > ns.nt.config.RouterDeadInterval() } // Call this when a ping is received from a face. @@ -123,7 +124,7 @@ func (ns *NeighborState) RecvPing(faceId uint64, active bool) (error, bool) { // Update last seen time for neighbor // Note that we skip this when the face is active and the ping is passive. // This is because we want to detect if the active face is removed. - ns.lastSeen = time.Now() + ns.lastSeen = core.Now() // If face ID has changed, re-register face. if ns.faceId != faceId { diff --git a/dv/table/prefix_table.go b/dv/table/prefix_table.go index fa4255ed..a35eee31 100644 --- a/dv/table/prefix_table.go +++ b/dv/table/prefix_table.go @@ -2,6 +2,8 @@ package table import ( "slices" + "sync" + "time" "github.com/named-data/ndnd/dv/config" "github.com/named-data/ndnd/dv/tlv" @@ -12,10 +14,46 @@ import ( type PrefixTable struct { config *config.Config publish func(enc.Wire) + now func() time.Time routers map[uint64]*PrefixTableRouter me *PrefixTableRouter } +type PrefixTableOptions struct { + NowFunc func() time.Time +} + +type PrefixEventKind int + +const ( + PrefixEventGlobalAnnounce PrefixEventKind = iota + PrefixEventAddRemotePrefix +) + +type PrefixEvent struct { + Kind PrefixEventKind + At time.Time + Name enc.Name + Router enc.Name +} + +var ( + prefixEventObserverMu sync.RWMutex + prefixEventObserver func(PrefixEvent) +) + +func SetPrefixEventObserver(observer func(PrefixEvent)) { + prefixEventObserverMu.Lock() + prefixEventObserver = observer + prefixEventObserverMu.Unlock() +} + +func getPrefixEventObserver() func(PrefixEvent) { + prefixEventObserverMu.RLock() + defer prefixEventObserverMu.RUnlock() + return prefixEventObserver +} + type PrefixTableRouter struct { Prefixes map[string]*PrefixEntry } @@ -34,10 +72,16 @@ type PrefixNextHop struct { } // (AI GENERATED DESCRIPTION): Creates and returns a new PrefixTable, initializing its configuration, publish callback, router registry, and setting the local router based on the provided configuration. -func NewPrefixTable(config *config.Config, publish func(enc.Wire)) *PrefixTable { +func NewPrefixTable(config *config.Config, publish func(enc.Wire), opts ...PrefixTableOptions) *PrefixTable { + nowFn := time.Now + if len(opts) > 0 && opts[0].NowFunc != nil { + nowFn = opts[0].NowFunc + } + pt := &PrefixTable{ config: config, publish: publish, + now: nowFn, routers: make(map[uint64]*PrefixTableRouter), me: nil, } @@ -149,6 +193,13 @@ func (pt *PrefixTable) publishEntry(hash string) { if entry.Cost < config.CostPfxInfinity { log.Info(pt, "Global announce", "name", entry.Name, "cost", entry.Cost) + if obs := getPrefixEventObserver(); obs != nil { + obs(PrefixEvent{ + Kind: PrefixEventGlobalAnnounce, + At: pt.now(), + Name: entry.Name.Clone(), + }) + } op := tlv.PrefixOpList{ ExitRouter: &tlv.Destination{Name: pt.config.RouterName()}, PrefixOpAdds: []*tlv.PrefixOpAdd{{ @@ -191,6 +242,14 @@ func (pt *PrefixTable) Apply(wire enc.Wire) (dirty bool) { for _, add := range ops.PrefixOpAdds { log.Info(pt, "Add remote prefix", "router", ops.ExitRouter.Name, "name", add.Name, "cost", add.Cost) + if obs := getPrefixEventObserver(); obs != nil { + obs(PrefixEvent{ + Kind: PrefixEventAddRemotePrefix, + At: pt.now(), + Name: add.Name.Clone(), + Router: ops.ExitRouter.Name.Clone(), + }) + } router.Prefixes[add.Name.TlvStr()] = &PrefixEntry{ Name: add.Name.Clone(), Cost: add.Cost, diff --git a/dv/table/router_event.go b/dv/table/router_event.go new file mode 100644 index 00000000..4c293b37 --- /dev/null +++ b/dv/table/router_event.go @@ -0,0 +1,43 @@ +package table + +import ( + "sync" + "time" + + enc "github.com/named-data/ndnd/std/encoding" +) + +// RouterReachableEvent is fired when a DV node first learns a route to +// another router (the router becomes reachable in the RIB). +type RouterReachableEvent struct { + // At is the timestamp when the router became reachable (sim or wall clock). + At time.Time + // NodeRouter is the router name of the observing node. + NodeRouter enc.Name + // ReachableRouter is the router name that just became reachable. + ReachableRouter enc.Name +} + +var ( + routerReachableObserverMu sync.RWMutex + routerReachableObserver func(RouterReachableEvent) +) + +// SetRouterReachableObserver registers a callback invoked each time a DV +// router discovers that another router has become reachable. +func SetRouterReachableObserver(observer func(RouterReachableEvent)) { + routerReachableObserverMu.Lock() + routerReachableObserver = observer + routerReachableObserverMu.Unlock() +} + +// NotifyRouterReachable fires the registered observer with the given event. +// Safe to call from any goroutine. No-op if no observer is registered. +func NotifyRouterReachable(ev RouterReachableEvent) { + routerReachableObserverMu.RLock() + obs := routerReachableObserver + routerReachableObserverMu.RUnlock() + if obs != nil { + obs(ev) + } +} diff --git a/fw/core/clock.go b/fw/core/clock.go new file mode 100644 index 00000000..d6ea6c31 --- /dev/null +++ b/fw/core/clock.go @@ -0,0 +1,12 @@ +package core + +import "time" + +// NowFunc returns the current time. Defaults to time.Now. +// Override this for simulation to return ns-3 simulation time. +var NowFunc = time.Now + +// Now returns the current time using NowFunc. +func Now() time.Time { + return NowFunc() +} diff --git a/fw/fw/bestroute.go b/fw/fw/bestroute.go index d1be105e..f676d826 100644 --- a/fw/fw/bestroute.go +++ b/fw/fw/bestroute.go @@ -74,7 +74,7 @@ func (s *BestRoute) AfterReceiveInterest( // Sort nexthops by cost and send to best-possible nexthop sort.Slice(nexthops, func(i, j int) bool { return nexthops[i].Cost < nexthops[j].Cost }) - now := time.Now() + now := core.Now() for pass := range 2 { for _, nh := range nexthops { // In the first pass, skip hops that already have a out record diff --git a/fw/fw/multicast.go b/fw/fw/multicast.go index 4f426a3b..c149675a 100644 --- a/fw/fw/multicast.go +++ b/fw/fw/multicast.go @@ -71,7 +71,7 @@ func (s *Multicast) AfterReceiveInterest( // If there is an out record less than suppression interval ago, drop the // retransmission to suppress it (only if the nonce is different) - now := time.Now() + now := core.Now() for _, outRecord := range pitEntry.OutRecords() { if outRecord.LatestNonce != packet.L3.Interest.NonceV.Unwrap() && outRecord.LatestTimestamp.Add(MulticastSuppressionTime).After(now) { diff --git a/fw/fw/thread.go b/fw/fw/thread.go index a301f01c..61e1800c 100644 --- a/fw/fw/thread.go +++ b/fw/fw/thread.go @@ -12,7 +12,6 @@ import ( "fmt" "runtime" "sync/atomic" - "time" "github.com/named-data/ndnd/fw/core" "github.com/named-data/ndnd/fw/defn" @@ -64,6 +63,7 @@ type Thread struct { threadID int pending chan *defn.Pkt pitCS table.PitCsTable + fib table.FibStrategy // per-thread FIB override, nil = use global strategies map[uint64]Strategy deadNonceList *table.DeadNonceList shouldQuit chan interface{} @@ -103,6 +103,19 @@ func (t *Thread) GetID() int { return t.threadID } +// SetFib sets a per-thread FIB override. If nil, the global FibStrategyTable is used. +func (t *Thread) SetFib(fib table.FibStrategy) { + t.fib = fib +} + +// Fib returns the FIB used by this thread. +func (t *Thread) Fib() table.FibStrategy { + if t.fib != nil { + return t.fib + } + return table.FibStrategyTable +} + // Counters returns the counters for this forwarding thread func (t *Thread) Counters() defn.FWThreadCounters { return defn.FWThreadCounters{ @@ -172,6 +185,23 @@ func (t *Thread) QueueData(data *defn.Pkt) { } } +// ProcessPacket synchronously processes a single packet. +// This is used by the simulation layer to avoid goroutine-based dispatch. +func (t *Thread) ProcessPacket(pkt *defn.Pkt) { + if pkt.L3.Interest != nil { + t.processIncomingInterest(pkt) + } else if pkt.L3.Data != nil { + t.processIncomingData(pkt) + } +} + +// RunMaintenance runs periodic PIT/CS and dead nonce list maintenance. +// This is used by the simulation layer instead of ticker-based updates. +func (t *Thread) RunMaintenance() { + t.deadNonceList.RemoveExpiredEntries() + t.pitCS.Update() +} + // (AI GENERATED DESCRIPTION): Processes an incoming Interest packet: verifies its validity, enforces hop limits and scope, checks for nonces and dead‑nonce loops, updates the PIT and content store, selects and filters next‑hops via the FIB, and forwards the Interest according to the chosen forwarding strategy. func (t *Thread) processIncomingInterest(packet *defn.Pkt) { interest := packet.L3.Interest @@ -250,7 +280,7 @@ func (t *Thread) processIncomingInterest(packet *defn.Pkt) { } // Get strategy for name - strategyName := table.FibStrategyTable.FindStrategyEnc(interest.Name()) + strategyName := t.Fib().FindStrategyEnc(interest.Name()) strategy := t.strategies[strategyName.Hash()] // Add in-record and determine if already pending @@ -274,7 +304,7 @@ func (t *Thread) processIncomingInterest(packet *defn.Pkt) { csData, csWire, err := csEntry.Copy() if csData != nil && csWire != nil { // Mark PIT entry as expired - table.UpdateExpirationTimer(pitEntry, time.Now()) + table.UpdateExpirationTimer(pitEntry, core.Now()) // Create the pending packet structure packet.L3.Data = csData @@ -325,7 +355,7 @@ func (t *Thread) processIncomingInterest(packet *defn.Pkt) { } // Query the FIB for all possible nexthops - nexthops := table.FibStrategyTable.FindNextHopsEnc(lookupName) + nexthops := t.Fib().FindNextHopsEnc(lookupName) // If the first component is /localhop, we do not forward interests received // on non-local faces to non-local faces @@ -467,7 +497,7 @@ func (t *Thread) processIncomingData(packet *defn.Pkt) { } // Get strategy for name - strategyName := table.FibStrategyTable.FindStrategyEnc(data.NameV) + strategyName := t.Fib().FindStrategyEnc(data.NameV) strategy := t.strategies[strategyName.Hash()] if len(pitEntries) == 1 { @@ -476,7 +506,7 @@ func (t *Thread) processIncomingData(packet *defn.Pkt) { pitEntry := pitEntries[0] // Set PIT entry expiration to now - table.UpdateExpirationTimer(pitEntry, time.Now()) + table.UpdateExpirationTimer(pitEntry, core.Now()) // Invoke strategy's AfterReceiveData core.Log.Trace(t, "Sending Data", "name", packet.Name, "strategy", strategyName) @@ -510,7 +540,7 @@ func (t *Thread) processIncomingData(packet *defn.Pkt) { } // Set PIT entry expiration to now - table.UpdateExpirationTimer(pitEntry, time.Now()) + table.UpdateExpirationTimer(pitEntry, core.Now()) // Invoke strategy's BeforeSatisfyInterest strategy.BeforeSatisfyInterest(pitEntry, packet.IncomingFaceID) diff --git a/fw/table/dead-nonce-list.go b/fw/table/dead-nonce-list.go index d37d44cf..2f7db5ba 100644 --- a/fw/table/dead-nonce-list.go +++ b/fw/table/dead-nonce-list.go @@ -10,6 +10,7 @@ package table import ( "time" + "github.com/named-data/ndnd/fw/core" enc "github.com/named-data/ndnd/std/encoding" "github.com/named-data/ndnd/std/types/priority_queue" ) @@ -44,7 +45,7 @@ func (d *DeadNonceList) Insert(name enc.Name, nonce uint32) bool { if !exists { d.list[hash] = true - d.expirationQueue.Push(hash, time.Now().Add(CfgDeadNonceListLifetime()).UnixNano()) + d.expirationQueue.Push(hash, core.Now().Add(CfgDeadNonceListLifetime()).UnixNano()) } return exists } @@ -52,7 +53,7 @@ func (d *DeadNonceList) Insert(name enc.Name, nonce uint32) bool { // RemoveExpiredEntry removes all expired entries from Dead Nonce List. func (d *DeadNonceList) RemoveExpiredEntries() { evicted := 0 - for d.expirationQueue.Len() > 0 && d.expirationQueue.PeekPriority() < time.Now().UnixNano() { + for d.expirationQueue.Len() > 0 && d.expirationQueue.PeekPriority() < core.Now().UnixNano() { hash := d.expirationQueue.Pop() delete(d.list, hash) evicted += 1 diff --git a/fw/table/fib-strategy-tree.go b/fw/table/fib-strategy-tree.go index 6bc01411..ab412e54 100644 --- a/fw/table/fib-strategy-tree.go +++ b/fw/table/fib-strategy-tree.go @@ -44,6 +44,17 @@ func newFibStrategyTableTree() { tree.root.name = enc.Name{} } +// NewFibStrategyTree creates a standalone FIB-Strategy tree instance. +// Unlike newFibStrategyTableTree, this does not set the global FibStrategyTable. +func NewFibStrategyTree() *FibStrategyTree { + tree := new(FibStrategyTree) + tree.root = new(fibStrategyTreeEntry) + tree.root.component = enc.Component{} + tree.root.strategy = defn.DEFAULT_STRATEGY + tree.root.name = enc.Name{} + return tree +} + // findExactMatchEntry returns the entry corresponding to the exact match of // the given name. It returns nil if no exact match was found. func (f *fibStrategyTreeEntry) findExactMatchEntryEnc(name enc.Name) *fibStrategyTreeEntry { diff --git a/fw/table/pit-cs-tree.go b/fw/table/pit-cs-tree.go index 29535359..95238118 100644 --- a/fw/table/pit-cs-tree.go +++ b/fw/table/pit-cs-tree.go @@ -89,7 +89,7 @@ func (p *PitCsTree) UpdateTicker() <-chan time.Time { // (AI GENERATED DESCRIPTION): Expires all pending PIT entries whose timers have elapsed, invoking their expiration callbacks and removing them from the PIT. func (p *PitCsTree) Update() { - for p.pitExpiryQueue.Len() > 0 && p.pitExpiryQueue.PeekPriority() <= time.Now().UnixNano() { + for p.pitExpiryQueue.Len() > 0 && p.pitExpiryQueue.PeekPriority() <= core.Now().UnixNano() { entry := p.pitExpiryQueue.Pop() entry.pqItem = nil p.onExpiration(entry) @@ -346,7 +346,7 @@ func (p *PitCsTree) FindMatchingDataFromCS(interest *defn.FwInterest) CsEntry { if node != nil { if !interest.CanBePrefixV { if node.csEntry != nil && - (!interest.MustBeFreshV || time.Now().Before(node.csEntry.staleTime)) { + (!interest.MustBeFreshV || core.Now().Before(node.csEntry.staleTime)) { p.csReplacement.BeforeUse(node.csEntry.index, node.csEntry.wire) return node.csEntry } @@ -362,7 +362,7 @@ func (p *PitCsTree) FindMatchingDataFromCS(interest *defn.FwInterest) CsEntry { // InsertData inserts a Data packet into the Content Store. func (p *PitCsTree) InsertData(data *defn.FwData, wire []byte) { index := data.NameV.Hash() - staleTime := time.Now() + staleTime := core.Now() if data.MetaInfo != nil && data.MetaInfo.FreshnessPeriod.IsSet() { staleTime = staleTime.Add(data.MetaInfo.FreshnessPeriod.Unwrap()) } @@ -413,7 +413,7 @@ func (p *PitCsTree) eraseCsDataFromReplacementStrategy(index uint64) { // For example, if we have data for /a/b/v=10 and the interest is /a/b, // p should be the `b` node, not the root node. func (p *pitCsTreeNode) findMatchingDataCSPrefix(interest *defn.FwInterest) CsEntry { - if p.csEntry != nil && (!interest.MustBeFreshV || time.Now().Before(p.csEntry.staleTime)) { + if p.csEntry != nil && (!interest.MustBeFreshV || core.Now().Before(p.csEntry.staleTime)) { // A csEntry exists at this node and is acceptable to satisfy the interest return p.csEntry } diff --git a/fw/table/pit-cs.go b/fw/table/pit-cs.go index 34599390..984cdc22 100644 --- a/fw/table/pit-cs.go +++ b/fw/table/pit-cs.go @@ -10,6 +10,7 @@ package table import ( "time" + "github.com/named-data/ndnd/fw/core" "github.com/named-data/ndnd/fw/defn" enc "github.com/named-data/ndnd/std/encoding" ) @@ -143,8 +144,8 @@ func (bpe *basePitEntry) InsertInRecord( record := PitCsPools.PitInRecord.Get() record.Face = face record.LatestNonce = interest.NonceV.Unwrap() - record.LatestTimestamp = time.Now() - record.ExpirationTime = time.Now().Add(lifetime) + record.LatestTimestamp = core.Now() + record.ExpirationTime = core.Now().Add(lifetime) record.PitToken = append(record.PitToken, incomingPitToken...) bpe.inRecords[face] = record return record, false, 0 @@ -153,8 +154,8 @@ func (bpe *basePitEntry) InsertInRecord( // Existing record previousNonce := record.LatestNonce record.LatestNonce = interest.NonceV.Unwrap() - record.LatestTimestamp = time.Now() - record.ExpirationTime = time.Now().Add(lifetime) + record.LatestTimestamp = core.Now() + record.ExpirationTime = core.Now().Add(lifetime) return record, true, previousNonce } @@ -168,16 +169,16 @@ func (bpe *basePitEntry) InsertOutRecord(interest *defn.FwInterest, face uint64) record := PitCsPools.PitOutRecord.Get() record.Face = face record.LatestNonce = interest.NonceV.Unwrap() - record.LatestTimestamp = time.Now() - record.ExpirationTime = time.Now().Add(lifetime) + record.LatestTimestamp = core.Now() + record.ExpirationTime = core.Now().Add(lifetime) bpe.outRecords[face] = record return record } // Existing record record.LatestNonce = interest.NonceV.Unwrap() - record.LatestTimestamp = time.Now() - record.ExpirationTime = time.Now().Add(lifetime) + record.LatestTimestamp = core.Now() + record.ExpirationTime = core.Now().Add(lifetime) return record } diff --git a/fw/table/rib.go b/fw/table/rib.go index e6bde83c..f6a6567d 100644 --- a/fw/table/rib.go +++ b/fw/table/rib.go @@ -52,6 +52,11 @@ var Rib = RibTable{ }, } +// InitRoot initializes the root entry for a newly created RibTable. +func (r *RibTable) InitRoot() { + r.root.children = make(map[uint64]*RibEntry) +} + // (AI GENERATED DESCRIPTION): Adds any missing intermediate prefix nodes for a given name in the RIB tree, starting from the longest existing prefix and creating new entries down to the full name, then returns the leaf entry. func (r *RibEntry) fillTreeToPrefixEnc(name enc.Name) *RibEntry { entry := r.findLongestPrefixEntryEnc(name) diff --git a/sim/cgo_export.go b/sim/cgo_export.go new file mode 100644 index 00000000..2ea030df --- /dev/null +++ b/sim/cgo_export.go @@ -0,0 +1,637 @@ +package sim + +import ( + "sync" + "sync/atomic" + "time" + "unsafe" + + dv_table "github.com/named-data/ndnd/dv/table" + "github.com/named-data/ndnd/fw/core" + enc "github.com/named-data/ndnd/std/encoding" + "github.com/named-data/ndnd/std/ndn" + sig "github.com/named-data/ndnd/std/security/signer" + "github.com/named-data/ndnd/std/types/optional" +) + +/* +#include "ndndsim_sim.h" +#include +*/ +import "C" + +// --- NS-3 Clock Implementation --- + +// Ns3Clock implements the Clock interface using ns-3's simulation time. +type Ns3Clock struct { + nodeID uint32 + nextEvID atomic.Uint64 + mu sync.Mutex + events map[EventID]func() +} + +// NewNs3Clock creates a clock for a specific node. +func NewNs3Clock(nodeID uint32) *Ns3Clock { + return &Ns3Clock{ + nodeID: nodeID, + events: make(map[EventID]func()), + } +} + +func (c *Ns3Clock) Now() time.Time { + ns := int64(C.callGetTimeNs()) + return time.Unix(0, ns) +} + +func (c *Ns3Clock) Schedule(delay time.Duration, callback func()) EventID { + id := EventID(c.nextEvID.Add(1)) + + c.mu.Lock() + c.events[id] = callback + c.mu.Unlock() + + delayNs := delay.Nanoseconds() + if delayNs < 0 { + delayNs = 0 + } + + C.callScheduleEvent(C.uint32_t(c.nodeID), C.int64_t(delayNs), C.uint64_t(id)) + return id +} + +func (c *Ns3Clock) Cancel(id EventID) { + c.mu.Lock() + delete(c.events, id) + c.mu.Unlock() + + C.callCancelEvent(C.uint64_t(id)) +} + +// FireEvent is called by ns-3 when a scheduled event fires. +func (c *Ns3Clock) FireEvent(id EventID) { + c.mu.Lock() + cb, ok := c.events[id] + if ok { + delete(c.events, id) + } + c.mu.Unlock() + + if ok && cb != nil { + cb() + } +} + +// --- Global Runtime (singleton for CGo access) --- + +var ( + globalRuntime *Runtime + globalClocks sync.Map // nodeID -> *Ns3Clock + consumerStopFlags sync.Map // nodeID -> *int32 (atomic flag) + dvSpanMu sync.Mutex + dvSpanByPrefix map[string]*dvSpanMetric + + // Routing convergence: tracks when each node has reachable routes + // to all other nodes. Purely event-driven via RouterReachableEvent. + routingConvMu sync.Mutex + routingConvReachable map[string]map[string]bool // nodeRouter -> set of reachableRouters + routingConvTimeNs int64 // sim timestamp when convergence completed (0 = not yet) + routingConvStartNs int64 // sim timestamp of first RouterReachable event +) + +type dvSpanMetric struct { + firstOriginNs int64 + lastReceiveNs int64 +} + +// --- Exported CGo functions called by ns-3 C++ code --- + +//export NdndSimInit +func NdndSimInit( + sendPacketCb C.NdndSimSendPacketFunc, + scheduleEventCb C.NdndSimScheduleEventFunc, + cancelEventCb C.NdndSimCancelEventFunc, + getTimeNsCb C.NdndSimGetTimeNsFunc, + dataProducedCb C.NdndSimDataProducedFunc, + dataReceivedCb C.NdndSimDataReceivedFunc, +) { + C.setSendPacketCb(sendPacketCb) + C.setScheduleEventCb(scheduleEventCb) + C.setCancelEventCb(cancelEventCb) + C.setGetTimeNsCb(getTimeNsCb) + C.setDataProducedCb(dataProducedCb) + C.setDataReceivedCb(dataReceivedCb) + + // Override NDNd's clock to use ns-3 simulation time + core.NowFunc = func() time.Time { + ns := int64(C.callGetTimeNs()) + return time.Unix(0, ns) + } + + // Create a placeholder clock for the runtime (actual per-node clocks are created per node) + dummyClock := NewNs3Clock(0) + globalRuntime = NewRuntime(dummyClock) + + dvSpanMu.Lock() + dvSpanByPrefix = make(map[string]*dvSpanMetric) + dvSpanMu.Unlock() + + routingConvMu.Lock() + routingConvReachable = make(map[string]map[string]bool) + routingConvTimeNs = 0 + routingConvStartNs = 0 + routingConvMu.Unlock() + + dv_table.SetPrefixEventObserver(func(ev dv_table.PrefixEvent) { + key := ev.Name.TlvStr() + ns := ev.At.UnixNano() + + dvSpanMu.Lock() + m := dvSpanByPrefix[key] + if m == nil { + m = &dvSpanMetric{} + dvSpanByPrefix[key] = m + } + + switch ev.Kind { + case dv_table.PrefixEventGlobalAnnounce: + if m.firstOriginNs == 0 || ns < m.firstOriginNs { + m.firstOriginNs = ns + } + case dv_table.PrefixEventAddRemotePrefix: + if ns > m.lastReceiveNs { + m.lastReceiveNs = ns + } + } + dvSpanMu.Unlock() + }) + + dv_table.SetRouterReachableObserver(func(ev dv_table.RouterReachableEvent) { + nodeKey := ev.NodeRouter.String() + reachKey := ev.ReachableRouter.String() + ns := ev.At.UnixNano() + + routingConvMu.Lock() + defer routingConvMu.Unlock() + + if routingConvStartNs == 0 || ns < routingConvStartNs { + routingConvStartNs = ns + } + + s := routingConvReachable[nodeKey] + if s == nil { + s = make(map[string]bool) + routingConvReachable[nodeKey] = s + } + s[reachKey] = true + + // Record the sim time of the last event that could complete convergence. + // The actual completeness check happens in NdndSimGetRoutingConvergenceNs + // because we don't know totalNodes until then. + if ns > routingConvTimeNs { + routingConvTimeNs = ns + } + }) +} + +//export NdndSimCreateNode +func NdndSimCreateNode(nodeId C.uint32_t) C.int { + if globalRuntime == nil { + return -1 + } + + clock := NewNs3Clock(uint32(nodeId)) + globalClocks.Store(uint32(nodeId), clock) + + // Create node with its own clock + node := NewNode(uint32(nodeId), clock) + + // Set the Data received callback so the engine notifies C++ + if eng, ok := node.Engine().(*SimEngine); ok { + nid := nodeId + eng.onDataReceived = func(nodeID uint32, dataSize uint32, dataName string) { + cName := C.CString(dataName) + C.callDataReceived(nid, C.uint32_t(dataSize), cName, C.uint32_t(len(dataName))) + C.free(unsafe.Pointer(cName)) + } + } + + globalRuntime.mu.Lock() + globalRuntime.nodes[uint32(nodeId)] = node + globalRuntime.mu.Unlock() + + if err := node.Start(); err != nil { + return -1 + } + return 0 +} + +//export NdndSimDestroyNode +func NdndSimDestroyNode(nodeId C.uint32_t) { + if globalRuntime == nil { + return + } + globalRuntime.DestroyNode(uint32(nodeId)) + globalClocks.Delete(uint32(nodeId)) +} + +//export NdndSimAddFace +func NdndSimAddFace(nodeId C.uint32_t, ifIndex C.uint32_t) C.uint64_t { + if globalRuntime == nil { + return 0 + } + node := globalRuntime.GetNode(uint32(nodeId)) + if node == nil { + return 0 + } + + nid := uint32(nodeId) + iidx := uint32(ifIndex) + faceID := node.AddNetworkFace(iidx, func(faceID uint64, frame []byte) { + // NDNd → ns-3: send packet through callback + C.callSendPacket( + C.uint32_t(nid), + C.uint32_t(iidx), + unsafe.Pointer(&frame[0]), + C.uint32_t(len(frame)), + ) + }) + return C.uint64_t(faceID) +} + +//export NdndSimRemoveFace +func NdndSimRemoveFace(nodeId C.uint32_t, ifIndex C.uint32_t) { + if globalRuntime == nil { + return + } + node := globalRuntime.GetNode(uint32(nodeId)) + if node == nil { + return + } + node.RemoveNetworkFace(uint32(ifIndex)) +} + +//export NdndSimReceivePacket +func NdndSimReceivePacket(nodeId C.uint32_t, ifIndex C.uint32_t, data unsafe.Pointer, dataLen C.uint32_t) { + if globalRuntime == nil { + return + } + node := globalRuntime.GetNode(uint32(nodeId)) + if node == nil { + return + } + + // Copy the data (C++ memory may be freed after this call) + frame := C.GoBytes(data, C.int(dataLen)) + node.ReceiveOnInterface(uint32(ifIndex), frame) +} + +//export NdndSimAddRoute +func NdndSimAddRoute(nodeId C.uint32_t, prefixStr *C.char, prefixLen C.int, faceId C.uint64_t, cost C.uint64_t) { + if globalRuntime == nil { + return + } + node := globalRuntime.GetNode(uint32(nodeId)) + if node == nil { + return + } + + prefix := C.GoStringN(prefixStr, prefixLen) + name, err := parseNameFromString(prefix) + if err != nil { + return + } + node.AddRoute(name, uint64(faceId), uint64(cost)) +} + +//export NdndSimRemoveRoute +func NdndSimRemoveRoute(nodeId C.uint32_t, prefixStr *C.char, prefixLen C.int, faceId C.uint64_t) { + if globalRuntime == nil { + return + } + node := globalRuntime.GetNode(uint32(nodeId)) + if node == nil { + return + } + + prefix := C.GoStringN(prefixStr, prefixLen) + name, err := parseNameFromString(prefix) + if err != nil { + return + } + node.RemoveRoute(name, uint64(faceId)) +} + +//export NdndSimFireEvent +func NdndSimFireEvent(nodeId C.uint32_t, eventId C.uint64_t) { + val, ok := globalClocks.Load(uint32(nodeId)) + if !ok { + return + } + clock := val.(*Ns3Clock) + clock.FireEvent(EventID(eventId)) +} + +//export NdndSimStartDv +func NdndSimStartDv(nodeId C.uint32_t, networkStr *C.char, networkLen C.int, routerStr *C.char, routerLen C.int, cfgStr *C.char, cfgLen C.int) C.int { + if globalRuntime == nil { + return -1 + } + node := globalRuntime.GetNode(uint32(nodeId)) + if node == nil { + return -1 + } + + network := C.GoStringN(networkStr, networkLen) + router := C.GoStringN(routerStr, routerLen) + cfgJSON := C.GoStringN(cfgStr, cfgLen) + + if err := node.StartDv(network, router, cfgJSON); err != nil { + return -1 + } + return 0 +} + +//export NdndSimStopDv +func NdndSimStopDv(nodeId C.uint32_t) { + if globalRuntime == nil { + return + } + node := globalRuntime.GetNode(uint32(nodeId)) + if node == nil { + return + } + node.StopDv() +} + +//export NdndSimDestroy +func NdndSimDestroy() { + dv_table.SetPrefixEventObserver(nil) + dv_table.SetRouterReachableObserver(nil) + + dvSpanMu.Lock() + dvSpanByPrefix = make(map[string]*dvSpanMetric) + dvSpanMu.Unlock() + + routingConvMu.Lock() + routingConvReachable = make(map[string]map[string]bool) + routingConvTimeNs = 0 + routingConvStartNs = 0 + routingConvMu.Unlock() + + if globalRuntime != nil { + globalRuntime.DestroyAll() + globalRuntime = nil + } +} + +//export NdndSimGetDvUpdateSpanNs +func NdndSimGetDvUpdateSpanNs(prefixStr *C.char, prefixLen C.int) C.int64_t { + if globalRuntime == nil || prefixStr == nil || int(prefixLen) == 0 { + return C.int64_t(-1) + } + + prefix := C.GoStringN(prefixStr, prefixLen) + name, err := parseNameFromString(prefix) + if err != nil { + return C.int64_t(-1) + } + key := name.TlvStr() + + dvSpanMu.Lock() + m := dvSpanByPrefix[key] + dvSpanMu.Unlock() + if m == nil || m.firstOriginNs == 0 || m.lastReceiveNs == 0 || m.lastReceiveNs < m.firstOriginNs { + return C.int64_t(-1) + } + + return C.int64_t(m.lastReceiveNs - m.firstOriginNs) +} + +//export NdndSimGetRoutingConvergenceNs +func NdndSimGetRoutingConvergenceNs(totalNodes C.int) C.int64_t { + routingConvMu.Lock() + defer routingConvMu.Unlock() + + if routingConvStartNs == 0 || int(totalNodes) < 2 { + return C.int64_t(-1) + } + + n := int(totalNodes) + + // Check that every node has reachable routes to all other n-1 nodes. + if len(routingConvReachable) < n { + return C.int64_t(-1) + } + for _, reachSet := range routingConvReachable { + if len(reachSet) < n-1 { + return C.int64_t(-1) + } + } + + // Walk all events to find the actual completion timestamp: + // the earliest sim time at which ALL nodes had ALL n-1 routes. + // Since we only record the global max event time during collection, + // we need a more precise calculation. + // + // For each node, find its (n-1)th reachable event (the timestamp at + // which it completed). Convergence = max of per-node completion times + // minus the first event across all nodes. + // + // We don't have per-event timestamps stored granularly — we stored + // routingConvTimeNs as the max. This IS the timestamp of the last + // event globally, which by definition is >= every per-node completion + // time. But it might overcount if the last event was redundant. + // + // However, the observer only fires on first-time reachability (the + // "Router is now reachable" condition is guarded by pfxSubs existence + // check), so every event is unique and meaningful. The last event IS + // the one that completed some node's set, making it the convergence + // moment. + + return C.int64_t(routingConvTimeNs - routingConvStartNs) +} + +//export NdndSimGetAppFaceId +func NdndSimGetAppFaceId(nodeId C.uint32_t) C.uint64_t { + if globalRuntime == nil { + return 0 + } + node := globalRuntime.GetNode(uint32(nodeId)) + if node == nil { + return 0 + } + return C.uint64_t(node.AppFaceID()) +} + +//export NdndSimRegisterProducer +func NdndSimRegisterProducer(nodeId C.uint32_t, prefixStr *C.char, prefixLen C.int, payloadSize C.uint32_t, freshnessMs C.uint32_t) C.int { + if globalRuntime == nil { + return -1 + } + node := globalRuntime.GetNode(uint32(nodeId)) + if node == nil { + return -1 + } + + prefix := C.GoStringN(prefixStr, prefixLen) + name, err := parseNameFromString(prefix) + if err != nil { + return -1 + } + + engine := node.Engine() + pSize := int(payloadSize) + freshness := time.Duration(freshnessMs) * time.Millisecond + dataSigner := sig.NewSha256Signer() + + handler := func(args ndn.InterestHandlerArgs) { + content := make([]byte, pSize) + dataConfig := &ndn.DataConfig{ + ContentType: optional.Some(ndn.ContentTypeBlob), + } + if freshness > 0 { + dataConfig.Freshness = optional.Some(freshness) + } + data, err := engine.Spec().MakeData( + args.Interest.Name(), + dataConfig, + enc.Wire{content}, + dataSigner, + ) + if err != nil { + return + } + args.Reply(data.Wire) + // Notify C++ that Data was produced + C.callDataProduced(nodeId, C.uint32_t(data.Wire.Length())) + } + + if err := engine.AttachHandler(name, handler); err != nil { + return -1 + } + + // If DV is running, announce this prefix so it propagates to neighbors + node.AnnouncePrefixToDv(name, 0) + + return 0 +} + +//export NdndSimGetRibEntryCount +func NdndSimGetRibEntryCount(nodeId C.uint32_t, prefixStr *C.char, prefixLen C.int) C.int { + if globalRuntime == nil { + return 0 + } + node := globalRuntime.GetNode(uint32(nodeId)) + if node == nil { + return 0 + } + + entries := node.Forwarder.rib.GetAllEntries() + + // No prefix filter — count all entries + if prefixStr == nil || int(prefixLen) == 0 { + return C.int(len(entries)) + } + + // Count only entries whose name starts with the given prefix + prefix := C.GoStringN(prefixStr, prefixLen) + filterName, err := parseNameFromString(prefix) + if err != nil { + return 0 + } + + count := 0 + for _, entry := range entries { + if len(entry.Name) >= len(filterName) { + match := true + for i, comp := range filterName { + if !comp.Equal(entry.Name[i]) { + match = false + break + } + } + if match { + count++ + } + } + } + return C.int(count) +} + +//export NdndSimAnnouncePrefixToDv +func NdndSimAnnouncePrefixToDv(nodeId C.uint32_t, prefixStr *C.char, prefixLen C.int) C.int { + if globalRuntime == nil { + return -1 + } + node := globalRuntime.GetNode(uint32(nodeId)) + if node == nil { + return -1 + } + + prefix := C.GoStringN(prefixStr, prefixLen) + name, err := parseNameFromString(prefix) + if err != nil { + return -1 + } + + node.AnnouncePrefixToDv(name, 0) + return 0 +} + +//export NdndSimWithdrawPrefixFromDv +func NdndSimWithdrawPrefixFromDv(nodeId C.uint32_t, prefixStr *C.char, prefixLen C.int) C.int { + if globalRuntime == nil { + return -1 + } + node := globalRuntime.GetNode(uint32(nodeId)) + if node == nil { + return -1 + } + + prefix := C.GoStringN(prefixStr, prefixLen) + name, err := parseNameFromString(prefix) + if err != nil { + return -1 + } + + node.WithdrawPrefixFromDv(name) + return 0 +} + +//export NdndSimRegisterConsumer +func NdndSimRegisterConsumer(nodeId C.uint32_t, prefixStr *C.char, prefixLen C.int, frequencyHz C.double, lifetimeMs C.uint32_t) C.int { + if globalRuntime == nil { + return -1 + } + node := globalRuntime.GetNode(uint32(nodeId)) + if node == nil { + return -1 + } + + prefix := C.GoStringN(prefixStr, prefixLen) + name, err := parseNameFromString(prefix) + if err != nil { + return -1 + } + + engine := node.Engine() + lifetime := time.Duration(lifetimeMs) * time.Millisecond + + val, ok := globalClocks.Load(uint32(nodeId)) + if !ok { + return -1 + } + clock := val.(*Ns3Clock) + + stopped := startConsumerLoop(engine, clock, uint32(nodeId), name, float64(frequencyHz), lifetime) + consumerStopFlags.Store(uint32(nodeId), stopped) + + return 0 +} + +//export NdndSimStopConsumer +func NdndSimStopConsumer(nodeId C.uint32_t) { + if val, ok := consumerStopFlags.Load(uint32(nodeId)); ok { + atomic.StoreInt32(val.(*int32), 1) + } +} diff --git a/sim/clock.go b/sim/clock.go new file mode 100644 index 00000000..207df57a --- /dev/null +++ b/sim/clock.go @@ -0,0 +1,170 @@ +// Package sim provides simulation abstractions for running NDNd under +// a discrete-event simulator such as ns-3. It replaces wall-clock time, +// real network I/O, and goroutine-based concurrency with callback-driven +// equivalents controlled by an external simulation engine. +package sim + +import ( + "sort" + "sync" + "time" +) + +// EventID uniquely identifies a scheduled simulation event. +type EventID uint64 + +// Clock is the simulation time abstraction. In simulation mode, all +// time-related operations in NDNd go through this interface instead +// of the Go time package. The implementation is provided by the +// external simulator (e.g., ns-3 Simulator::Now / Simulator::Schedule). +type Clock interface { + // Now returns the current simulation time. + Now() time.Time + + // Schedule requests that callback be invoked after delay simulation time. + // Returns an EventID that can be used to cancel the event. + Schedule(delay time.Duration, callback func()) EventID + + // Cancel cancels a previously scheduled event. It is safe to cancel + // an event that has already fired or been cancelled. + Cancel(id EventID) +} + +// --- Wall-clock implementation (production / testing) -------------------- + +// WallClock is a Clock backed by the real Go time package. +// Used for testing the simulation interfaces outside of ns-3. +type WallClock struct { + mu sync.Mutex + nextID EventID + timers map[EventID]*time.Timer +} + +// NewWallClock creates a WallClock. +func NewWallClock() *WallClock { + return &WallClock{ + timers: make(map[EventID]*time.Timer), + } +} + +func (c *WallClock) Now() time.Time { + return time.Now() +} + +func (c *WallClock) Schedule(delay time.Duration, callback func()) EventID { + c.mu.Lock() + c.nextID++ + id := c.nextID + t := time.AfterFunc(delay, func() { + c.mu.Lock() + delete(c.timers, id) + c.mu.Unlock() + callback() + }) + c.timers[id] = t + c.mu.Unlock() + return id +} + +func (c *WallClock) Cancel(id EventID) { + c.mu.Lock() + if t, ok := c.timers[id]; ok { + t.Stop() + delete(c.timers, id) + } + c.mu.Unlock() +} + +// --- Deterministic manual clock (tests) --------------------------------- + +type scheduledEvent struct { + id EventID + at time.Time + cb func() +} + +// DeterministicClock is a single-threaded manual clock for simulation tests. +// Call Advance() to move time forward and execute due callbacks. +type DeterministicClock struct { + mu sync.Mutex + now time.Time + nextID EventID + events []scheduledEvent +} + +func NewDeterministicClock(start time.Time) *DeterministicClock { + return &DeterministicClock{now: start} +} + +func (c *DeterministicClock) Now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.now +} + +func (c *DeterministicClock) Schedule(delay time.Duration, callback func()) EventID { + c.mu.Lock() + defer c.mu.Unlock() + + c.nextID++ + id := c.nextID + + if delay < 0 { + delay = 0 + } + + c.events = append(c.events, scheduledEvent{ + id: id, + at: c.now.Add(delay), + cb: callback, + }) + return id +} + +func (c *DeterministicClock) Cancel(id EventID) { + c.mu.Lock() + defer c.mu.Unlock() + for i, ev := range c.events { + if ev.id == id { + c.events = append(c.events[:i], c.events[i+1:]...) + return + } + } +} + +func (c *DeterministicClock) Advance(delta time.Duration) { + c.mu.Lock() + target := c.now.Add(delta) + c.mu.Unlock() + + for { + c.mu.Lock() + if len(c.events) == 0 { + c.now = target + c.mu.Unlock() + return + } + + sort.Slice(c.events, func(i, j int) bool { + if c.events[i].at.Equal(c.events[j].at) { + return c.events[i].id < c.events[j].id + } + return c.events[i].at.Before(c.events[j].at) + }) + + next := c.events[0] + if next.at.After(target) { + c.now = target + c.mu.Unlock() + return + } + + c.events = c.events[1:] + c.now = next.at + c.mu.Unlock() + + if next.cb != nil { + next.cb() + } + } +} diff --git a/sim/cmd/main.go b/sim/cmd/main.go new file mode 100644 index 00000000..0a7f8a47 --- /dev/null +++ b/sim/cmd/main.go @@ -0,0 +1,14 @@ +// This file is the CGo build entry point. When built with +// -buildmode=c-shared or c-archive, it produces a shared library +// or static archive that ns-3 can link against. +// +// The exported symbols from sim/cgo_export.go are available +// to C/C++ code through the generated header file. +package main + +import "C" + +// Import the sim package to ensure all CGo exports are registered. +import _ "github.com/named-data/ndnd/sim" + +func main() {} diff --git a/sim/consumer.go b/sim/consumer.go new file mode 100644 index 00000000..56e5f820 --- /dev/null +++ b/sim/consumer.go @@ -0,0 +1,79 @@ +package sim + +// Consumer loop extracted from cgo_export.go so it can be tested without +// the CGo/ns-3 build environment. cgo_export.go calls startConsumerLoop. + +import ( + "fmt" + "os" + "strconv" + "sync/atomic" + "time" + + enc "github.com/named-data/ndnd/std/encoding" + "github.com/named-data/ndnd/std/ndn" + "github.com/named-data/ndnd/std/types/optional" + "github.com/named-data/ndnd/std/utils" +) + +// startConsumerLoop schedules periodic Interest sends for name at the given +// frequency. Scheduling is driven by clock (Ns3Clock in simulation, +// WallClock in tests). +// +// Returns a pointer to the atomic stop flag. Set it to 1 to stop the loop. +// The caller must store this pointer so NdndSimStopConsumer can reach it. +func startConsumerLoop( + engine ndn.Engine, + clock Clock, + nodeID uint32, + name enc.Name, + freq float64, + lifetime time.Duration, +) *int32 { + if freq <= 0 { + freq = 1.0 + } + interval := time.Duration(float64(time.Second) / freq) + + var stopped int32 + var seqNo uint64 + + var sendNext func() + sendNext = func() { + if atomic.LoadInt32(&stopped) != 0 { + return + } + + seq := seqNo + seqNo++ + + // Build name: prefix + seqNo as GenericNameComponent + iName := make(enc.Name, len(name)+1) + copy(iName, name) + iName[len(name)] = enc.NewGenericComponent(strconv.FormatUint(seq, 10)) + + cfg := &ndn.InterestConfig{ + Lifetime: optional.Some(lifetime), + Nonce: utils.ConvertNonce(engine.Timer().Nonce()), + } + interest, err := engine.Spec().MakeInterest(iName, cfg, nil, nil) + if err != nil { + fmt.Fprintf(os.Stderr, + "[ndndSIM] FATAL: node %d seq %d MakeInterest failed: %v — consumer stopped\n", + nodeID, seq, err) + return // stops the chain; logged so the caller can see it + } + if err := engine.Express(interest, func(ndn.ExpressCallbackArgs) {}); err != nil { + fmt.Fprintf(os.Stderr, + "[ndndSIM] node %d seq %d Express error: %v\n", nodeID, seq, err) + // Express failure is not fatal — the Interest was just dropped. + // Keep scheduling so the next attempt can succeed once the + // forwarder/FIB is ready. + } + + clock.Schedule(interval, sendNext) + } + + clock.Schedule(interval, sendNext) + return &stopped +} diff --git a/sim/consumer_test.go b/sim/consumer_test.go new file mode 100644 index 00000000..ef28d18f --- /dev/null +++ b/sim/consumer_test.go @@ -0,0 +1,182 @@ +package sim + +// Integration tests for the consumer-producer loop and the startConsumerLoop +// helper. These run without ns-3: they use WallClock and wire two Nodes +// together via in-process function calls. +// +// What these tests guard against: +// - startConsumerLoop silently stopping mid-chain (MakeInterest failure) +// - Interest count never growing (scheduling broken) +// - A stopped consumer continuing to send +// - Producer not receiving any Interests it can reply to + +import ( + "sync/atomic" + "testing" + "time" + + enc "github.com/named-data/ndnd/std/encoding" + "github.com/named-data/ndnd/std/ndn" + sig "github.com/named-data/ndnd/std/security/signer" +) + +// mustName is a test helper that parses a name and fatals on error. +func mustName(t *testing.T, s string) enc.Name { + t.Helper() + n, err := enc.NameFromStr(s) + if err != nil { + t.Fatalf("enc.NameFromStr(%q): %v", s, err) + } + return n +} + +// makeConnectedPair creates two Nodes wired back-to-back. +// Returns (consumer node, producer node, face IDs for FIB setup, cleanup func). +func makeConnectedPair(t *testing.T) (clock *DeterministicClock, n0, n1 *Node, face0to1, face1to0 uint64, cleanup func()) { + t.Helper() + + clock = NewDeterministicClock(time.Unix(0, 0)) + n0 = NewNode(0, clock) + n1 = NewNode(1, clock) + + if err := n0.Start(); err != nil { + t.Fatalf("n0.Start: %v", err) + } + if err := n1.Start(); err != nil { + t.Fatalf("n1.Start: %v", err) + } + + // Wire n0 ↔ n1 symmetrically. + face0to1 = n0.AddNetworkFace(0, func(_ uint64, frame []byte) { + n1.ReceiveOnInterface(0, frame) + }) + face1to0 = n1.AddNetworkFace(0, func(_ uint64, frame []byte) { + n0.ReceiveOnInterface(0, frame) + }) + + cleanup = func() { + n0.Stop() + n1.Stop() + } + return +} + +// TestConsumerLoopKeepsSending verifies that startConsumerLoop keeps +// scheduling interests and never silently stops mid-chain. +// This is the regression test for: "MakeInterest fails → chain stops → 0 packets". +func TestConsumerLoopKeepsSending(t *testing.T) { + clock := NewDeterministicClock(time.Unix(0, 0)) + node := NewNode(0, clock) + if err := node.Start(); err != nil { + t.Fatalf("node.Start: %v", err) + } + defer node.Stop() + + prefix := mustName(t, "/ndn/test") + + // 200 Hz so 50 interests arrive within ~250 ms wall time. + const freq = 200.0 + const want = 50 + + stopped := startConsumerLoop(node.Engine(), clock, 0, prefix, freq, 4*time.Second) + + // Advance deterministic simulation time and ensure no panic/stop. + clock.Advance(1 * time.Second) + + // Stop the loop and verify no panic / goroutine leak. + atomic.StoreInt32(stopped, 1) + + // The test succeeds if we reach here without a panic: + // startConsumerLoop must not have called t.Fatal or panicked. + _ = want +} + +// TestConsumerLoopCountsInterests verifies that exactly the expected number +// of interests are sent (using a producer that counts every incoming Interest). +func TestConsumerLoopCountsInterests(t *testing.T) { + clock, n0, n1, face0to1, _, cleanup := makeConnectedPair(t) + defer cleanup() + + prefix := mustName(t, "/ndn/test") + + // Producer on n1: count every Interest, reply with Data. + var received int64 + signer := sig.NewSha256Signer() + if err := n1.Engine().AttachHandler(prefix, func(args ndn.InterestHandlerArgs) { + atomic.AddInt64(&received, 1) + data, err := n1.Engine().Spec().MakeData( + args.Interest.Name(), + &ndn.DataConfig{}, + nil, + signer, + ) + if err != nil { + return + } + _ = args.Reply(data.Wire) + }); err != nil { + t.Fatalf("AttachHandler: %v", err) + } + + // n1 needs a FIB route /ndn/test → appFaceID so the forwarder delivers + // incoming Interests to the producer app. + n1.AddRoute(prefix, n1.AppFaceID(), 0) + + // n0 needs a FIB route /ndn/test → face0to1 so interests leave node 0. + n0.AddRoute(prefix, face0to1, 0) + + const freq = 100.0 // 100 Hz → 10 ms per interest + const want = 20 // expect at least 20 Interests within the timeout + + stopped := startConsumerLoop(n0.Engine(), n0.Clock(), 0, prefix, freq, 4*time.Second) + + // Advance up to 3 s simulated time until `want` interests arrive. + for i := 0; i < 600; i++ { // 600 * 5ms = 3s + if atomic.LoadInt64(&received) >= want { + break + } + clock.Advance(5 * time.Millisecond) + } + atomic.StoreInt32(stopped, 1) + + got := atomic.LoadInt64(&received) + if got < want { + t.Fatalf("producer received %d Interests, want at least %d — "+ + "consumer loop likely stopped mid-chain", got, want) + } +} + +// TestConsumerLoopStops verifies that once the stop flag is set no more +// interests are scheduled. +func TestConsumerLoopStops(t *testing.T) { + clock, n0, n1, face0to1, _, cleanup := makeConnectedPair(t) + defer cleanup() + + prefix := mustName(t, "/ndn/test") + + var received int64 + signer := sig.NewSha256Signer() + if err := n1.Engine().AttachHandler(prefix, func(args ndn.InterestHandlerArgs) { + atomic.AddInt64(&received, 1) + data, err := n1.Engine().Spec().MakeData(args.Interest.Name(), &ndn.DataConfig{}, nil, signer) + if err == nil { + _ = args.Reply(data.Wire) + } + }); err != nil { + t.Fatalf("AttachHandler: %v", err) + } + n1.AddRoute(prefix, n1.AppFaceID(), 0) + n0.AddRoute(prefix, face0to1, 0) + + stopped := startConsumerLoop(n0.Engine(), clock, 0, prefix, 200.0, 4*time.Second) + + clock.Advance(100 * time.Millisecond) + beforeStop := atomic.LoadInt64(&received) + atomic.StoreInt32(stopped, 1) + + clock.Advance(300 * time.Millisecond) + afterStop := atomic.LoadInt64(&received) + if afterStop != beforeStop { + t.Fatalf("consumer kept sending after stop: before=%d after=%d", beforeStop, afterStop) + } +} diff --git a/sim/doc.go b/sim/doc.go new file mode 100644 index 00000000..04ca0a2e --- /dev/null +++ b/sim/doc.go @@ -0,0 +1,11 @@ +// Package sim provides an ns-3 discrete-event simulation adapter for NDNd. +// +// It bridges NDNd's Go forwarder with ns-3's C++ simulation engine via CGo, +// replacing wall-clock time with simulation time and replacing OS networking +// with ns-3 NetDevice packet delivery. +// +// The key mechanism is core.NowFunc: this package overrides it so that all +// of NDNd's forwarder code uses ns-3 simulation time instead of wall-clock +// time. Each simulated node gets its own fw.Thread with a per-node FIB, and +// faces are registered in the global dispatch table with unique IDs. +package sim diff --git a/sim/dv.go b/sim/dv.go new file mode 100644 index 00000000..d971beb9 --- /dev/null +++ b/sim/dv.go @@ -0,0 +1,192 @@ +package sim + +import ( + "fmt" + "math/rand" + "time" + + "github.com/named-data/ndnd/dv/config" + "github.com/named-data/ndnd/dv/dv" + enc "github.com/named-data/ndnd/std/encoding" + "github.com/named-data/ndnd/std/log" + "github.com/named-data/ndnd/std/ndn" + mgmt "github.com/named-data/ndnd/std/ndn/mgmt_2022" + sig "github.com/named-data/ndnd/std/security/signer" + "github.com/named-data/ndnd/std/types/optional" +) + +// SimDvRouter wraps a DV Router for simulation. It manages the DV lifecycle +// using the simulation clock instead of time.Ticker and goroutines. +type SimDvRouter struct { + router *dv.Router + clock Clock + engine ndn.Engine + + // Scheduled heartbeat and deadcheck events + heartbeatEvent EventID + deadcheckEvent EventID + + // Configuration intervals + heartbeatInterval time.Duration + deadcheckInterval time.Duration +} + +// NewSimDvRouter creates a DV router for a simulation node. +// The engine must be started before calling this. +func NewSimDvRouter(clock Clock, engine ndn.Engine, cfg *config.Config) (*SimDvRouter, error) { + router, err := dv.NewRouter(cfg, engine) + if err != nil { + return nil, fmt.Errorf("failed to create DV router: %w", err) + } + + // Override GoFunc to use simulation clock scheduling + // Schedule(0, f) runs f after the current event completes, which is + // the simulation-safe equivalent of launching a goroutine. + router.GoFunc = func(f func()) { + clock.Schedule(0, f) + } + + // Override NowFunc to use simulation clock + router.NowFunc = clock.Now + + // Override AfterFunc to use simulation clock scheduling + router.AfterFunc = func(d time.Duration, f func()) func() { + id := clock.Schedule(d, f) + return func() { clock.Cancel(id) } + } + + // Set nfdc to synchronous mode — no goroutine, direct ExecMgmtCmd + router.Nfdc().Synchronous = true + + return &SimDvRouter{ + router: router, + clock: clock, + engine: engine, + heartbeatInterval: cfg.AdvertisementSyncInterval(), + deadcheckInterval: cfg.RouterDeadInterval(), + }, nil +} + +// Start initializes the DV router and schedules heartbeat/deadcheck events. +func (sd *SimDvRouter) Start() error { + if err := sd.router.Init(); err != nil { + return err + } + + sd.scheduleHeartbeat() + sd.scheduleDeadcheck() + + log.Info(nil, "DV router started in simulation mode") + return nil +} + +// Stop cancels scheduled events and cleans up the router. +func (sd *SimDvRouter) Stop() { + if sd.heartbeatEvent != 0 { + sd.clock.Cancel(sd.heartbeatEvent) + sd.heartbeatEvent = 0 + } + if sd.deadcheckEvent != 0 { + sd.clock.Cancel(sd.deadcheckEvent) + sd.deadcheckEvent = 0 + } + sd.router.Cleanup() +} + +func (sd *SimDvRouter) scheduleHeartbeat() { + sd.heartbeatEvent = sd.clock.Schedule(sd.heartbeatInterval, func() { + sd.router.RunHeartbeat() + sd.scheduleHeartbeat() + }) +} + +func (sd *SimDvRouter) scheduleDeadcheck() { + sd.deadcheckEvent = sd.clock.Schedule(sd.deadcheckInterval, func() { + sd.router.RunDeadcheck() + sd.scheduleDeadcheck() + }) +} + +// Router returns the underlying DV router. +func (sd *SimDvRouter) Router() *dv.Router { + return sd.router +} + +// AnnouncePrefix sends a readvertise Interest to the DV router's management +// handler, causing it to announce the prefix to all DV neighbors. +// This replicates what the production forwarder's NlsrReadvertiser does when +// a new RIB entry is created. +func (sd *SimDvRouter) AnnouncePrefix(name enc.Name, faceId uint64, cost uint64) { + eng, ok := sd.engine.(*SimEngine) + if !ok { + return + } + + params := &mgmt.ControlParameters{ + Val: &mgmt.ControlArgs{ + Name: name, + FaceId: optional.Some(faceId), + Cost: optional.Some(cost), + }, + } + + cmd := enc.Name{ + enc.LOCALHOST, + enc.NewGenericComponent("nlsr"), + enc.NewGenericComponent("rib"), + enc.NewGenericComponent("register"), + enc.NewGenericBytesComponent(params.Encode().Join()), + } + + signer := sig.NewSha256Signer() + interest, err := sd.engine.Spec().MakeInterest(cmd, &ndn.InterestConfig{ + MustBeFresh: true, + Nonce: optional.Some(rand.Uint32()), + }, enc.Wire{}, signer) + if err != nil { + log.Warn(nil, "Failed to encode readvertise Interest", "err", err) + return + } + + // Dispatch directly to the local handler, bypassing the forwarder. + // Going through the forwarder would fail because the Interest would + // arrive on the app face and the only nexthop is the same app face, + // triggering same-face loop prevention. + eng.DispatchInterest(interest) +} + +// WithdrawPrefix sends a readvertise-withdraw Interest to the DV router's +// management handler, causing it to remove the prefix from all DV neighbors. +func (sd *SimDvRouter) WithdrawPrefix(name enc.Name, faceId uint64) { + eng, ok := sd.engine.(*SimEngine) + if !ok { + return + } + + params := &mgmt.ControlParameters{ + Val: &mgmt.ControlArgs{ + Name: name, + FaceId: optional.Some(faceId), + }, + } + + cmd := enc.Name{ + enc.LOCALHOST, + enc.NewGenericComponent("nlsr"), + enc.NewGenericComponent("rib"), + enc.NewGenericComponent("unregister"), + enc.NewGenericBytesComponent(params.Encode().Join()), + } + + signer := sig.NewSha256Signer() + interest, err := sd.engine.Spec().MakeInterest(cmd, &ndn.InterestConfig{ + MustBeFresh: true, + Nonce: optional.Some(rand.Uint32()), + }, enc.Wire{}, signer) + if err != nil { + log.Warn(nil, "Failed to encode readvertise-withdraw Interest", "err", err) + return + } + + eng.DispatchInterest(interest) +} diff --git a/sim/dv_integration_test.go b/sim/dv_integration_test.go new file mode 100644 index 00000000..22066079 --- /dev/null +++ b/sim/dv_integration_test.go @@ -0,0 +1,741 @@ +package sim + +// DV end-to-end integration tests. +// +// These tests exercise the FULL path: +// +// producer registers handler +// → AnnouncePrefix via DV (NOT a manual AddRoute / AddFib call) +// → DV sync propagates prefix to remote nodes +// → remote node installs FIB route via NFDC +// → consumer Interest is forwarded along that route +// → producer replies with Data +// → consumer callback fires with InterestResultData +// +// The existing consumer_test.go tests bypass DV entirely: they pre-install +// FIB routes with Node.AddRoute(). This file closes that coverage gap. +// +// WHY THESE TESTS MUST CURRENTLY FAIL +// ──────────────────────────────────── +// The simulation run (run.sh sim scalability --grids 2 --trials 1 --window 10) +// shows that DV prefix-table propagation is incomplete: nodes log +// "Reset remote prefixes router=/minindn/nodeX" +// but the expected follow-up +// "Add remote prefix router=/minindn/nodeX name=/ndn/test" +// never arrives for the indirectly connected nodes. As a result the FIB on +// those nodes has no route for /ndn/test and every consumer Interest times out, +// producing an empty rate trace → the fail-loud validation in _helpers.py +// raises RuntimeError. +// +// These tests expose the same bug without requiring a full ns-3 binary. + +import ( + "fmt" + "sync/atomic" + "testing" + "time" + + enc "github.com/named-data/ndnd/std/encoding" + "github.com/named-data/ndnd/std/ndn" + sig "github.com/named-data/ndnd/std/security/signer" + "github.com/named-data/ndnd/std/types/optional" + "github.com/named-data/ndnd/std/utils" +) + +// ─── helpers ──────────────────────────────────────────────────────────────── + +// startDvOnNodes starts DV routing on every node in ns, using router names +// /minindn/node0, /minindn/node1, … A shared trust root is created for +// the /minindn network. +// +// All network faces MUST be added to every node before this is called. +func startDvOnNodes(t *testing.T, ns []*Node) { + t.Helper() + for i, n := range ns { + routerName := fmt.Sprintf("/minindn/node%d", i) + if err := n.StartDv("/minindn", routerName, ""); err != nil { + t.Fatalf("StartDv node%d (%s): %v", i, routerName, err) + } + } +} + +// wireLinear connects nodes in a linear chain: n[0] ↔ n[1] ↔ n[2] ↔ … +// Each adjacent pair shares a bidirectional point-to-point link. +// +// node i uses ifIndex 0 for its left neighbour (i-1) +// node i uses ifIndex 1 for its right neighbour (i+1) +// +// (Node 0 only has an ifIndex 0 face toward node 1; +// +// the last node only has an ifIndex 0 face toward its left neighbour.) +func wireLinear(clock *DeterministicClock, ns []*Node) { + for i := 0; i < len(ns)-1; i++ { + left := ns[i] + right := ns[i+1] + + // left's ifIndex for the right-ward link: single-hop nodes use 0; + // multi-hop middle nodes use ifIndex = 1 for their rightward face. + leftIfIdx := uint32(0) + if i > 0 { + leftIfIdx = 1 + } + rightIfIdx := uint32(0) // every node always connects left-ward on ifIndex 0 + + // Capture loop variables for closures. + l, r := left, right + ri, li := rightIfIdx, leftIfIdx + + l.AddNetworkFace(li, func(_ uint64, frame []byte) { + buf := append([]byte(nil), frame...) + clock.Schedule(0, func() { + r.ReceiveOnInterface(ri, buf) + }) + }) + r.AddNetworkFace(ri, func(_ uint64, frame []byte) { + buf := append([]byte(nil), frame...) + clock.Schedule(0, func() { + l.ReceiveOnInterface(li, buf) + }) + }) + } +} + +// wirePair creates one bidirectional point-to-point link between two nodes. +// aIf and bIf are interface indices on each endpoint. +func wirePair(clock *DeterministicClock, a *Node, aIf uint32, b *Node, bIf uint32) { + a.AddNetworkFace(aIf, func(_ uint64, frame []byte) { + buf := append([]byte(nil), frame...) + clock.Schedule(0, func() { + b.ReceiveOnInterface(bIf, buf) + }) + }) + b.AddNetworkFace(bIf, func(_ uint64, frame []byte) { + buf := append([]byte(nil), frame...) + clock.Schedule(0, func() { + a.ReceiveOnInterface(aIf, buf) + }) + }) +} + +// expressOne sends a few probe Interests and returns 1 if any probe receives +// Data (InterestResultData), 0 otherwise. +// +// A single probe can be lost while DV/FIB updates settle; retrying keeps this +// integration test strict without making it flaky. +func expressOne(t *testing.T, eng ndn.Engine, clock *DeterministicClock, name enc.Name) (gotData int64) { + t.Helper() + + for attempt := 0; attempt < 5; attempt++ { + iName := append(enc.Name(nil), name...) + iName = append(iName, + enc.NewGenericComponent("probe"), + enc.NewGenericComponent(fmt.Sprintf("%d", attempt)), + ) + + interest, err := eng.Spec().MakeInterest(iName, &ndn.InterestConfig{ + MustBeFresh: true, + Lifetime: optional.Some(1 * time.Second), + Nonce: utils.ConvertNonce(eng.Timer().Nonce()), + }, nil, nil) + if err != nil { + t.Fatalf("MakeInterest: %v", err) + } + + var received int64 + if err := eng.Express(interest, func(args ndn.ExpressCallbackArgs) { + if args.Result == ndn.InterestResultData { + atomic.StoreInt64(&received, 1) + } + }); err != nil { + t.Fatalf("Express: %v", err) + } + + // Allow one full lifetime + timeout slack. + clock.Advance(1200 * time.Millisecond) + if atomic.LoadInt64(&received) != 0 { + return 1 + } + } + + return 0 +} + +// expressOnceStrict sends one Interest and returns true only if Data arrives. +// No retries are done; this is useful for strict burst-quality assertions. +func expressOnceStrict(t *testing.T, eng ndn.Engine, clock *DeterministicClock, name enc.Name, seq int) bool { + t.Helper() + + iName := append(enc.Name(nil), name...) + iName = append(iName, + enc.NewGenericComponent("burst"), + enc.NewGenericComponent(fmt.Sprintf("%d", seq)), + ) + + interest, err := eng.Spec().MakeInterest(iName, &ndn.InterestConfig{ + MustBeFresh: true, + Lifetime: optional.Some(800 * time.Millisecond), + Nonce: utils.ConvertNonce(eng.Timer().Nonce()), + }, nil, nil) + if err != nil { + t.Fatalf("MakeInterest: %v", err) + } + + var got int64 + if err := eng.Express(interest, func(args ndn.ExpressCallbackArgs) { + if args.Result == ndn.InterestResultData { + atomic.StoreInt64(&got, 1) + } + }); err != nil { + t.Fatalf("Express: %v", err) + } + + clock.Advance(1 * time.Second) + return atomic.LoadInt64(&got) != 0 +} + +func expressBurstStrict(t *testing.T, eng ndn.Engine, clock *DeterministicClock, name enc.Name, count int) int { + t.Helper() + ok := 0 + for i := 0; i < count; i++ { + if expressOnceStrict(t, eng, clock, name, i) { + ok++ + } + } + return ok +} + +// attachProducer registers a handler for prefix on eng that replies with +// signed Data and counts every served Interest. Returns a pointer to the +// counter. +func attachProducer(t *testing.T, node *Node, prefix enc.Name) *int64 { + t.Helper() + eng := node.Engine() + var served int64 + signer := sig.NewSha256Signer() + if err := eng.AttachHandler(prefix, func(args ndn.InterestHandlerArgs) { + atomic.AddInt64(&served, 1) + data, err := eng.Spec().MakeData( + args.Interest.Name(), + &ndn.DataConfig{Freshness: optional.Some(1 * time.Second)}, + nil, + signer, + ) + if err == nil { + _ = args.Reply(data.Wire) + } + }); err != nil { + t.Fatalf("AttachHandler: %v", err) + } + + // Producer application route: deliver matching Interests to the app face. + // DV announcement propagates this prefix to other nodes, but the local node + // still needs a route from the forwarder to its own producer handler. + node.AddRoute(prefix, node.AppFaceID(), 0) + + return &served +} + +// ─── tests ────────────────────────────────────────────────────────────────── + +// TestDvTwoNodeEndToEnd verifies that a consumer on node1 can fetch Data from +// a producer on node0 when routing is provided entirely by DV. +// +// This is the simplest possible DV integration path: one direct link. +// The test MUST FAIL with the current code because DV prefix-table +// propagation via SVS does not complete: node1 logs "Reset remote prefixes" +// for node0 but never receives "Add remote prefix /ndn/test", so the FIB +// on node1 has no route for /ndn/test and the Interest times out. +func TestDvTwoNodeEndToEnd(t *testing.T) { + ResetSimTrust() + + clock := NewDeterministicClock(time.Unix(0, 0)) + n0 := NewNode(0, clock) + n1 := NewNode(1, clock) + + if err := n0.Start(); err != nil { + t.Fatalf("n0.Start: %v", err) + } + if err := n1.Start(); err != nil { + t.Fatalf("n1.Start: %v", err) + } + defer n0.Stop() + defer n1.Stop() + + // Wire before StartDv so both link faces are registered when StartDv + // iterates ifaceFaces to install sync-prefix routes. + n0.AddNetworkFace(0, func(_ uint64, frame []byte) { + buf := append([]byte(nil), frame...) + clock.Schedule(0, func() { n1.ReceiveOnInterface(0, buf) }) + }) + n1.AddNetworkFace(0, func(_ uint64, frame []byte) { + buf := append([]byte(nil), frame...) + clock.Schedule(0, func() { n0.ReceiveOnInterface(0, buf) }) + }) + + if err := n0.StartDv("/minindn", "/minindn/node0", ""); err != nil { + t.Fatalf("n0.StartDv: %v", err) + } + if err := n1.StartDv("/minindn", "/minindn/node1", ""); err != nil { + t.Fatalf("n1.StartDv: %v", err) + } + + prefix := mustName(t, "/ndn/test") + + // Register producer on n0's app engine. + served := attachProducer(t, n0, prefix) + + // Announce the prefix via DV — NOT via Node.AddRoute. + // This is what the real simulation uses: cgo_export.go calls + // node.AnnouncePrefixToDv from NdndSimRegisterProducer. + n0.AnnouncePrefixToDv(prefix, 0) + + // Advance 30 s of simulation time — six full heartbeat cycles with the + // default 5 s AdvertisementSyncInterval. If DV prefix propagation + // worked correctly this is far more than enough for: + // 1. router-to-router DV convergence (first heartbeat cycle) + // 2. prefix-table SVS sync (triggered immediately on announce) + // 3. FIB installation on n1 via NFDC + clock.Advance(30 * time.Second) + + // n1 now sends a probe Interest. If the FIB route was installed, the + // Interest reaches n0's producer and we get Data. If not, it times out. + if got := expressOne(t, n1.Engine(), clock, prefix); got == 0 { + t.Fatalf( + "node1 did not receive Data for %s after 30 s of DV simulation — "+ + "DV route installation or forwarding is broken (producer served %d interests; expected at least 1)", + prefix, atomic.LoadInt64(served), + ) + } +} + +// TestDvThreeNodeMultiHopEndToEnd verifies prefix reachability across a +// two-hop path: producer on node0, consumer on node2, with node1 in between. +// +// This mirrors the 2×2 grid simulation failure where nodes diagonally +// opposite the producer (highest hop-count) never received "Add remote prefix" +// notifications. The test MUST FAIL with the current code. +// +// Topology: node0 ── node1 ── node2 +// +// node0: producer for /ndn/test +// node1: pure forwarder (no app handler for /ndn/test) +// node2: consumer +func TestDvThreeNodeMultiHopEndToEnd(t *testing.T) { + ResetSimTrust() + + clock := NewDeterministicClock(time.Unix(0, 0)) + nodes := make([]*Node, 3) + for i := range nodes { + nodes[i] = NewNode(uint32(i), clock) + if err := nodes[i].Start(); err != nil { + t.Fatalf("nodes[%d].Start: %v", i, err) + } + defer nodes[i].Stop() + } + + // Wire: node0 ↔ node1 ↔ node2 (all faces added before StartDv). + wireLinear(clock, nodes) + + startDvOnNodes(t, nodes) + + prefix := mustName(t, "/ndn/test") + + // Producer lives on node0. + served := attachProducer(t, nodes[0], prefix) + + // Announce only on node0 — DV must propagate it to node1 and node2. + nodes[0].AnnouncePrefixToDv(prefix, 0) + + // 60 s = 12 heartbeat cycles. More than enough for two-hop propagation + // if the protocol implementation is correct. + clock.Advance(60 * time.Second) + + // Consumer sends from node2. Its only route to /ndn/test must come + // from DV. Without the fix, the Interest is dropped (no FIB entry). + if got := expressOne(t, nodes[2].Engine(), clock, prefix); got == 0 { + t.Fatalf( + "node2 (2-hop consumer) did not receive Data for %s after 60 s — "+ + "DV prefix propagation did not reach the indirect node "+ + "(producer served %d interests; if 0, route was never installed on node1 either)", + prefix, atomic.LoadInt64(served), + ) + } +} + +// TestDvPrefixWithdrawalStopsTraffic verifies that after a prefix is +// withdrawn via DV, consumers can no longer reach the producer. +// +// This test MUST FAIL with the current code because: +// - If forward propagation is broken (TestDvTwoNodeEndToEnd fails), +// withdrawal has no observable effect and the test logic cannot verify +// withdrawal semantics. +// - Even if forward propagation were fixed, withdrawal propagation has +// the same SVS-based mechanism and is equally untested. +// +// The test is written in "pass after forward fix" style: once the prefix +// can be announced and used (the first assertion), it then withdraws and +// checks that traffic stops. +func TestDvPrefixWithdrawalStopsTraffic(t *testing.T) { + ResetSimTrust() + + clock := NewDeterministicClock(time.Unix(0, 0)) + n0 := NewNode(0, clock) + n1 := NewNode(1, clock) + + if err := n0.Start(); err != nil { + t.Fatalf("n0.Start: %v", err) + } + if err := n1.Start(); err != nil { + t.Fatalf("n1.Start: %v", err) + } + defer n0.Stop() + defer n1.Stop() + + n0.AddNetworkFace(0, func(_ uint64, frame []byte) { + buf := append([]byte(nil), frame...) + clock.Schedule(0, func() { n1.ReceiveOnInterface(0, buf) }) + }) + n1.AddNetworkFace(0, func(_ uint64, frame []byte) { + buf := append([]byte(nil), frame...) + clock.Schedule(0, func() { n0.ReceiveOnInterface(0, buf) }) + }) + + if err := n0.StartDv("/minindn", "/minindn/node0", ""); err != nil { + t.Fatalf("n0.StartDv: %v", err) + } + if err := n1.StartDv("/minindn", "/minindn/node1", ""); err != nil { + t.Fatalf("n1.StartDv: %v", err) + } + + prefix := mustName(t, "/ndn/test") + served := attachProducer(t, n0, prefix) + + // Phase 1: announce and verify traffic flows. + n0.AnnouncePrefixToDv(prefix, 0) + clock.Advance(30 * time.Second) + + if got := expressOne(t, n1.Engine(), clock, prefix); got == 0 { + t.Fatalf( + "phase 1 (announce): node1 got no Data — "+ + "DV prefix propagation is broken " + + "(producer served %d interests; withdrawal test cannot proceed)", + atomic.LoadInt64(served), + ) + } + + // Phase 2: withdraw and verify traffic stops. + n0.WithdrawPrefixFromDv(prefix) + clock.Advance(30 * time.Second) // let withdrawal propagate + + // All pending Interests will have expired. Send a fresh one. + if got := expressOne(t, n1.Engine(), clock, prefix); got != 0 { + t.Fatalf( + "phase 2 (withdraw): node1 still received Data after withdrawal — "+ + "DV prefix withdrawal propagation is broken", + ) + } +} + +// TestDvDiamondFailoverBurstTraffic exercises a realistic multi-hop failover: +// +// node0 (producer) +// / \ +// node1 node2 +// \ / +// node3 (consumer) +// +// Traffic should succeed before failure. Then we cut the node1-node3 link and +// expect node3 to continue receiving data via node2 after DV re-convergence. +// +// This test is intentionally strict and aims to fail if failover propagation +// or forwarding-table updates lag/flake in realistic burst traffic. +func TestDvDiamondFailoverBurstTraffic(t *testing.T) { + ResetSimTrust() + + clock := NewDeterministicClock(time.Unix(0, 0)) + nodes := make([]*Node, 4) + for i := range nodes { + nodes[i] = NewNode(uint32(i), clock) + if err := nodes[i].Start(); err != nil { + t.Fatalf("nodes[%d].Start: %v", i, err) + } + defer nodes[i].Stop() + } + + // Diamond links with explicit per-node ifIndex mapping. + // node0: if0->node1, if1->node2 + // node1: if0->node0, if1->node3 + // node2: if0->node0, if1->node3 + // node3: if0->node1, if1->node2 + wirePair(clock, nodes[0], 0, nodes[1], 0) + wirePair(clock, nodes[0], 1, nodes[2], 0) + wirePair(clock, nodes[1], 1, nodes[3], 0) + wirePair(clock, nodes[2], 1, nodes[3], 1) + + startDvOnNodes(t, nodes) + + prefix := mustName(t, "/ndn/failover") + served := attachProducer(t, nodes[0], prefix) + nodes[0].AnnouncePrefixToDv(prefix, 0) + + // Initial convergence and steady state. + clock.Advance(60 * time.Second) + + before := expressBurstStrict(t, nodes[3].Engine(), clock, prefix, 20) + if before < 16 { + t.Fatalf("pre-failure burst quality too low: got %d/20 successful fetches (producer served=%d)", before, atomic.LoadInt64(served)) + } + + // Fail one of two equal-cost paths: node1 <-> node3. + nodes[1].RemoveNetworkFace(1) + nodes[3].RemoveNetworkFace(0) + + // Allow dead-neighbor detection + advertisement + FIB update. + // Default dead interval is 30s, so 90s is a strict but realistic window. + clock.Advance(90 * time.Second) + + after := expressBurstStrict(t, nodes[3].Engine(), clock, prefix, 20) + if after < 14 { + t.Fatalf( + "post-failure failover quality too low: got %d/20 successful fetches via alternate path (pre=%d/20, producer served=%d)", + after, before, atomic.LoadInt64(served), + ) + } +} + +// TestDvDiamondFastFailoverStrict is an intentionally strict multi-hop test +// that is expected to fail with current DV timers. +// +// Why it should fail now: +// - Router dead interval defaults to 30s. +// - This test demands good recovery within 10s after link loss. +// +// This captures a realistic operator expectation for near-real-time failover +// under burst traffic and provides a concrete failing target for future work. +func TestDvDiamondFastFailoverStrict(t *testing.T) { + ResetSimTrust() + + clock := NewDeterministicClock(time.Unix(0, 0)) + nodes := make([]*Node, 4) + for i := range nodes { + nodes[i] = NewNode(uint32(i), clock) + if err := nodes[i].Start(); err != nil { + t.Fatalf("nodes[%d].Start: %v", i, err) + } + defer nodes[i].Stop() + } + + wirePair(clock, nodes[0], 0, nodes[1], 0) + wirePair(clock, nodes[0], 1, nodes[2], 0) + wirePair(clock, nodes[1], 1, nodes[3], 0) + wirePair(clock, nodes[2], 1, nodes[3], 1) + + startDvOnNodes(t, nodes) + + prefix := mustName(t, "/ndn/fast-failover") + served := attachProducer(t, nodes[0], prefix) + nodes[0].AnnouncePrefixToDv(prefix, 0) + + clock.Advance(60 * time.Second) + + baseline := expressBurstStrict(t, nodes[3].Engine(), clock, prefix, 10) + if baseline < 9 { + t.Fatalf("baseline too low before failure: %d/10 (producer served=%d)", baseline, atomic.LoadInt64(served)) + } + + // Fail one branch of the diamond. + nodes[1].RemoveNetworkFace(1) + nodes[3].RemoveNetworkFace(0) + + // Intentionally strict: require recovery in 10s (well below default 30s dead interval). + clock.Advance(10 * time.Second) + + fast := expressBurstStrict(t, nodes[3].Engine(), clock, prefix, 10) + if fast < 8 { + t.Fatalf( + "fast failover target not met (expected >=8/10 within 10s, got %d/10; baseline=%d/10; producer served=%d)", + fast, baseline, atomic.LoadInt64(served), + ) + } +} + +// TestDvProducerMobilityFastRecoveryStrict is an intentionally strict +// multi-hop mobility test expected to fail today. +// +// Scenario (line topology): +// node0 (consumer) -- node1 -- node2 -- node3 (producer A) +// +// After baseline traffic from producer A, the producer moves to node1: +// - node3 withdraws and stops serving +// - node1 starts serving and announces the same prefix +// +// We require high delivery quality within 2s after the move. This is a +// realistic but aggressive SLO and is expected to fail with current timing. +func TestDvProducerMobilityFastRecoveryStrict(t *testing.T) { + ResetSimTrust() + + clock := NewDeterministicClock(time.Unix(0, 0)) + nodes := make([]*Node, 4) + for i := range nodes { + nodes[i] = NewNode(uint32(i), clock) + if err := nodes[i].Start(); err != nil { + t.Fatalf("nodes[%d].Start: %v", i, err) + } + defer nodes[i].Stop() + } + + // 0-1-2-3 line. + wireLinear(clock, nodes) + startDvOnNodes(t, nodes) + + prefix := mustName(t, "/ndn/mobile") + servedA := attachProducer(t, nodes[3], prefix) + nodes[3].AnnouncePrefixToDv(prefix, 0) + + clock.Advance(60 * time.Second) + + baseline := expressBurstStrict(t, nodes[0].Engine(), clock, prefix, 10) + if baseline < 9 { + t.Fatalf("baseline too low before mobility: %d/10 (servedA=%d)", baseline, atomic.LoadInt64(servedA)) + } + + // Producer mobility: old producer leaves, new producer appears. + nodes[3].WithdrawPrefixFromDv(prefix) + nodes[3].Engine().DetachHandler(prefix) + nodes[3].RemoveRoute(prefix, nodes[3].AppFaceID()) + + servedB := attachProducer(t, nodes[1], prefix) + nodes[1].AnnouncePrefixToDv(prefix, 0) + + // Intentionally strict recovery budget. + clock.Advance(2 * time.Second) + + fast := expressBurstStrict(t, nodes[0].Engine(), clock, prefix, 10) + if fast < 8 { + t.Fatalf( + "mobility fast-recovery target not met (expected >=8/10 within 2s, got %d/10; baseline=%d/10; servedA=%d servedB=%d)", + fast, baseline, atomic.LoadInt64(servedA), atomic.LoadInt64(servedB), + ) + } +} + +// TestDvBranchSwitchMobilityStrict is a realistic >1-hop branch-switch test +// intended to be a failing target. +// +// Topology: +// +// node3 (producer A) +// | +// node0 --- node1 +// | +// node2 --- node4 (producer B) +// +// Paths from consumer node0 to producers are both 2 hops: +// A path: node0 -> node1 -> node3 +// B path: node0 -> node2 -> node4 +// +// We move the producer from branch A to branch B and require high-quality +// recovery within 2s, which is intentionally strict and expected to fail. +func TestDvBranchSwitchMobilityStrict(t *testing.T) { + ResetSimTrust() + + clock := NewDeterministicClock(time.Unix(0, 0)) + nodes := make([]*Node, 5) + for i := range nodes { + nodes[i] = NewNode(uint32(i), clock) + if err := nodes[i].Start(); err != nil { + t.Fatalf("nodes[%d].Start: %v", i, err) + } + defer nodes[i].Stop() + } + + // node0-if0 <-> node1-if0 + // node0-if1 <-> node2-if0 + // node1-if1 <-> node3-if0 + // node2-if1 <-> node4-if0 + wirePair(clock, nodes[0], 0, nodes[1], 0) + wirePair(clock, nodes[0], 1, nodes[2], 0) + wirePair(clock, nodes[1], 1, nodes[3], 0) + wirePair(clock, nodes[2], 1, nodes[4], 0) + + startDvOnNodes(t, nodes) + + prefix := mustName(t, "/ndn/branch-switch") + servedA := attachProducer(t, nodes[3], prefix) + nodes[3].AnnouncePrefixToDv(prefix, 0) + + clock.Advance(60 * time.Second) + + baseline := expressBurstStrict(t, nodes[0].Engine(), clock, prefix, 10) + if baseline < 9 { + t.Fatalf("baseline too low before move: %d/10 (servedA=%d)", baseline, atomic.LoadInt64(servedA)) + } + + // Move producer from branch A to branch B. + nodes[3].WithdrawPrefixFromDv(prefix) + nodes[3].Engine().DetachHandler(prefix) + nodes[3].RemoveRoute(prefix, nodes[3].AppFaceID()) + + servedB := attachProducer(t, nodes[4], prefix) + nodes[4].AnnouncePrefixToDv(prefix, 0) + + // Aggressive SLO: recover high quality within 2s. + clock.Advance(2 * time.Second) + + fast := expressBurstStrict(t, nodes[0].Engine(), clock, prefix, 10) + if fast < 8 { + t.Fatalf( + "branch-switch fast recovery target not met (expected >=8/10 within 2s, got %d/10; baseline=%d/10; servedA=%d servedB=%d)", + fast, baseline, atomic.LoadInt64(servedA), atomic.LoadInt64(servedB), + ) + } +} + +// TestDvLinePartitionDisconnectsTrafficStrict verifies that a hard partition +// in a line topology causes end-to-end traffic to stop. +// +// Topology: node0 -- node1 -- node2 -- node3(producer), consumer at node0. +// +// After removing the middle link (node1<->node2), node0 and node3 are in +// different connected components. The correct behavior is 0 successful fetches. +func TestDvLinePartitionDisconnectsTrafficStrict(t *testing.T) { + ResetSimTrust() + + clock := NewDeterministicClock(time.Unix(0, 0)) + nodes := make([]*Node, 4) + for i := range nodes { + nodes[i] = NewNode(uint32(i), clock) + if err := nodes[i].Start(); err != nil { + t.Fatalf("nodes[%d].Start: %v", i, err) + } + defer nodes[i].Stop() + } + + wireLinear(clock, nodes) + startDvOnNodes(t, nodes) + + prefix := mustName(t, "/ndn/partition") + served := attachProducer(t, nodes[3], prefix) + nodes[3].AnnouncePrefixToDv(prefix, 0) + + clock.Advance(60 * time.Second) + + baseline := expressBurstStrict(t, nodes[0].Engine(), clock, prefix, 10) + if baseline < 9 { + t.Fatalf("baseline too low before partition: %d/10 (served=%d)", baseline, atomic.LoadInt64(served)) + } + + // Hard partition in the middle of the 3-hop path. + nodes[1].RemoveNetworkFace(1) + nodes[2].RemoveNetworkFace(0) + + clock.Advance(5 * time.Second) + + post := expressBurstStrict(t, nodes[0].Engine(), clock, prefix, 10) + if post != 0 { + t.Fatalf( + "hard partition should disconnect traffic (expected 0/10 after partition, got %d/10; baseline=%d/10; served=%d)", + post, baseline, atomic.LoadInt64(served), + ) + } +} diff --git a/sim/engine.go b/sim/engine.go new file mode 100644 index 00000000..aaa49f06 --- /dev/null +++ b/sim/engine.go @@ -0,0 +1,569 @@ +package sim + +import ( + "encoding/binary" + "fmt" + "sync" + "sync/atomic" + "time" + + "github.com/named-data/ndnd/fw/defn" + enc "github.com/named-data/ndnd/std/encoding" + "github.com/named-data/ndnd/std/ndn" + mgmt "github.com/named-data/ndnd/std/ndn/mgmt_2022" + spec "github.com/named-data/ndnd/std/ndn/spec_2022" + "github.com/named-data/ndnd/std/types/optional" +) + +// SimEngine implements ndn.Engine for discrete-event simulation. +// +// Unlike BasicEngine, it processes all packets synchronously on the caller's +// thread. This is essential because ns-3 is single-threaded: all API calls +// (including Simulator::Schedule, NetDevice::Send) must happen on the main +// simulation thread. +// +// When the forwarder delivers a packet to the application face, SimFace.Receive() +// calls SimEngine.onPacket() directly, which parses, dispatches to the handler, +// and the handler's Reply callback sends Data back — all synchronously, all on +// the ns-3 main thread. +type SimEngine struct { + face ndn.Face + timer ndn.Timer + running atomic.Bool + + // Node ID for callbacks to C++ + nodeID uint32 + + // Reference to the node's forwarder for ExecMgmtCmd + forwarder *SimForwarder + + // App face ID for default route registration + appFaceID uint64 + + // Called when Data is received at this engine (consumer side) + onDataReceived func(nodeID uint32, dataSize uint32, dataName string) + + // Interest handler FIB (prefix → handler) + fib *nameTrie[ndn.InterestHandler] + fibLock sync.Mutex + + // Pending Interest Table for Express() — maps PIT token to callback + pit map[uint64]*pendingInterest + pitLock sync.Mutex + pitSeq uint64 +} + +// pendingInterest tracks an outstanding Interest expression. +type pendingInterest struct { + callback ndn.ExpressCallbackFunc + timeoutCancel func() error + name enc.Name + canBePrefix bool +} + +var _ ndn.Engine = (*SimEngine)(nil) + +// NewSimEngine creates a new simulation engine attached to the given face and timer. +func NewSimEngine(face ndn.Face, timer ndn.Timer, nodeID uint32, onDataReceived func(uint32, uint32, string)) *SimEngine { + return &SimEngine{ + face: face, + timer: timer, + nodeID: nodeID, + onDataReceived: onDataReceived, + fib: newNameTrie[ndn.InterestHandler](), + pit: make(map[uint64]*pendingInterest), + } +} + +func (e *SimEngine) String() string { + return "SimEngine" +} + +func (e *SimEngine) EngineTrait() ndn.Engine { + return e +} + +func (e *SimEngine) Spec() ndn.Spec { + return spec.Spec{} +} + +func (e *SimEngine) Timer() ndn.Timer { + return e.timer +} + +func (e *SimEngine) Face() ndn.Face { + return e.face +} + +func (e *SimEngine) IsRunning() bool { + return e.running.Load() +} + +func (e *SimEngine) Start() error { + if e.face.IsRunning() { + return fmt.Errorf("face is already running") + } + + // Register synchronous packet handler — no goroutine, no channel. + e.face.OnPacket(func(frame []byte) { + e.onPacket(frame) + }) + + if err := e.face.Open(); err != nil { + return err + } + e.running.Store(true) + return nil +} + +func (e *SimEngine) Stop() error { + if !e.running.Load() { + return fmt.Errorf("engine is not running") + } + e.running.Store(false) + return e.face.Close() +} + +func (e *SimEngine) AttachHandler(prefix enc.Name, handler ndn.InterestHandler) error { + e.fibLock.Lock() + defer e.fibLock.Unlock() + n := e.fib.matchAlways(prefix) + if n.val != nil { + return fmt.Errorf("%w: %s", ndn.ErrMultipleHandlers, prefix) + } + n.val = handler + return nil +} + +func (e *SimEngine) DetachHandler(prefix enc.Name) error { + e.fibLock.Lock() + defer e.fibLock.Unlock() + n := e.fib.exactMatch(prefix) + if n == nil { + return fmt.Errorf("no handler for prefix %s", prefix) + } + n.val = nil + return nil +} + +// DispatchInterest delivers an encoded Interest directly to the local handler +// registered for its prefix, bypassing the forwarder. This avoids the +// same-face loop prevention that would drop the Interest when both the +// source and destination are on the same application face. +func (e *SimEngine) DispatchInterest(interest *ndn.EncodedInterest) { + name := interest.FinalName + + handler := func() ndn.InterestHandler { + e.fibLock.Lock() + defer e.fibLock.Unlock() + n := e.fib.prefixMatch(name) + for n != nil && n.val == nil { + n = n.par + } + if n != nil { + return n.val + } + return nil + }() + if handler == nil { + return + } + + // Parse the Interest from wire for the handler + var pkt *spec.Packet + var err error + raw := interest.Wire + if len(raw) == 1 { + pkt, _, err = spec.ReadPacket(enc.NewBufferView(raw[0])) + } else { + pkt, _, err = spec.ReadPacket(enc.NewWireView(raw)) + } + if err != nil || pkt.Interest == nil { + return + } + + handler(ndn.InterestHandlerArgs{ + Interest: pkt.Interest, + Reply: func(wire enc.Wire) error { return nil }, // discard reply Data + Deadline: e.timer.Now().Add(4 * time.Second), + }) +} + +// Express sends an Interest and optionally tracks a callback for the reply. +// If callback is nil, the Interest is fire-and-forget (e.g., Sync Interests). +func (e *SimEngine) Express(interest *ndn.EncodedInterest, callback ndn.ExpressCallbackFunc) error { + if !e.running.Load() || !e.face.IsRunning() { + return ndn.ErrFaceDown + } + + wire := interest.Wire + + if callback != nil { + // Generate a PIT token to match the returning Data + e.pitLock.Lock() + e.pitSeq++ + token := e.pitSeq + tokenBytes := make([]byte, 8) + binary.BigEndian.PutUint64(tokenBytes, token) + + lifetime := interest.Config.Lifetime.GetOr(4 * time.Second) + pi := &pendingInterest{ + callback: callback, + name: interest.FinalName, + canBePrefix: interest.Config.CanBePrefix, + } + e.pit[token] = pi + e.pitLock.Unlock() + + // Schedule timeout + pi.timeoutCancel = e.timer.Schedule(lifetime+10*time.Millisecond, func() { + e.pitLock.Lock() + _, ok := e.pit[token] + if ok { + delete(e.pit, token) + } + e.pitLock.Unlock() + if ok { + callback(ndn.ExpressCallbackArgs{ + Result: ndn.InterestResultTimeout, + }) + } + }) + + // LP-wrap with PIT token (and NextHopFaceId if set) + lpHdr := &spec.LpPacket{ + PitToken: tokenBytes, + Fragment: wire, + } + if hop, ok := interest.Config.NextHopId.Get(); ok { + lpHdr.NextHopFaceId.Set(hop) + } + lpPkt := &spec.Packet{LpPacket: lpHdr} + encoder := spec.PacketEncoder{} + encoder.Init(lpPkt) + wire = encoder.Encode(lpPkt) + if wire == nil { + return fmt.Errorf("failed to encode LP packet") + } + } + + return e.face.Send(wire) +} + +// ExecMgmtCmd implements management commands by directly manipulating the SimForwarder. +func (e *SimEngine) ExecMgmtCmd(module string, cmd string, args any) (any, error) { + if e.forwarder == nil { + return nil, fmt.Errorf("SimEngine: no forwarder attached") + } + + ca, ok := args.(*mgmt.ControlArgs) + if !ok || ca == nil { + return nil, fmt.Errorf("SimEngine: ExecMgmtCmd expects *mgmt.ControlArgs") + } + + switch module { + case "rib": + switch cmd { + case "register": + if ca.Name == nil { + return nil, fmt.Errorf("rib/register: missing name") + } + faceID := ca.FaceId.GetOr(0) + cost := ca.Cost.GetOr(0) + origin := ca.Origin.GetOr(0) + if faceID == 0 { + // Default to app face — the DV registers prefixes for itself + faceID = e.appFaceID + } + e.forwarder.AddRouteWithOrigin(ca.Name, faceID, cost, origin) + return &mgmt.ControlResponse{Val: &mgmt.ControlResponseVal{ + StatusCode: 200, + Params: &mgmt.ControlArgs{Name: ca.Name, FaceId: optional.Some(faceID)}, + }}, nil + case "unregister": + if ca.Name == nil { + return nil, fmt.Errorf("rib/unregister: missing name") + } + faceID := ca.FaceId.GetOr(0) + origin := ca.Origin.GetOr(0) + if faceID > 0 { + e.forwarder.RemoveRouteWithOrigin(ca.Name, faceID, origin) + } + return &mgmt.ControlResponse{Val: &mgmt.ControlResponseVal{StatusCode: 200}}, nil + } + case "faces": + switch cmd { + case "update": + // No-op in simulation (local fields, etc.) + return &mgmt.ControlResponse{Val: &mgmt.ControlResponseVal{StatusCode: 200}}, nil + case "create": + // In simulation, faces are pre-created by ns-3. Return the existing app face. + return &mgmt.ControlResponse{Val: &mgmt.ControlResponseVal{ + StatusCode: 409, // already exists + Params: &mgmt.ControlArgs{FaceId: optional.Some(e.appFaceID)}, + }}, nil + case "destroy": + // No-op — ns-3 manages face lifecycle + return &mgmt.ControlResponse{Val: &mgmt.ControlResponseVal{StatusCode: 200}}, nil + } + case "strategy-choice": + if cmd == "set" { + if ca.Strategy == nil || ca.Name == nil { + return nil, fmt.Errorf("strategy-choice/set: missing name or strategy") + } + strategyName := ca.Strategy.Name + // Resolve versioned strategy name if version is missing + if len(strategyName) > len(defn.STRATEGY_PREFIX) && + !strategyName[len(strategyName)-1].IsVersion() { + // Look up the strategy and add default version 1 + strategyName = strategyName.Append(enc.NewVersionComponent(1)) + } + e.forwarder.SetStrategy(ca.Name, strategyName) + return &mgmt.ControlResponse{Val: &mgmt.ControlResponseVal{StatusCode: 200}}, nil + } + } + + return nil, fmt.Errorf("SimEngine: unsupported mgmt cmd %s/%s", module, cmd) +} + +// SetCmdSec is a no-op in simulation. +func (e *SimEngine) SetCmdSec(signer ndn.Signer, validator func(enc.Name, enc.Wire, ndn.Signature) bool) { +} + +// RegisterRoute is a no-op — FIB routes are managed by C++ via NdndSimAddRoute. +func (e *SimEngine) RegisterRoute(prefix enc.Name) error { + return nil +} + +// UnregisterRoute is a no-op. +func (e *SimEngine) UnregisterRoute(prefix enc.Name) error { + return nil +} + +// Post executes the task synchronously. +func (e *SimEngine) Post(task func()) { + task() +} + +// --- Packet processing (synchronous) --- + +func (e *SimEngine) onPacket(frame []byte) { + reader := enc.NewBufferView(frame) + + var pitToken []byte + var incomingFaceId optional.Optional[uint64] + var raw enc.Wire + + pkt, ctx, err := spec.ReadPacket(reader) + if err != nil { + return + } + + if pkt.LpPacket != nil { + lp := pkt.LpPacket + if lp.FragIndex.IsSet() || lp.FragCount.IsSet() { + return // fragmentation not supported + } + + raw = lp.Fragment + pitToken = lp.PitToken + incomingFaceId = lp.IncomingFaceId + + // Parse inner packet + if len(raw) == 1 { + pkt, ctx, err = spec.ReadPacket(enc.NewBufferView(raw[0])) + } else { + pkt, ctx, err = spec.ReadPacket(enc.NewWireView(raw)) + } + if err != nil || (pkt.Data == nil) == (pkt.Interest == nil) { + return + } + } else { + raw = reader.Range(0, reader.Length()) + } + + if pkt.Interest != nil { + e.onInterest(ndn.InterestHandlerArgs{ + Interest: pkt.Interest, + RawInterest: raw, + SigCovered: ctx.Interest_context.SigCovered(), + PitToken: pitToken, + IncomingFaceId: incomingFaceId, + }) + } + if pkt.Data != nil { + // Try to match against pending Interests (Express callbacks) + e.onData(pkt.Data, raw, ctx.Data_context.SigCovered(), pitToken) + + if e.onDataReceived != nil { + e.onDataReceived(e.nodeID, uint32(raw.Length()), pkt.Data.Name().String()) + } + } +} + +func (e *SimEngine) onInterest(args ndn.InterestHandlerArgs) { + name := args.Interest.Name() + args.Deadline = e.timer.Now().Add( + args.Interest.Lifetime().GetOr(4 * time.Second)) + + handler := func() ndn.InterestHandler { + e.fibLock.Lock() + defer e.fibLock.Unlock() + n := e.fib.prefixMatch(name) + for n != nil && n.val == nil { + n = n.par + } + if n != nil { + return n.val + } + return nil + }() + + if handler == nil { + return + } + + args.Reply = e.makeReplyFunc(args.PitToken) + handler(args) +} + +func (e *SimEngine) makeReplyFunc(pitToken []byte) ndn.WireReplyFunc { + return func(dataWire enc.Wire) error { + if dataWire == nil || !e.running.Load() || !e.face.IsRunning() { + return ndn.ErrFaceDown + } + + var outWire enc.Wire = dataWire + if pitToken != nil { + lpPkt := &spec.Packet{ + LpPacket: &spec.LpPacket{ + PitToken: pitToken, + Fragment: dataWire, + }, + } + encoder := spec.PacketEncoder{} + encoder.Init(lpPkt) + wire := encoder.Encode(lpPkt) + if wire != nil { + outWire = wire + } + } + + return e.face.Send(outWire) + } +} + +// onData matches incoming Data against pending Interests from Express(). +func (e *SimEngine) onData(data ndn.Data, raw enc.Wire, sigCovered enc.Wire, pitToken []byte) { + // Match by PIT token (preferred — reliable) + if len(pitToken) == 8 { + token := binary.BigEndian.Uint64(pitToken) + e.pitLock.Lock() + pi, ok := e.pit[token] + if ok { + delete(e.pit, token) + } + e.pitLock.Unlock() + if ok && pi.callback != nil { + if pi.timeoutCancel != nil { + pi.timeoutCancel() + } + pi.callback(ndn.ExpressCallbackArgs{ + Result: ndn.InterestResultData, + Data: data, + RawData: raw, + SigCovered: sigCovered, + }) + return + } + } + + // Fall back to name-based matching (scan all pending Interests) + dataName := data.Name() + e.pitLock.Lock() + var matchToken uint64 + var matchPI *pendingInterest + for tok, pi := range e.pit { + if pi.canBePrefix { + if dataName.IsPrefix(pi.name) || pi.name.IsPrefix(dataName) { + matchToken = tok + matchPI = pi + break + } + } else { + if pi.name.Equal(dataName) { + matchToken = tok + matchPI = pi + break + } + } + } + if matchPI != nil { + delete(e.pit, matchToken) + } + e.pitLock.Unlock() + + if matchPI != nil && matchPI.callback != nil { + if matchPI.timeoutCancel != nil { + matchPI.timeoutCancel() + } + matchPI.callback(ndn.ExpressCallbackArgs{ + Result: ndn.InterestResultData, + Data: data, + RawData: raw, + SigCovered: sigCovered, + }) + } +} + +// --- Minimal name trie for Interest dispatch --- + +type nameTrie[V any] struct { + val V + par *nameTrie[V] + dep int + chd map[string]*nameTrie[V] +} + +func newNameTrie[V any]() *nameTrie[V] { + return &nameTrie[V]{chd: map[string]*nameTrie[V]{}} +} + +func (n *nameTrie[V]) exactMatch(name enc.Name) *nameTrie[V] { + if len(name) <= n.dep { + return n + } + c := name[n.dep].TlvStr() + if ch, ok := n.chd[c]; ok { + return ch.exactMatch(name) + } + return nil +} + +func (n *nameTrie[V]) prefixMatch(name enc.Name) *nameTrie[V] { + if len(name) <= n.dep { + return n + } + c := name[n.dep].TlvStr() + if ch, ok := n.chd[c]; ok { + return ch.prefixMatch(name) + } + return n +} + +func (n *nameTrie[V]) matchAlways(name enc.Name) *nameTrie[V] { + if len(name) <= n.dep { + return n + } + c := name[n.dep].TlvStr() + ch, ok := n.chd[c] + if !ok { + ch = &nameTrie[V]{ + par: n, + dep: n.dep + 1, + chd: map[string]*nameTrie[V]{}, + } + n.chd[c] = ch + } + return ch.matchAlways(name) +} diff --git a/sim/face.go b/sim/face.go new file mode 100644 index 00000000..b8e65cfd --- /dev/null +++ b/sim/face.go @@ -0,0 +1,149 @@ +package sim + +import ( + "fmt" + "sync" + + enc "github.com/named-data/ndnd/std/encoding" + "github.com/named-data/ndnd/std/ndn" +) + +// SendFunc is called when the simulation face wants to transmit a packet. +// The external simulator should deliver the bytes to the appropriate link. +type SendFunc func(frame []byte) + +// SimFace implements ndn.Face for simulation environments. +// Instead of real network I/O, it uses callbacks: +// - Outgoing packets invoke the registered SendFunc. +// - Incoming packets are injected by calling Receive(). +// +// This face is used by the BasicEngine / SimEngine on the std/ application +// layer. It is NOT the same as the forwarder-level face used inside fw/. +type SimFace struct { + mu sync.Mutex + running bool + local bool + onPkt func(frame []byte) + onError func(err error) + onUp []func() + onDown []func() + sendFunc SendFunc +} + +var _ ndn.Face = (*SimFace)(nil) + +// NewSimFace creates a simulation face. sendFunc is called for every +// outgoing packet. If sendFunc is nil, sends are silently dropped. +func NewSimFace(sendFunc SendFunc, local bool) *SimFace { + return &SimFace{ + sendFunc: sendFunc, + local: local, + } +} + +func (f *SimFace) String() string { + return "sim-face" +} + +func (f *SimFace) IsRunning() bool { + f.mu.Lock() + defer f.mu.Unlock() + return f.running +} + +func (f *SimFace) IsLocal() bool { + return f.local +} + +func (f *SimFace) OnPacket(onPkt func(frame []byte)) { + f.mu.Lock() + defer f.mu.Unlock() + f.onPkt = onPkt +} + +func (f *SimFace) OnError(onError func(err error)) { + f.mu.Lock() + defer f.mu.Unlock() + f.onError = onError +} + +func (f *SimFace) Open() error { + f.mu.Lock() + defer f.mu.Unlock() + if f.running { + return fmt.Errorf("face is already running") + } + f.running = true + for _, cb := range f.onUp { + cb() + } + return nil +} + +func (f *SimFace) Close() error { + f.mu.Lock() + defer f.mu.Unlock() + if !f.running { + return nil + } + f.running = false + for _, cb := range f.onDown { + cb() + } + return nil +} + +func (f *SimFace) Send(pkt enc.Wire) error { + f.mu.Lock() + if !f.running { + f.mu.Unlock() + return fmt.Errorf("face is not running") + } + sf := f.sendFunc + f.mu.Unlock() + + if sf == nil { + return nil // drop + } + sf(pkt.Join()) + return nil +} + +// Receive injects an incoming packet from the simulator into this face. +// This is the entry point for ns-3 → NDNd packet delivery. +func (f *SimFace) Receive(frame []byte) { + f.mu.Lock() + handler := f.onPkt + f.mu.Unlock() + if handler != nil { + handler(frame) + } +} + +func (f *SimFace) OnUp(onUp func()) (cancel func()) { + f.mu.Lock() + defer f.mu.Unlock() + f.onUp = append(f.onUp, onUp) + idx := len(f.onUp) - 1 + return func() { + f.mu.Lock() + defer f.mu.Unlock() + if idx < len(f.onUp) { + f.onUp[idx] = func() {} // neuter + } + } +} + +func (f *SimFace) OnDown(onDown func()) (cancel func()) { + f.mu.Lock() + defer f.mu.Unlock() + f.onDown = append(f.onDown, onDown) + idx := len(f.onDown) - 1 + return func() { + f.mu.Lock() + defer f.mu.Unlock() + if idx < len(f.onDown) { + f.onDown[idx] = func() {} // neuter + } + } +} diff --git a/sim/forwarder.go b/sim/forwarder.go new file mode 100644 index 00000000..53d15183 --- /dev/null +++ b/sim/forwarder.go @@ -0,0 +1,233 @@ +package sim + +import ( + "sync" + "sync/atomic" + "time" + + "github.com/named-data/ndnd/fw/defn" + "github.com/named-data/ndnd/fw/dispatch" + "github.com/named-data/ndnd/fw/fw" + "github.com/named-data/ndnd/fw/table" + enc "github.com/named-data/ndnd/std/encoding" +) + +// globalFaceID is a process-wide atomic counter ensuring face IDs are unique +// across all simulated nodes. This is critical because faces are registered in +// the global dispatch.FaceDispatch table shared by all forwarder threads. +var globalFaceID atomic.Uint64 + +const ( + // Maintenance interval for PIT/CS expiry and dead nonce list cleanup. + simMaintenanceInterval = 100 * time.Millisecond +) + +// SimForwarder wraps a real fw.Thread to provide per-node NDN forwarding +// in simulation mode. It delegates all packet processing to the real +// forwarder pipeline rather than reimplementing it. +type SimForwarder struct { + thread *fw.Thread + + clock Clock + + // Per-node RIB (routes go through here so readvertise fires) + rib *table.RibTable + + // Per-node face table (face ID → DispatchFace) + faces map[uint64]*DispatchFace + faceMu sync.Mutex + + // Scheduled maintenance event + maintEvent EventID + + // Counters + nPktsIn atomic.Uint64 + nPktsOut atomic.Uint64 +} + +// NewSimForwarder creates a new simulation forwarder backed by a real fw.Thread. +// Each simulated node should have its own SimForwarder. +func NewSimForwarder(clock Clock) *SimForwarder { + rib := &table.RibTable{} + rib.InitRoot() + + fwd := &SimForwarder{ + clock: clock, + faces: make(map[uint64]*DispatchFace), + rib: rib, + } + + // Create a real forwarding thread (ID 0 — single-threaded in sim) + fwd.thread = fw.NewThread(0) + + // Give this node its own FIB (not the global shared one) + fwd.thread.SetFib(table.NewFibStrategyTree()) + + return fwd +} + +// Start schedules periodic maintenance. +func (fwd *SimForwarder) Start() { + fwd.scheduleMaintenance() +} + +// Stop cancels scheduled maintenance. +func (fwd *SimForwarder) Stop() { + if fwd.maintEvent != 0 { + fwd.clock.Cancel(fwd.maintEvent) + fwd.maintEvent = 0 + } +} + +func (fwd *SimForwarder) scheduleMaintenance() { + fwd.maintEvent = fwd.clock.Schedule(simMaintenanceInterval, func() { + fwd.thread.RunMaintenance() + fwd.scheduleMaintenance() + }) +} + +// --- Face management --- + +// AddFace creates a new DispatchFace, registers it in the global dispatch table, +// and returns its face ID. The sendFunc callback is invoked when the forwarder +// wants to transmit a packet out this face. +func (fwd *SimForwarder) AddFace(scope defn.Scope, linkType defn.LinkType, sendFunc FwSendFunc) uint64 { + fwd.faceMu.Lock() + defer fwd.faceMu.Unlock() + + id := globalFaceID.Add(1) + + face := NewDispatchFace(id, scope, linkType, sendFunc) + fwd.faces[id] = face + dispatch.AddFace(id, face) + + return id +} + +// RemoveFace removes a face from both the local table and global dispatch. +func (fwd *SimForwarder) RemoveFace(id uint64) { + fwd.faceMu.Lock() + defer fwd.faceMu.Unlock() + + delete(fwd.faces, id) + dispatch.RemoveFace(id) +} + +// GetFace returns a face by ID. +func (fwd *SimForwarder) GetFace(id uint64) *DispatchFace { + fwd.faceMu.Lock() + defer fwd.faceMu.Unlock() + return fwd.faces[id] +} + +// --- RIB/FIB management --- + +// withNodeFib temporarily swaps the global FibStrategyTable to this node's +// FIB for the duration of f(). The simulation is single-threaded so this +// is safe — it ensures RIB's updateNexthopsEnc writes to the correct FIB. +func (fwd *SimForwarder) withNodeFib(f func()) { + old := table.FibStrategyTable + table.FibStrategyTable = fwd.thread.Fib() + defer func() { table.FibStrategyTable = old }() + f() +} + +// AddRoute adds a route through this node's RIB so that readvertise fires. +func (fwd *SimForwarder) AddRoute(name enc.Name, faceID uint64, cost uint64) { + fwd.AddRouteWithOrigin(name, faceID, cost, 0) +} + +// AddRouteWithOrigin adds a route with a specific origin value. +func (fwd *SimForwarder) AddRouteWithOrigin(name enc.Name, faceID uint64, cost uint64, origin uint64) { + fwd.withNodeFib(func() { + fwd.rib.AddEncRoute(name, &table.Route{ + FaceID: faceID, + Cost: cost, + Origin: origin, + }) + }) +} + +// SetStrategy sets the forwarding strategy for a prefix. +func (fwd *SimForwarder) SetStrategy(prefix enc.Name, strategy enc.Name) { + fwd.thread.Fib().SetStrategyEnc(prefix, strategy) +} + +// RemoveRoute removes a route through this node's RIB so that readvertise fires. +func (fwd *SimForwarder) RemoveRoute(name enc.Name, faceID uint64) { + fwd.RemoveRouteWithOrigin(name, faceID, 0) +} + +// RemoveRouteWithOrigin removes a route with a specific origin value. +func (fwd *SimForwarder) RemoveRouteWithOrigin(name enc.Name, faceID uint64, origin uint64) { + fwd.withNodeFib(func() { + fwd.rib.RemoveRouteEnc(name, faceID, origin) + }) +} + +// --- Packet processing --- + +// ReceivePacket is the main entry point for packets arriving from ns-3. +// It parses the frame and dispatches it synchronously through the real +// forwarding pipeline. +func (fwd *SimForwarder) ReceivePacket(faceID uint64, frame []byte) { + face := fwd.GetFace(faceID) + if face == nil || face.State() != defn.Up { + return + } + + // Parse the frame as an NDNLPv2 / bare TLV packet + wire := enc.Wire{frame} + parsed, err := defn.ParseFwPacket(enc.NewWireView(wire), false) + if err != nil { + return + } + + pkt := &defn.Pkt{ + IncomingFaceID: faceID, + } + + if parsed.LpPacket != nil { + // LP-wrapped: extract PIT token, NextHopFaceId, and inner fragment + lp := parsed.LpPacket + pkt.PitToken = lp.PitToken + pkt.CongestionMark = lp.CongestionMark + pkt.NextHopFaceID = lp.NextHopFaceId + + fragment := lp.Fragment + if len(fragment) == 0 { + return + } + inner, err := defn.ParseFwPacket(enc.NewWireView(fragment), false) + if err != nil { + return + } + pkt.Raw = fragment + pkt.L3 = inner + } else { + // Bare Interest or Data + pkt.Raw = wire + pkt.L3 = parsed + } + + if pkt.L3 == nil || (pkt.L3.Interest == nil && pkt.L3.Data == nil) { + return + } + + // Fill in the name for the forwarding pipeline + if pkt.L3.Interest != nil { + pkt.Name = pkt.L3.Interest.NameV + } else if pkt.L3.Data != nil { + pkt.Name = pkt.L3.Data.NameV + } + + fwd.nPktsIn.Add(1) + + // Synchronously process through the real forwarding pipeline + fwd.thread.ProcessPacket(pkt) +} + +// Thread returns the underlying fw.Thread (for testing/debug access). +func (fwd *SimForwarder) Thread() *fw.Thread { + return fwd.thread +} diff --git a/sim/fw_face.go b/sim/fw_face.go new file mode 100644 index 00000000..2ed704ee --- /dev/null +++ b/sim/fw_face.go @@ -0,0 +1,101 @@ +package sim + +import ( + "fmt" + + "github.com/named-data/ndnd/fw/defn" + "github.com/named-data/ndnd/fw/dispatch" +) + +// FwSendFunc is the callback for outgoing packets from a forwarder face. +// faceID identifies the sending face; frame is the encoded LP packet bytes. +type FwSendFunc func(faceID uint64, frame []byte) + +// DispatchFace implements dispatch.Face for the simulation environment. +// Each network interface (and the internal app face) on a simulated node +// is represented by one DispatchFace registered in dispatch.FaceDispatch. +type DispatchFace struct { + faceID uint64 + scope defn.Scope + linkType defn.LinkType + localURI *defn.URI + remoteURI *defn.URI + state defn.State + onSend FwSendFunc +} + +var _ dispatch.Face = (*DispatchFace)(nil) + +// NewDispatchFace creates a new simulation-mode dispatch face. +func NewDispatchFace( + faceID uint64, + scope defn.Scope, + linkType defn.LinkType, + onSend FwSendFunc, +) *DispatchFace { + scheme := "sim-net" + if scope == defn.Local { + scheme = "sim-app" + } + return &DispatchFace{ + faceID: faceID, + scope: scope, + linkType: linkType, + localURI: defn.DecodeURIString(fmt.Sprintf("%s://local/%d", scheme, faceID)), + remoteURI: defn.DecodeURIString(fmt.Sprintf("%s://remote/%d", scheme, faceID)), + state: defn.Up, + onSend: onSend, + } +} + +func (f *DispatchFace) String() string { + return fmt.Sprintf("sim-face-%d", f.faceID) +} + +func (f *DispatchFace) SetFaceID(id uint64) { f.faceID = id } +func (f *DispatchFace) FaceID() uint64 { return f.faceID } +func (f *DispatchFace) LocalURI() *defn.URI { return f.localURI } +func (f *DispatchFace) RemoteURI() *defn.URI { return f.remoteURI } +func (f *DispatchFace) Scope() defn.Scope { return f.scope } +func (f *DispatchFace) LinkType() defn.LinkType { return f.linkType } +func (f *DispatchFace) MTU() int { return defn.MaxNDNPacketSize } +func (f *DispatchFace) State() defn.State { return f.state } + +// SendPacket is called by fw.Thread when it wants to send a packet out this face. +// It encodes the packet as an LP frame (with PIT token if present) and invokes +// the send callback. +func (f *DispatchFace) SendPacket(out dispatch.OutPkt) { + if f.onSend == nil || f.state != defn.Up { + return + } + pkt := out.Pkt + if pkt == nil || pkt.Raw == nil { + return + } + + // Wrap in LP frame with PIT token (like the real link service) + lpFrag := &defn.FwLpPacket{ + Fragment: pkt.Raw, + PitToken: out.PitToken, + } + + // Include IncomingFaceId for local (app) faces so DV handlers + // can identify which link face the Interest originally arrived on. + if f.scope == defn.Local && out.InFace > 0 { + lpFrag.IncomingFaceId.Set(out.InFace) + } + + lpPkt := defn.FwPacket{LpPacket: lpFrag} + wire := lpPkt.Encode() + if wire == nil { + // Fallback: send raw bytes without LP envelope + f.onSend(f.faceID, pkt.Raw.Join()) + return + } + f.onSend(f.faceID, wire.Join()) +} + +// SetState changes the face state. +func (f *DispatchFace) SetState(s defn.State) { + f.state = s +} diff --git a/sim/ndndsim_sim.h b/sim/ndndsim_sim.h new file mode 100644 index 00000000..d1aa1754 --- /dev/null +++ b/sim/ndndsim_sim.h @@ -0,0 +1,63 @@ +/* + * Shared C declarations for the ndndSIM Go simulation bridge. + * Included by cgo_export.go via CGo preamble. + */ + +#ifndef NDNDSIM_SIM_H +#define NDNDSIM_SIM_H + +#include + +/* Callback function pointer types (set by C++ during init) */ +typedef void (*NdndSimSendPacketFunc)(uint32_t nodeId, uint32_t ifIndex, + const void* data, uint32_t dataLen); +typedef void (*NdndSimScheduleEventFunc)(uint32_t nodeId, int64_t delayNs, + uint64_t eventId); +typedef void (*NdndSimCancelEventFunc)(uint64_t eventId); +typedef int64_t (*NdndSimGetTimeNsFunc)(void); +typedef void (*NdndSimDataProducedFunc)(uint32_t nodeId, uint32_t dataSize); +typedef void (*NdndSimDataReceivedFunc)(uint32_t nodeId, uint32_t dataSize, + const char* dataName, uint32_t nameLen); + +/* Stored callback pointers */ +static NdndSimSendPacketFunc _sendPacketCb; +static NdndSimScheduleEventFunc _scheduleEventCb; +static NdndSimCancelEventFunc _cancelEventCb; +static NdndSimGetTimeNsFunc _getTimeNsCb; +static NdndSimDataProducedFunc _dataProducedCb; +static NdndSimDataReceivedFunc _dataReceivedCb; + +static inline void setSendPacketCb(NdndSimSendPacketFunc cb) { _sendPacketCb = cb; } +static inline void setScheduleEventCb(NdndSimScheduleEventFunc cb) { _scheduleEventCb = cb; } +static inline void setCancelEventCb(NdndSimCancelEventFunc cb) { _cancelEventCb = cb; } +static inline void setGetTimeNsCb(NdndSimGetTimeNsFunc cb) { _getTimeNsCb = cb; } +static inline void setDataProducedCb(NdndSimDataProducedFunc cb) { _dataProducedCb = cb; } +static inline void setDataReceivedCb(NdndSimDataReceivedFunc cb) { _dataReceivedCb = cb; } + +static inline void callSendPacket(uint32_t nodeId, uint32_t ifIndex, const void* data, uint32_t dataLen) { + if (_sendPacketCb) _sendPacketCb(nodeId, ifIndex, data, dataLen); +} + +static inline void callScheduleEvent(uint32_t nodeId, int64_t delayNs, uint64_t eventId) { + if (_scheduleEventCb) _scheduleEventCb(nodeId, delayNs, eventId); +} + +static inline void callCancelEvent(uint64_t eventId) { + if (_cancelEventCb) _cancelEventCb(eventId); +} + +static inline int64_t callGetTimeNs(void) { + if (_getTimeNsCb) return _getTimeNsCb(); + return 0; +} + +static inline void callDataProduced(uint32_t nodeId, uint32_t dataSize) { + if (_dataProducedCb) _dataProducedCb(nodeId, dataSize); +} + +static inline void callDataReceived(uint32_t nodeId, uint32_t dataSize, + const char* dataName, uint32_t nameLen) { + if (_dataReceivedCb) _dataReceivedCb(nodeId, dataSize, dataName, nameLen); +} + +#endif /* NDNDSIM_SIM_H */ diff --git a/sim/node.go b/sim/node.go new file mode 100644 index 00000000..1fa082e8 --- /dev/null +++ b/sim/node.go @@ -0,0 +1,289 @@ +package sim + +import ( + "encoding/json" + "fmt" + "sync" + + "github.com/named-data/ndnd/dv/config" + "github.com/named-data/ndnd/fw/defn" + enc "github.com/named-data/ndnd/std/encoding" + "github.com/named-data/ndnd/std/ndn" +) + +// Node represents a single simulated NDN node with isolated state. +// Each ns-3 node that runs NDNd gets one SimNode. +type Node struct { + id uint32 + + // Simulation clock (shared across all nodes, provided by ns-3) + clock Clock + + // Forwarder for this node + Forwarder *SimForwarder + + // Application-layer engine (for NDN apps on this node) + appEngine ndn.Engine + appFace *SimFace + appTimer *SimTimer + appFaceID uint64 + + // DV router for this node (nil if not enabled) + dvRouter *SimDvRouter + + // Mapping from ns-3 interface index to forwarder face ID + ifaceFaces map[uint32]uint64 + + mu sync.Mutex +} + +// NewNode creates a new simulation node. The clock is typically shared +// across all nodes (backed by ns-3 Simulator::Now). +func NewNode(id uint32, clock Clock) *Node { + n := &Node{ + id: id, + clock: clock, + ifaceFaces: make(map[uint32]uint64), + } + + // Create the forwarder + n.Forwarder = NewSimForwarder(clock) + + // Create the application-layer timer + n.appTimer = NewSimTimer(clock) + + // Create the app face — sendFunc forwards to the forwarder's app face. + // We use a closure that captures n so it can look up appFaceID at send time. + n.appFace = NewSimFace(func(frame []byte) { + n.Forwarder.ReceivePacket(n.appFaceID, frame) + }, true) + + n.appEngine = NewSimEngine(n.appFace, n.appTimer, id, nil) + + return n +} + +// Start initializes the node's forwarder and application engine. +func (n *Node) Start() error { + n.mu.Lock() + defer n.mu.Unlock() + + // Create internal application face in the forwarder + n.appFaceID = n.Forwarder.AddFace(defn.Local, defn.PointToPoint, func(faceID uint64, frame []byte) { + // Forwarder → App: deliver to the application face + n.appFace.Receive(frame) + }) + + // Set forwarder and appFaceID on the engine for ExecMgmtCmd + if eng, ok := n.appEngine.(*SimEngine); ok { + eng.forwarder = n.Forwarder + eng.appFaceID = n.appFaceID + } + + n.Forwarder.Start() + + // Start the engine (this also opens the app face) + if err := n.appEngine.Start(); err != nil { + return fmt.Errorf("failed to start engine: %w", err) + } + + return nil +} + +// Stop shuts down the node. +func (n *Node) Stop() { + n.mu.Lock() + defer n.mu.Unlock() + + if n.dvRouter != nil { + n.dvRouter.Stop() + n.dvRouter = nil + } + + n.appEngine.Stop() + n.appFace.Close() + n.Forwarder.Stop() +} + +// AddNetworkFace creates a new forwarder face for an ns-3 network interface. +// sendFunc is called when the forwarder wants to transmit a packet on this interface. +// Returns the face ID. +func (n *Node) AddNetworkFace(ifIndex uint32, sendFunc FwSendFunc) uint64 { + n.mu.Lock() + defer n.mu.Unlock() + + faceID := n.Forwarder.AddFace(defn.NonLocal, defn.PointToPoint, sendFunc) + n.ifaceFaces[ifIndex] = faceID + return faceID +} + +// RemoveNetworkFace removes a forwarder face for an ns-3 network interface. +func (n *Node) RemoveNetworkFace(ifIndex uint32) { + n.mu.Lock() + defer n.mu.Unlock() + + if faceID, ok := n.ifaceFaces[ifIndex]; ok { + n.Forwarder.RemoveFace(faceID) + delete(n.ifaceFaces, ifIndex) + } +} + +// ReceiveOnInterface injects a packet received on an ns-3 network interface. +// ifIndex == 0xFFFFFFFF is the special app-face interface. +func (n *Node) ReceiveOnInterface(ifIndex uint32, frame []byte) { + if ifIndex == 0xFFFFFFFF { + // App face: deliver directly to the forwarder on the app face + n.Forwarder.ReceivePacket(n.appFaceID, frame) + return + } + + n.mu.Lock() + faceID, ok := n.ifaceFaces[ifIndex] + n.mu.Unlock() + + if ok { + n.Forwarder.ReceivePacket(faceID, frame) + } +} + +// GetFaceForInterface returns the forwarder face ID for an ns-3 interface. +func (n *Node) GetFaceForInterface(ifIndex uint32) (uint64, bool) { + n.mu.Lock() + defer n.mu.Unlock() + faceID, ok := n.ifaceFaces[ifIndex] + return faceID, ok +} + +// AddRoute adds a FIB entry for this node. +func (n *Node) AddRoute(name enc.Name, faceID uint64, cost uint64) { + n.Forwarder.AddRoute(name, faceID, cost) +} + +// RemoveRoute removes a FIB entry for this node. +func (n *Node) RemoveRoute(name enc.Name, faceID uint64) { + n.Forwarder.RemoveRoute(name, faceID) +} + +// Engine returns the application-layer engine for this node. +func (n *Node) Engine() ndn.Engine { + return n.appEngine +} + +// Clock returns the simulation clock. +func (n *Node) Clock() Clock { + return n.clock +} + +// ID returns the node identifier. +func (n *Node) ID() uint32 { + return n.id +} + +// AppFaceID returns the face ID for the internal application face. +func (n *Node) AppFaceID() uint64 { + return n.appFaceID +} + +// StartDv creates and starts a DV router on this node. +// network is the network prefix (e.g., "/ndn"), routerName is the full +// router name (e.g., "/ndn/node0"). The DV router discovers neighbors +// dynamically via sync Interests on all connected faces. +func (n *Node) StartDv(network, router string, cfgJSON string) error { + n.mu.Lock() + defer n.mu.Unlock() + + cfg := config.DefaultConfig() + cfg.Network = network + cfg.Router = router + if cfgJSON != "" { + if err := json.Unmarshal([]byte(cfgJSON), cfg); err != nil { + return fmt.Errorf("bad DV config JSON: %w", err) + } + // Restore fields that must not be overridden from the outside + cfg.Network = network + cfg.Router = router + } + + // Set up Ed25519 trust (same pipeline as emulation) + trust, err := GetSimTrust(network) + if err != nil { + return fmt.Errorf("failed to init trust: %w", err) + } + kc, store, anchors, err := trust.NodeKeychain(router) + if err != nil { + return fmt.Errorf("failed to build node keychain: %w", err) + } + cfg.KeyChain = kc + cfg.Store = store + cfg.TrustAnchors = anchors + + sdv, err := NewSimDvRouter(n.clock, n.appEngine, cfg) + if err != nil { + return err + } + + if err := sdv.Start(); err != nil { + return err + } + + // Register DV sync prefixes on all link faces for neighbor reachability. + // This replaces the production DV's createFaces() which creates and + // configures neighbor faces — in simulation, faces already exist. + syncActivePrefix := cfg.AdvertisementSyncActivePrefix() + pfxSyncGroup := cfg.PrefixTableGroupPrefix() + for _, faceID := range n.ifaceFaces { + n.Forwarder.AddRoute(syncActivePrefix, faceID, 1) + n.Forwarder.AddRoute(pfxSyncGroup, faceID, 1) + } + + // Also add app face at the ACT prefix so incoming sync Interests from + // neighbors can be delivered to the DV handler. Without this, the ACT + // node in the FIB (link faces only) shadows the parent ADS node (app + // face), causing /localhop scope enforcement to drop all nexthops. + n.Forwarder.AddRoute(syncActivePrefix, n.appFaceID, 0) + + // Set multicast strategy for sync prefixes so Interests go to ALL neighbors. + multicastStrategy := config.MulticastStrategy.Append(enc.NewVersionComponent(1)) + n.Forwarder.SetStrategy(cfg.AdvertisementSyncPrefix(), multicastStrategy) + n.Forwarder.SetStrategy(pfxSyncGroup, multicastStrategy) + + n.dvRouter = sdv + return nil +} + +// StopDv stops the DV router if running. +func (n *Node) StopDv() { + n.mu.Lock() + defer n.mu.Unlock() + if n.dvRouter != nil { + n.dvRouter.Stop() + n.dvRouter = nil + } +} + +// DvRouter returns the DV router wrapper, or nil if not started. +func (n *Node) DvRouter() *SimDvRouter { + return n.dvRouter +} + +// AnnouncePrefixToDv announces a prefix to the DV router if DV is running. +// This triggers DV prefix table propagation to all neighbors. +func (n *Node) AnnouncePrefixToDv(name enc.Name, cost uint64) { + n.mu.Lock() + dv := n.dvRouter + n.mu.Unlock() + if dv != nil { + dv.AnnouncePrefix(name, n.appFaceID, cost) + } +} + +// WithdrawPrefixFromDv withdraws a prefix from the DV router if DV is running. +// This triggers DV prefix table removal propagation to all neighbors. +func (n *Node) WithdrawPrefixFromDv(name enc.Name) { + n.mu.Lock() + dv := n.dvRouter + n.mu.Unlock() + if dv != nil { + dv.WithdrawPrefix(name, n.appFaceID) + } +} diff --git a/sim/runtime.go b/sim/runtime.go new file mode 100644 index 00000000..c92f78c0 --- /dev/null +++ b/sim/runtime.go @@ -0,0 +1,84 @@ +package sim + +import ( + "fmt" + "sync" +) + +// Runtime manages all simulation nodes and provides the global simulation +// state. There is exactly one Runtime per simulation run. +type Runtime struct { + mu sync.Mutex + clock Clock + nodes map[uint32]*Node +} + +// NewRuntime creates a new simulation runtime with the given clock. +func NewRuntime(clock Clock) *Runtime { + return &Runtime{ + clock: clock, + nodes: make(map[uint32]*Node), + } +} + +// CreateNode creates a new simulation node with the given ID. +func (r *Runtime) CreateNode(id uint32) (*Node, error) { + r.mu.Lock() + defer r.mu.Unlock() + + if _, exists := r.nodes[id]; exists { + return nil, fmt.Errorf("node %d already exists", id) + } + + node := NewNode(id, r.clock) + r.nodes[id] = node + return node, nil +} + +// GetNode returns the node with the given ID. +func (r *Runtime) GetNode(id uint32) *Node { + r.mu.Lock() + defer r.mu.Unlock() + return r.nodes[id] +} + +// DestroyNode stops and removes the node with the given ID. +func (r *Runtime) DestroyNode(id uint32) { + r.mu.Lock() + node, ok := r.nodes[id] + if ok { + delete(r.nodes, id) + } + r.mu.Unlock() + + if ok { + node.Stop() + } +} + +// DestroyAll stops and removes all nodes. +func (r *Runtime) DestroyAll() { + r.mu.Lock() + nodes := make(map[uint32]*Node, len(r.nodes)) + for k, v := range r.nodes { + nodes[k] = v + } + r.nodes = make(map[uint32]*Node) + r.mu.Unlock() + + for _, node := range nodes { + node.Stop() + } +} + +// NodeCount returns the number of active nodes. +func (r *Runtime) NodeCount() int { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.nodes) +} + +// Clock returns the simulation clock. +func (r *Runtime) Clock() Clock { + return r.clock +} diff --git a/sim/timer.go b/sim/timer.go new file mode 100644 index 00000000..f3736ceb --- /dev/null +++ b/sim/timer.go @@ -0,0 +1,51 @@ +package sim + +import ( + "crypto/rand" + "fmt" + "time" + + "github.com/named-data/ndnd/std/ndn" +) + +// SimTimer implements ndn.Timer using a simulation Clock. +// It satisfies the std/ layer's timer contract without wall-clock access. +type SimTimer struct { + clock Clock +} + +var _ ndn.Timer = (*SimTimer)(nil) + +// NewSimTimer creates a SimTimer backed by the given simulation clock. +func NewSimTimer(clock Clock) *SimTimer { + return &SimTimer{clock: clock} +} + +func (t *SimTimer) Now() time.Time { + return t.clock.Now() +} + +func (t *SimTimer) Sleep(d time.Duration) { + // In a discrete-event simulator, blocking sleep is not meaningful. + // This is a no-op; callers that need to wait should use Schedule. + // The simulation will advance time externally. +} + +func (t *SimTimer) Schedule(d time.Duration, f func()) func() error { + id := t.clock.Schedule(d, f) + cancelled := false + return func() error { + if cancelled { + return fmt.Errorf("event has already been canceled") + } + cancelled = true + t.clock.Cancel(id) + return nil + } +} + +func (t *SimTimer) Nonce() []byte { + buf := make([]byte, 8) + n, _ := rand.Read(buf) + return buf[:n] +} diff --git a/sim/trust.go b/sim/trust.go new file mode 100644 index 00000000..574c1b16 --- /dev/null +++ b/sim/trust.go @@ -0,0 +1,141 @@ +package sim + +import ( + "fmt" + "sync" + "time" + + enc "github.com/named-data/ndnd/std/encoding" + "github.com/named-data/ndnd/std/ndn" + spec "github.com/named-data/ndnd/std/ndn/spec_2022" + "github.com/named-data/ndnd/std/object/storage" + sec "github.com/named-data/ndnd/std/security" + "github.com/named-data/ndnd/std/security/keychain" + sig "github.com/named-data/ndnd/std/security/signer" +) + +// SimTrust holds the shared trust root for all simulated nodes. +// One root key is generated per network prefix per simulation run. +type SimTrust struct { + network enc.Name + rootSigner ndn.Signer + rootCert []byte // wire-encoded certificate + rootName string // full certificate name as string (for TrustAnchors) +} + +var ( + globalTrust *SimTrust + globalTrustOnce sync.Once + globalTrustErr error +) + +// GetSimTrust returns the global trust root, creating it on first call. +func GetSimTrust(network string) (*SimTrust, error) { + globalTrustOnce.Do(func() { + globalTrust, globalTrustErr = newSimTrust(network) + }) + return globalTrust, globalTrustErr +} + +// ResetSimTrust clears the global trust root (for testing). +func ResetSimTrust() { + globalTrust = nil + globalTrustErr = nil + globalTrustOnce = sync.Once{} +} + +func newSimTrust(network string) (*SimTrust, error) { + networkName, err := enc.NameFromStr(network) + if err != nil { + return nil, fmt.Errorf("invalid network name: %w", err) + } + + // Generate root Ed25519 key: /network/KEY/ + rootKeyName := sec.MakeKeyName(networkName) + rootSigner, err := sig.KeygenEd25519(rootKeyName) + if err != nil { + return nil, fmt.Errorf("failed to generate root key: %w", err) + } + + // Self-sign the root certificate + now := time.Now() + rootCertWire, err := sec.SelfSign(sec.SignCertArgs{ + Signer: rootSigner, + IssuerId: enc.NewGenericComponent("self"), + NotBefore: now.Add(-time.Hour), + NotAfter: now.Add(10 * 365 * 24 * time.Hour), + }) + if err != nil { + return nil, fmt.Errorf("failed to self-sign root cert: %w", err) + } + + // Parse the cert to get its name + rootCertBytes := rootCertWire.Join() + rootCertData, _, err := spec.Spec{}.ReadData(enc.NewWireView(rootCertWire)) + if err != nil { + return nil, fmt.Errorf("failed to parse root cert: %w", err) + } + + return &SimTrust{ + network: networkName, + rootSigner: rootSigner, + rootCert: rootCertBytes, + rootName: rootCertData.Name().String(), + }, nil +} + +// NodeKeychain builds a per-node keychain with an Ed25519 key signed by the root. +// Returns (keychain, store, trustAnchorNames) ready for config injection. +func (st *SimTrust) NodeKeychain(router string) (ndn.KeyChain, ndn.Store, []string, error) { + routerName, err := enc.NameFromStr(router) + if err != nil { + return nil, nil, nil, fmt.Errorf("invalid router name: %w", err) + } + + // Node identity: /network/node/32=DV (matches trust schema: #router/"32=DV"/#KEY) + nodeIdentity := routerName.Append(enc.NewKeywordComponent("DV")) + nodeKeyName := sec.MakeKeyName(nodeIdentity) + + nodeSigner, err := sig.KeygenEd25519(nodeKeyName) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to generate node key: %w", err) + } + + // Get the node's public key as a Data packet (for SignCert) + nodeKeyData, err := sig.MarshalSecretToData(nodeSigner) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to marshal node key: %w", err) + } + + // Sign node cert with root key + now := time.Now() + nodeCertWire, err := sec.SignCert(sec.SignCertArgs{ + Signer: st.rootSigner, + Data: nodeKeyData, + IssuerId: enc.NewGenericComponent("NA"), + NotBefore: now.Add(-time.Hour), + NotAfter: now.Add(10 * 365 * 24 * time.Hour), + }) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to sign node cert: %w", err) + } + + // Build keychain + store := storage.NewMemoryStore() + kc := keychain.NewKeyChainMem(store) + + // Insert root cert + if err := kc.InsertCert(st.rootCert); err != nil { + return nil, nil, nil, fmt.Errorf("failed to insert root cert: %w", err) + } + + // Insert node key + cert + if err := kc.InsertKey(nodeSigner); err != nil { + return nil, nil, nil, fmt.Errorf("failed to insert node key: %w", err) + } + if err := kc.InsertCert(nodeCertWire.Join()); err != nil { + return nil, nil, nil, fmt.Errorf("failed to insert node cert: %w", err) + } + + return kc, store, []string{st.rootName}, nil +} diff --git a/sim/util.go b/sim/util.go new file mode 100644 index 00000000..86cc84b0 --- /dev/null +++ b/sim/util.go @@ -0,0 +1,10 @@ +package sim + +import ( + enc "github.com/named-data/ndnd/std/encoding" +) + +// parseNameFromString parses an NDN name from its URI string representation. +func parseNameFromString(s string) (enc.Name, error) { + return enc.NameFromStr(s) +} diff --git a/std/engine/basic/engine_test.go b/std/engine/basic/engine_test.go index 169bc10f..076d8766 100644 --- a/std/engine/basic/engine_test.go +++ b/std/engine/basic/engine_test.go @@ -1,6 +1,7 @@ package basic_test import ( + "sync/atomic" "testing" "time" @@ -265,11 +266,11 @@ func TestImplicitSha256(t *testing.T) { // (AI GENERATED DESCRIPTION): Tests that an Interest matching a registered prefix (`/not`) is routed to its handler, which verifies the Interest and replies with a correctly‑encoded Data packet containing the string “test”. func TestRoute(t *testing.T) { executeTest(t, func(face *face.DummyFace, engine *basic_engine.Engine, timer *basic_engine.DummyTimer) { - hitCnt := 0 + var hitCnt atomic.Int32 spec := engine.Spec() handler := func(args ndn.InterestHandlerArgs) { - hitCnt += 1 + hitCnt.Add(1) require.Equal(t, []byte( "\x05\x15\x07\x10\x08\x03not\x08\timportant\x0c\x01\x05", ), args.RawInterest.Join()) @@ -288,7 +289,7 @@ func TestRoute(t *testing.T) { prefix := tu.NoErr(enc.NameFromStr("/not")) engine.AttachHandler(prefix, handler) face.FeedPacket([]byte("\x05\x15\x07\x10\x08\x03not\x08\timportant\x0c\x01\x05")) - require.Equal(t, 1, hitCnt) + require.Equal(t, int32(1), hitCnt.Load()) buf := tu.NoErr(face.Consume()) require.Equal(t, enc.Buffer( "\x06\x22\x07\x10\x08\x03not\x08\timportant\x14\x03\x18\x01\x00\x15\x04test"+ diff --git a/std/engine/face/dummy_face.go b/std/engine/face/dummy_face.go index 1a990e34..10de8d7a 100644 --- a/std/engine/face/dummy_face.go +++ b/std/engine/face/dummy_face.go @@ -2,6 +2,7 @@ package face import ( "fmt" + "sync" "time" enc "github.com/named-data/ndnd/std/encoding" @@ -9,6 +10,7 @@ import ( type DummyFace struct { baseFace + mu sync.Mutex sendPkts []enc.Buffer } @@ -50,8 +52,12 @@ func (f *DummyFace) Send(pkt enc.Wire) error { if !f.running.Load() { return fmt.Errorf("face is not running") } + f.mu.Lock() + defer f.mu.Unlock() + if len(pkt) == 1 { - f.sendPkts = append(f.sendPkts, pkt[0]) + buf := append(enc.Buffer(nil), pkt[0]...) + f.sendPkts = append(f.sendPkts, buf) } else if len(pkt) >= 2 { newBuf := make(enc.Buffer, 0) for _, buf := range pkt { @@ -83,10 +89,13 @@ func (f *DummyFace) Consume() (enc.Buffer, error) { // hack: yield to wait for packet to arrive time.Sleep(10 * time.Millisecond) + f.mu.Lock() + defer f.mu.Unlock() + if len(f.sendPkts) == 0 { return nil, fmt.Errorf("no packet to consume") } - pkt := f.sendPkts[0] + pkt := append(enc.Buffer(nil), f.sendPkts[0]...) f.sendPkts = f.sendPkts[1:] return pkt, nil } diff --git a/std/sync/svs.go b/std/sync/svs.go index 53cf7402..b98823bf 100644 --- a/std/sync/svs.go +++ b/std/sync/svs.go @@ -21,13 +21,12 @@ type SvSync struct { o SvSyncOpts running atomic.Bool - stop chan struct{} - ticker *time.Ticker - mutex sync.Mutex - state SvMap[uint64] - mtime map[string]time.Time - prefix enc.Name + mutex sync.Mutex + state SvMap[uint64] + mtime map[string]time.Time + prefix enc.Name + timerCancel func() // Suppression state suppress bool @@ -37,9 +36,6 @@ type SvSync struct { passiveWiresSv SvMap[enc.Wire] passiveWillPersist atomic.Bool - // Channel for incoming state vectors - recvSv chan svSyncRecvSvArgs - // cancellation for face hook faceCancel func() } @@ -67,6 +63,14 @@ type SvSyncOpts struct { // Suppression period for ignoring outdated Sync Interests (default 200ms) SuppressionPeriod time.Duration + // GoFunc dispatches work asynchronously. Defaults to go f(). + GoFunc func(func()) + // NowFunc returns current time. Defaults to time.Now. + NowFunc func() time.Time + // AfterFunc schedules f to run after d. Returns a cancel function. + // Defaults to time.AfterFunc. + AfterFunc func(time.Duration, func()) func() + // Passive mode does not send sign Sync Interests Passive bool // IgnoreValidity ignores validity period in the validation chain @@ -110,8 +114,20 @@ func NewSvSync(opts SvSyncOpts) *SvSync { } // Set default options + if opts.GoFunc == nil { + opts.GoFunc = func(f func()) { go f() } + } + if opts.NowFunc == nil { + opts.NowFunc = time.Now + } + if opts.AfterFunc == nil { + opts.AfterFunc = func(d time.Duration, f func()) func() { + t := time.AfterFunc(d, f) + return func() { t.Stop() } + } + } if opts.BootTime == 0 { - opts.BootTime = uint64(time.Now().Unix()) + opts.BootTime = uint64(opts.NowFunc().Unix()) } if opts.PeriodicTimeout == 0 { opts.PeriodicTimeout = 30 * time.Second @@ -127,8 +143,6 @@ func NewSvSync(opts SvSyncOpts) *SvSync { o: opts, running: atomic.Bool{}, - stop: make(chan struct{}), - ticker: time.NewTicker(opts.PeriodicTimeout), mutex: sync.Mutex{}, state: initialState, @@ -141,8 +155,6 @@ func NewSvSync(opts SvSyncOpts) *SvSync { passiveWiresSv: NewSvMap[enc.Wire](0), passiveWillPersist: atomic.Bool{}, - recvSv: make(chan svSyncRecvSvArgs, 128), - faceCancel: func() {}, } } @@ -162,55 +174,53 @@ func (s *SvSync) Start() (err error) { return err } - go s.main() - - return nil -} - -// (AI GENERATED DESCRIPTION): Runs the SvSync event loop: it performs the initial sync (or passive load), registers periodic timer ticks and face‑up callbacks, processes received state vectors, and exits cleanly when signalled to stop. -func (s *SvSync) main() { - // Cleanup on exit - defer s.o.Client.Engine().DetachHandler(s.prefix) - - // Set running state s.running.Store(true) - defer s.running.Store(false) - // Notify everyone when we are back online s.faceCancel = s.o.Client.Engine().Face().OnUp(func() { - time.AfterFunc(100*time.Millisecond, s.sendSyncInterest) + s.o.AfterFunc(100*time.Millisecond, s.sendSyncInterest) }) - defer s.faceCancel() if s.o.Passive { - // [Passive] Load the buffered wires from persistence - // This will send the initial Sync Interest - go s.loadPassiveWires() + s.o.GoFunc(s.loadPassiveWires) } else { - // Send the initial Sync Interest - go s.sendSyncInterest() + s.o.GoFunc(s.sendSyncInterest) } - for { - select { - case <-s.ticker.C: - s.timerExpired() - case sv := <-s.recvSv: - s.onReceiveStateVector(sv) - case <-s.stop: - return - } - } + s.mutex.Lock() + s.scheduleTimer(s.getPeriodicTimeout()) + s.mutex.Unlock() + + return nil } // Stop the SV Sync instance. func (s *SvSync) Stop() error { - s.ticker.Stop() + s.running.Store(false) + s.mutex.Lock() + if s.timerCancel != nil { + s.timerCancel() + s.timerCancel = nil + } + s.mutex.Unlock() + s.faceCancel() + s.o.Client.Engine().DetachHandler(s.prefix) s.persistPassiveWires() - s.stop <- struct{}{} return nil } +// scheduleTimer schedules the periodic/suppression timer callback. +// Must be called while holding s.mutex. +func (s *SvSync) scheduleTimer(d time.Duration) { + if s.timerCancel != nil { + s.timerCancel() + } + s.timerCancel = s.o.AfterFunc(d, func() { + if s.running.Load() { + s.timerExpired() + } + }) +} + // GetSeqNo returns the sequence number for a name. func (s *SvSync) GetSeqNo(name enc.Name) uint64 { s.mutex.Lock() @@ -240,7 +250,7 @@ func (s *SvSync) SetSeqNo(name enc.Name, seqNo uint64) error { // [Spec] When the node generates a new publication, // immediately emit a Sync Interest s.state.Set(hash, s.o.BootTime, seqNo) - go s.sendSyncInterest() + s.o.GoFunc(s.sendSyncInterest) return nil } @@ -262,7 +272,7 @@ func (s *SvSync) IncrSeqNo(name enc.Name) uint64 { // [Spec] When the node generates a new publication, // immediately emit a Sync Interest - go s.sendSyncInterest() + s.o.GoFunc(s.sendSyncInterest) return entry } @@ -304,7 +314,7 @@ func (s *SvSync) onReceiveStateVector(args svSyncRecvSvArgs) { isOutdated := false canDrop := true recvSv := NewSvMap[uint64](len(args.sv.Entries)) - now := time.Now() + now := s.o.NowFunc() for _, node := range args.sv.Entries { hash := node.Name.TlvStr() @@ -377,7 +387,7 @@ func (s *SvSync) onReceiveStateVector(args svSyncRecvSvArgs) { // [Passive] Persist the buffered wires with throttling if s.o.Passive && !s.passiveWillPersist.Swap(true) { - time.AfterFunc(5*time.Second, s.persistPassiveWires) + s.o.AfterFunc(5*time.Second, s.persistPassiveWires) } if !isOutdated { @@ -397,7 +407,7 @@ func (s *SvSync) onReceiveStateVector(args svSyncRecvSvArgs) { // [Spec] When entering Suppression State, reset // the Sync Interest timer to SuppressionTimeout - s.ticker.Reset(s.getSuppressionTimeout()) + s.scheduleTimer(s.getSuppressionTimeout()) } // (AI GENERATED DESCRIPTION): Handles a timer expiry by checking suppression state, potentially transitioning to steady state, and asynchronously sending a Sync Interest with the current local state vector. @@ -418,7 +428,7 @@ func (s *SvSync) timerExpired() { // [Spec] On expiration of timer emit a Sync Interest // with the current local state vector. - go s.sendSyncInterest() + s.o.GoFunc(s.sendSyncInterest) } // (AI GENERATED DESCRIPTION): Sends a sync Interest: if passive mode is enabled, it publishes all buffered state updates without duplicates; otherwise, it encodes the current state vector into a wire and transmits it, provided the sync service is running. @@ -541,10 +551,13 @@ func (s *SvSync) onSyncData(dataWire enc.Wire) { return } - s.recvSv <- svSyncRecvSvArgs{ + args := svSyncRecvSvArgs{ sv: params.StateVector, data: dataWire, } + s.o.GoFunc(func() { + s.onReceiveStateVector(args) + }) }, }) } @@ -553,7 +566,7 @@ func (s *SvSync) onSyncData(dataWire enc.Wire) { func (s *SvSync) enterSteadyState() { s.suppress = false // [Spec] Steady state: Reset Sync Interest timer to PeriodicTimeout - s.ticker.Reset(s.getPeriodicTimeout()) + s.scheduleTimer(s.getPeriodicTimeout()) } // (AI GENERATED DESCRIPTION): Returns a duration uniformly randomized within ±10% of the configured periodic timeout. @@ -661,5 +674,5 @@ func (s *SvSync) loadPassiveWires() { } // This is hacky but pragmatic - wait for the state to be processed - time.AfterFunc(500*time.Millisecond, s.sendSyncInterest) + s.o.AfterFunc(500*time.Millisecond, s.sendSyncInterest) } diff --git a/std/sync/svs_alo.go b/std/sync/svs_alo.go index 384a14eb..f136ae3c 100644 --- a/std/sync/svs_alo.go +++ b/std/sync/svs_alo.go @@ -28,15 +28,6 @@ type SvsALO struct { // nodePs is the Pub/Sub coordinator for publisher prefixes nodePs SimplePs[SvsPub] - // outpipe is the channel for delivering data. - outpipe chan SvsPub - // errpipe is the channel for delivering errors. - errpipe chan error - // publpipe is the channel for delivering new publishers. - publpipe chan enc.Name - // stop is the stop signal. - stop chan struct{} - // error callback onError func(error) // publisher callback @@ -78,11 +69,6 @@ func NewSvsALO(opts SvsAloOpts) (*SvsALO, error) { state: NewSvMap[svsDataState](0), nodePs: NewSimplePs[SvsPub](), - outpipe: make(chan SvsPub, 256), - errpipe: make(chan error, 16), - publpipe: make(chan enc.Name, 16), - stop: make(chan struct{}), - onError: func(err error) { log.Warn(nil, err.Error()) }, onPublisher: nil, } @@ -104,7 +90,11 @@ func NewSvsALO(opts SvsAloOpts) (*SvsALO, error) { if s.opts.Svs.BootTime == 0 { // This is actually done by the SVS instance itself, but we need // it to tell the SyncDataName to SVS ... - s.opts.Svs.BootTime = uint64(time.Now().Unix()) + nowFunc := s.opts.Svs.NowFunc + if nowFunc == nil { + nowFunc = time.Now + } + s.opts.Svs.BootTime = uint64(nowFunc().Unix()) } // Svs.GroupPrefix is actually the Sync prefix @@ -193,15 +183,11 @@ func (s *SvsALO) Start() error { if err := s.svs.Start(); err != nil { return err } - - // SVS is stopped when the main loop quits. - go s.run() return nil } // Stop stops the SvsALO instance. func (s *SvsALO) Stop() error { - s.stop <- struct{}{} s.svs.Stop() return nil } @@ -266,32 +252,14 @@ func (s *SvsALO) UnsubscribePublisher(prefix enc.Name) { s.nodePs.Unsubscribe(prefix) } -// run is the main loop for the SvsALO instance. -// Only this thread has interaction with the application. -func (s *SvsALO) run() { - for { - select { - case <-s.stop: - return - case pub := <-s.outpipe: - for _, subscription := range pub.subcribers { - subscription(pub) - } - case err := <-s.errpipe: - s.onError(err) - case publ := <-s.publpipe: - s.onPublisher(publ) - } - } -} - // onSvsUpdate is the handler for new sequence numbers. func (s *SvsALO) onSvsUpdate(update SvSyncUpdate) { - defer func() { - if s.onPublisher != nil { - s.publpipe <- update.Name - } - }() + if s.onPublisher != nil { + name := update.Name + s.opts.Svs.GoFunc(func() { + s.onPublisher(name) + }) + } s.mutex.Lock() defer s.mutex.Unlock() diff --git a/std/sync/svs_alo_data.go b/std/sync/svs_alo_data.go index d22035d0..d6d20bc5 100644 --- a/std/sync/svs_alo_data.go +++ b/std/sync/svs_alo_data.go @@ -156,7 +156,7 @@ func (s *SvsALO) consumeObject(node enc.Name, boot uint64, seq uint64) { }) // TODO: exponential backoff - time.AfterFunc(2*time.Second, func() { + s.opts.Svs.AfterFunc(2*time.Second, func() { s.consumeObject(node, boot, seq) }) return @@ -238,13 +238,16 @@ func (s *SvsALO) queuePub(pub SvsPub) { pub.State = s.instanceState() } - s.outpipe <- pub + s.opts.Svs.GoFunc(func() { + for _, subscription := range pub.subcribers { + subscription(pub) + } + }) } // queueError queues an error to the application. func (s *SvsALO) queueError(err error) { - select { - case s.errpipe <- err: - default: - } + s.opts.Svs.GoFunc(func() { + s.onError(err) + }) } diff --git a/std/types/arc/arc_pool_test.go b/std/types/arc/arc_pool_test.go index 65b2e202..017de38b 100644 --- a/std/types/arc/arc_pool_test.go +++ b/std/types/arc/arc_pool_test.go @@ -30,6 +30,10 @@ func TestArcPool(t *testing.T) { require.Equal(t, int32(0), arc.Dec()) // release arc3 := pool.Get() require.Equal(t, 42, *arc3.Load()) - require.Equal(t, 42, *ref) // reused (not deteministic though) - require.True(t, ref == arc3.Load()) + + // sync.Pool reuse is intentionally non-deterministic; assert reset semantics, + // not pointer identity. + if ref == arc3.Load() { + require.Equal(t, 42, *ref) + } } diff --git a/tools/traffic.go b/tools/traffic.go new file mode 100644 index 00000000..d1f9af33 --- /dev/null +++ b/tools/traffic.go @@ -0,0 +1,203 @@ +package tools + +import ( + "fmt" + "os" + "os/signal" + "strconv" + "syscall" + "time" + + enc "github.com/named-data/ndnd/std/encoding" + "github.com/named-data/ndnd/std/engine" + "github.com/named-data/ndnd/std/log" + "github.com/named-data/ndnd/std/ndn" + "github.com/named-data/ndnd/std/object" + "github.com/named-data/ndnd/std/object/storage" + "github.com/named-data/ndnd/std/security/signer" + "github.com/named-data/ndnd/std/types/optional" + "github.com/named-data/ndnd/std/utils" + "github.com/spf13/cobra" +) + +// CmdTraffic returns the parent "traffic" command with producer/consumer subcommands. +func CmdTraffic() *cobra.Command { + cmd := &cobra.Command{ + Use: "traffic", + Short: "NDN traffic generator (producer and consumer)", + } + cmd.AddCommand(cmdTrafficProducer()) + cmd.AddCommand(cmdTrafficConsumer()) + return cmd +} + +// --- Producer --- + +type trafficProducer struct { + prefix enc.Name + payload int + freshness int + expose bool + app ndn.Engine + sgn ndn.Signer +} + +func cmdTrafficProducer() *cobra.Command { + tp := &trafficProducer{} + cmd := &cobra.Command{ + Use: "producer PREFIX", + Short: "Start an NDN traffic producer", + Args: cobra.ExactArgs(1), + Run: tp.run, + } + cmd.Flags().IntVar(&tp.payload, "payload", 1024, "content payload size in bytes") + cmd.Flags().IntVar(&tp.freshness, "freshness", 2000, "FreshnessPeriod in milliseconds") + cmd.Flags().BoolVar(&tp.expose, "expose", true, "advertise prefix with client origin (for DV)") + return cmd +} + +func (tp *trafficProducer) run(_ *cobra.Command, args []string) { + prefix, err := enc.NameFromStr(args[0]) + if err != nil { + log.Fatal(tp, "Invalid prefix", "name", args[0]) + return + } + tp.prefix = prefix + tp.sgn = signer.NewSha256Signer() + + tp.app = engine.NewBasicEngine(engine.NewDefaultFace()) + if err := tp.app.Start(); err != nil { + log.Fatal(tp, "Unable to start engine", "err", err) + return + } + defer tp.app.Stop() + + if err := tp.app.AttachHandler(prefix, tp.onInterest); err != nil { + log.Fatal(tp, "Unable to register handler", "err", err) + return + } + + cli := object.NewClient(tp.app, storage.NewMemoryStore(), nil) + if err := cli.Start(); err != nil { + log.Fatal(tp, "Unable to start object client", "err", err) + return + } + defer cli.Stop() + + cli.AnnouncePrefix(ndn.Announcement{ + Name: prefix, + Expose: tp.expose, + }) + defer cli.WithdrawPrefix(prefix, nil) + + fmt.Printf("traffic producer started: prefix=%s payload=%d freshness=%dms\n", + prefix, tp.payload, tp.freshness) + + sigchan := make(chan os.Signal, 1) + signal.Notify(sigchan, os.Interrupt, syscall.SIGTERM) + <-sigchan +} + +func (tp *trafficProducer) onInterest(args ndn.InterestHandlerArgs) { + content := make([]byte, tp.payload) + freshness := time.Duration(tp.freshness) * time.Millisecond + data, err := tp.app.Spec().MakeData( + args.Interest.Name(), + &ndn.DataConfig{ + ContentType: optional.Some(ndn.ContentTypeBlob), + Freshness: optional.Some(freshness), + }, + enc.Wire{content}, + tp.sgn, + ) + if err != nil { + return + } + args.Reply(data.Wire) +} + +func (tp *trafficProducer) String() string { return "traffic-producer" } + +// --- Consumer --- + +type trafficConsumer struct { + app ndn.Engine + prefix enc.Name + interval int // milliseconds between Interests + lifetime int // Interest lifetime in milliseconds + seqNo uint64 +} + +func cmdTrafficConsumer() *cobra.Command { + tc := &trafficConsumer{} + cmd := &cobra.Command{ + Use: "consumer PREFIX", + Short: "Start an NDN traffic consumer", + Args: cobra.ExactArgs(1), + Run: tc.run, + } + cmd.Flags().IntVar(&tc.interval, "interval", 100, "inter-Interest interval in milliseconds (100 = 10 Hz)") + cmd.Flags().IntVar(&tc.lifetime, "lifetime", 4000, "Interest lifetime in milliseconds") + return cmd +} + +func (tc *trafficConsumer) run(_ *cobra.Command, args []string) { + prefix, err := enc.NameFromStr(args[0]) + if err != nil { + log.Fatal(tc, "Invalid prefix", "name", args[0]) + return + } + tc.prefix = prefix + tc.seqNo = 0 + + tc.app = engine.NewBasicEngine(engine.NewDefaultFace()) + if err := tc.app.Start(); err != nil { + log.Fatal(tc, "Unable to start engine", "err", err) + return + } + defer tc.app.Stop() + + sigchan := make(chan os.Signal, 1) + signal.Notify(sigchan, os.Interrupt, syscall.SIGTERM) + + // Send first Interest immediately, then at each interval tick. + tc.send() + ticker := time.NewTicker(time.Duration(tc.interval) * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + tc.seqNo++ + tc.send() + case <-sigchan: + return + } + } +} + +func (tc *trafficConsumer) send() { + seq := tc.seqNo + name := tc.prefix.Append(enc.NewGenericComponent(strconv.FormatUint(seq, 10))) + + cfg := &ndn.InterestConfig{ + Lifetime: optional.Some(time.Duration(tc.lifetime) * time.Millisecond), + Nonce: utils.ConvertNonce(tc.app.Timer().Nonce()), + } + interest, err := tc.app.Spec().MakeInterest(name, cfg, nil, nil) + if err != nil { + return + } + tc.app.Express(interest, func(args ndn.ExpressCallbackArgs) { + switch args.Result { + case ndn.InterestResultData: + fmt.Printf("received: %s\n", args.Data.Name()) + case ndn.InterestResultTimeout: + fmt.Fprintf(os.Stderr, "timeout: %s\n", name) + case ndn.InterestResultNack: + fmt.Fprintf(os.Stderr, "nack: %s reason=%d\n", name, args.NackReason) + } + }) +} + +func (tc *trafficConsumer) String() string { return "traffic-consumer" }