From afec8adce82e1c27a3cf496006d68618d580b1f8 Mon Sep 17 00:00:00 2001 From: Gyorgy Szaszko Date: Wed, 12 Aug 2026 17:48:38 +0200 Subject: [PATCH 01/14] queueing: unindent the branch-creation path in DynamicClassifier::classifyPacket Invert the class lookup into an early return, so that the block creating the branch of a first-seen class sits at function level instead of inside the conditional. Whitespace-only except for the inverted condition and the hoisted return -- review with a whitespace-ignoring diff. No change in behavior. This puts the block in position for the next commit to move it out verbatim. --- .../queueing/classifier/DynamicClassifier.cc | 51 +++++++++---------- .../queueing/classifier/DynamicClassifier.h | 1 - 2 files changed, 24 insertions(+), 28 deletions(-) diff --git a/src/inet/queueing/classifier/DynamicClassifier.cc b/src/inet/queueing/classifier/DynamicClassifier.cc index 43d6fe71c02..78456cf9347 100644 --- a/src/inet/queueing/classifier/DynamicClassifier.cc +++ b/src/inet/queueing/classifier/DynamicClassifier.cc @@ -30,35 +30,32 @@ int DynamicClassifier::classifyPacket(Packet *packet) { int index = PacketClassifier::classifyPacket(packet); auto it = classIndexToGateItMap.find(index); - if (it == classIndexToGateItMap.end()) { - auto parentModule = getParentModule(); - int submoduleIndex = gateSize("out"); - int origVectorSize = parentModule->getSubmoduleVectorSize(submoduleName); - parentModule->setSubmoduleVectorSize(submoduleName, std::max(origVectorSize, submoduleIndex + 1)); - auto module = moduleType->create(submoduleName, parentModule, submoduleIndex); - auto moduleInputGate = module->gate("in"); - auto moduleOutputGate = module->gate("out"); - auto multiplexer = parentModule->getSubmodule("multiplexer"); - multiplexer->setGateSize("in", multiplexer->gateSize("in") + 1); - auto multiplexerInputGate = multiplexer->gate("in", multiplexer->gateSize("in") - 1); - setGateSize("out", submoduleIndex + 1); - auto classifierOutputGate = gate("out", gateSize("out") - 1); - classifierOutputGate->connectTo(moduleInputGate); - outputGates.push_back(classifierOutputGate); - PassivePacketSinkRef consumer; - consumer.reference(classifierOutputGate, false); - consumers.push_back(consumer); - moduleOutputGate->connectTo(multiplexerInputGate); - module->finalizeParameters(); - module->buildInside(); - module->callInitialize(); - classIndexToGateItMap[index] = submoduleIndex; - return submoduleIndex; - } - else + if (it != classIndexToGateItMap.end()) return it->second; + auto parentModule = getParentModule(); + int submoduleIndex = gateSize("out"); + int origVectorSize = parentModule->getSubmoduleVectorSize(submoduleName); + parentModule->setSubmoduleVectorSize(submoduleName, std::max(origVectorSize, submoduleIndex + 1)); + auto module = moduleType->create(submoduleName, parentModule, submoduleIndex); + auto moduleInputGate = module->gate("in"); + auto moduleOutputGate = module->gate("out"); + auto multiplexer = parentModule->getSubmodule("multiplexer"); + multiplexer->setGateSize("in", multiplexer->gateSize("in") + 1); + auto multiplexerInputGate = multiplexer->gate("in", multiplexer->gateSize("in") - 1); + setGateSize("out", submoduleIndex + 1); + auto classifierOutputGate = gate("out", gateSize("out") - 1); + classifierOutputGate->connectTo(moduleInputGate); + outputGates.push_back(classifierOutputGate); + PassivePacketSinkRef consumer; + consumer.reference(classifierOutputGate, false); + consumers.push_back(consumer); + moduleOutputGate->connectTo(multiplexerInputGate); + module->finalizeParameters(); + module->buildInside(); + module->callInitialize(); + classIndexToGateItMap[index] = submoduleIndex; + return submoduleIndex; } } // namespace queueing } // namespace inet - diff --git a/src/inet/queueing/classifier/DynamicClassifier.h b/src/inet/queueing/classifier/DynamicClassifier.h index e1907fe1e55..f052601d718 100644 --- a/src/inet/queueing/classifier/DynamicClassifier.h +++ b/src/inet/queueing/classifier/DynamicClassifier.h @@ -31,4 +31,3 @@ class INET_API DynamicClassifier : public PacketClassifier } // namespace inet #endif - From dc7af743f0e9df9b59ed16190d9e610c3f591e47 Mon Sep 17 00:00:00 2001 From: Gyorgy Szaszko Date: Wed, 12 Aug 2026 17:49:19 +0200 Subject: [PATCH 02/14] queueing: move branch creation out of DynamicClassifier::classifyPacket Extract-function move: the block that builds a branch -- grows the submodule vector, creates the module, wires it between the classifier and the multiplexer, and initializes it -- becomes createBranch(), the lines byte-identical (review with --color-moved). The class-to-branch map entry stays at the call site, fed by the return value: the map is classification bookkeeping, and createBranch() is topology only. No change in behavior. classifyPacket() reads as what it is: look the class up, create its branch on first sight. --- src/inet/queueing/classifier/DynamicClassifier.cc | 8 +++++++- src/inet/queueing/classifier/DynamicClassifier.h | 2 ++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/inet/queueing/classifier/DynamicClassifier.cc b/src/inet/queueing/classifier/DynamicClassifier.cc index 78456cf9347..bac32535354 100644 --- a/src/inet/queueing/classifier/DynamicClassifier.cc +++ b/src/inet/queueing/classifier/DynamicClassifier.cc @@ -32,6 +32,13 @@ int DynamicClassifier::classifyPacket(Packet *packet) auto it = classIndexToGateItMap.find(index); if (it != classIndexToGateItMap.end()) return it->second; + int branchIndex = createBranch(); + classIndexToGateItMap[index] = branchIndex; + return branchIndex; +} + +int DynamicClassifier::createBranch() +{ auto parentModule = getParentModule(); int submoduleIndex = gateSize("out"); int origVectorSize = parentModule->getSubmoduleVectorSize(submoduleName); @@ -53,7 +60,6 @@ int DynamicClassifier::classifyPacket(Packet *packet) module->finalizeParameters(); module->buildInside(); module->callInitialize(); - classIndexToGateItMap[index] = submoduleIndex; return submoduleIndex; } diff --git a/src/inet/queueing/classifier/DynamicClassifier.h b/src/inet/queueing/classifier/DynamicClassifier.h index f052601d718..91c187fdfeb 100644 --- a/src/inet/queueing/classifier/DynamicClassifier.h +++ b/src/inet/queueing/classifier/DynamicClassifier.h @@ -25,6 +25,8 @@ class INET_API DynamicClassifier : public PacketClassifier protected: virtual void initialize(int stage) override; virtual int classifyPacket(Packet *packet) override; + + virtual int createBranch(); }; } // namespace queueing From 7263f0b49772728ef6155e74e8790b7583372044 Mon Sep 17 00:00:00 2001 From: Gyorgy Szaszko Date: Wed, 12 Aug 2026 17:14:01 +0200 Subject: [PATCH 03/14] queueing: let DynamicClassifier wire branches into any aggregator ~DynamicClassifier could only wire a branch into a submodule literally named "multiplexer". The downstream aggregator is now named by the aggregatorSubmoduleName parameter (still "multiplexer" by default), and it may be a pull scheduler instead of a push multiplexer: an aggregator that has to take notice of an input appearing at runtime learns about it from the POST_MODEL_CHANGE notification of the connection being made (cPostPathCreateNotification), so no contract is needed between the classifier and the aggregator beyond wiring the gate. For the pull side the classifier now also takes a collector reference per branch, the way it already took a consumer reference for the push side. The missing-submodule-vector and missing-aggregator cases fail with a clear error naming the module instead of a null dereference. --- .../queueing/classifier/DynamicClassifier.cc | 22 ++++++++++++++----- .../queueing/classifier/DynamicClassifier.h | 18 +++++++++++++-- .../queueing/classifier/DynamicClassifier.ned | 20 +++++++++++++++-- 3 files changed, 50 insertions(+), 10 deletions(-) diff --git a/src/inet/queueing/classifier/DynamicClassifier.cc b/src/inet/queueing/classifier/DynamicClassifier.cc index bac32535354..aae4142374e 100644 --- a/src/inet/queueing/classifier/DynamicClassifier.cc +++ b/src/inet/queueing/classifier/DynamicClassifier.cc @@ -21,9 +21,12 @@ void DynamicClassifier::initialize(int stage) if (stage == INITSTAGE_LOCAL) { submoduleName = par("submoduleName"); moduleType = cModuleType::get(par("moduleType")); + aggregatorSubmoduleName = par("aggregatorSubmoduleName"); if (!getParentModule()->hasSubmoduleVector(submoduleName)) - throw cRuntimeError("The submodule vector '%s' missing from %s", submoduleName, getParentModule()->getFullPath().c_str()); - } + throw cRuntimeError("The submodule vector '%s' is missing from %s", submoduleName, getParentModule()->getFullPath().c_str()); + if (getParentModule()->getSubmodule(aggregatorSubmoduleName) == nullptr) + throw cRuntimeError("The aggregator submodule '%s' is missing from %s", aggregatorSubmoduleName, getParentModule()->getFullPath().c_str()); + } } int DynamicClassifier::classifyPacket(Packet *packet) @@ -46,9 +49,13 @@ int DynamicClassifier::createBranch() auto module = moduleType->create(submoduleName, parentModule, submoduleIndex); auto moduleInputGate = module->gate("in"); auto moduleOutputGate = module->gate("out"); - auto multiplexer = parentModule->getSubmodule("multiplexer"); - multiplexer->setGateSize("in", multiplexer->gateSize("in") + 1); - auto multiplexerInputGate = multiplexer->gate("in", multiplexer->gateSize("in") - 1); + // Wire the branch output into the aggregator's next input gate. An aggregator that has + // to take notice of a runtime-added input (a pull scheduler, for example) learns about + // it from the model change notification of this very connection, so nothing here needs + // to know what kind of aggregator it is. + auto aggregator = parentModule->getSubmodule(aggregatorSubmoduleName); + aggregator->setGateSize("in", aggregator->gateSize("in") + 1); + auto aggregatorInputGate = aggregator->gate("in", aggregator->gateSize("in") - 1); setGateSize("out", submoduleIndex + 1); auto classifierOutputGate = gate("out", gateSize("out") - 1); classifierOutputGate->connectTo(moduleInputGate); @@ -56,7 +63,10 @@ int DynamicClassifier::createBranch() PassivePacketSinkRef consumer; consumer.reference(classifierOutputGate, false); consumers.push_back(consumer); - moduleOutputGate->connectTo(multiplexerInputGate); + ActivePacketSinkRef collector; + collector.reference(classifierOutputGate, false); + collectors.push_back(collector); + moduleOutputGate->connectTo(aggregatorInputGate); module->finalizeParameters(); module->buildInside(); module->callInitialize(); diff --git a/src/inet/queueing/classifier/DynamicClassifier.h b/src/inet/queueing/classifier/DynamicClassifier.h index 91c187fdfeb..3151479fa7d 100644 --- a/src/inet/queueing/classifier/DynamicClassifier.h +++ b/src/inet/queueing/classifier/DynamicClassifier.h @@ -15,11 +15,25 @@ namespace queueing { using namespace inet::queueing; +/** + * A packet classifier that creates the branch for each traffic class on demand, the first + * time a packet of that class is seen. Each branch is one element of a submodule vector + * (submoduleName) of a configurable type (moduleType), wired between this classifier's output + * and a downstream aggregator submodule (aggregatorSubmoduleName). + * + * The aggregator may be either a push ~PacketMultiplexer (the traditional use) or a pull + * scheduler. An aggregator that needs to take notice of an input appearing at runtime picks + * it up from the POST_MODEL_CHANGE notification of the connection itself (see + * cPostPathCreateNotification), so no extra contract is needed between the two. This lets the + * same classifier build both push demux/remux chains and pull per-class queue/scheduler + * structures. + */ class INET_API DynamicClassifier : public PacketClassifier { protected: - const char *submoduleName = nullptr; - cModuleType *moduleType = nullptr; + const char *submoduleName = nullptr; // submodule vector that holds the branches + cModuleType *moduleType = nullptr; // type of the per-class branch module (may be a compound) + const char *aggregatorSubmoduleName = nullptr; // downstream aggregator submodule (multiplexer or scheduler) std::map classIndexToGateItMap; protected: diff --git a/src/inet/queueing/classifier/DynamicClassifier.ned b/src/inet/queueing/classifier/DynamicClassifier.ned index 9a679e097ce..7cbf88dd536 100644 --- a/src/inet/queueing/classifier/DynamicClassifier.ned +++ b/src/inet/queueing/classifier/DynamicClassifier.ned @@ -7,10 +7,26 @@ package inet.queueing.classifier; +// +// A packet classifier that creates the branch for each traffic class on demand. Each branch is +// one element of the `submoduleName` submodule vector, of type `moduleType` (which may be a +// compound module), wired between this classifier's output and a downstream aggregator +// submodule (`aggregatorSubmoduleName`, a push multiplexer by default). The aggregator may also +// be a pull scheduler; one that has to take notice of an input appearing at runtime learns +// about it from the model change notification of the connection being made, so no extra +// contract is needed between the two. +// +// The submodule vector must be declared in the enclosing compound module, where it may be +// empty; the classifier extends it as branches are created. A branch is created with its final +// name and index, so parameter assignments (both from the enclosing NED declaration and from +// the ini file), display string configuration and result recording all address it as +// `[k]`. +// simple DynamicClassifier extends PacketClassifier { parameters: - string submoduleName; - string moduleType; + string moduleType; // NED type of the per-class branch module (may be a compound) + string submoduleName; // the submodule vector that holds the branches + string aggregatorSubmoduleName = default("multiplexer"); // downstream aggregator submodule to wire branches into @class(DynamicClassifier); } From 1b0ed6121c99cc91c6a0700f586cb188b71aac0e Mon Sep 17 00:00:00 2001 From: Gyorgy Szaszko Date: Wed, 12 Aug 2026 17:15:02 +0200 Subject: [PATCH 04/14] queueing: initialize a DynamicClassifier branch only once it is fully wired Branch modules were initialized right after being built, before the branch was connected to the aggregator, and the classifier took its sink references on its new out gate while the far end of the path was still incomplete. Both are traps for a compound branch: a module that resolves its downstream peer in initialize() would see a dangling gate, and ModuleRefByGate::reference() resolves the peer eagerly by walking the connection -- with mandatory=false it silently stores a nullptr that nothing ever re-resolves, leaving a permanently null consumer whose canPushPacket() throws and whose pushPacket() quietly degrades to send(), bypassing back-pressure. Wire first, resolve and initialize after: createModuleBranch() builds the branch module (with its final name and index, so its parameters, display string and result recording are all resolved for the module path it keeps) and defers its initialization; createBranch() connects the chain up to and including the aggregator, then takes the references and runs the deferred initializations. No change in behavior for the existing simple-branch users, where the old order happened to be safe. --- .../queueing/classifier/DynamicClassifier.cc | 47 +++++++++++++------ .../queueing/classifier/DynamicClassifier.h | 3 ++ 2 files changed, 35 insertions(+), 15 deletions(-) diff --git a/src/inet/queueing/classifier/DynamicClassifier.cc b/src/inet/queueing/classifier/DynamicClassifier.cc index aae4142374e..11c7993dec3 100644 --- a/src/inet/queueing/classifier/DynamicClassifier.cc +++ b/src/inet/queueing/classifier/DynamicClassifier.cc @@ -42,23 +42,25 @@ int DynamicClassifier::classifyPacket(Packet *packet) int DynamicClassifier::createBranch() { - auto parentModule = getParentModule(); - int submoduleIndex = gateSize("out"); - int origVectorSize = parentModule->getSubmoduleVectorSize(submoduleName); - parentModule->setSubmoduleVectorSize(submoduleName, std::max(origVectorSize, submoduleIndex + 1)); - auto module = moduleType->create(submoduleName, parentModule, submoduleIndex); - auto moduleInputGate = module->gate("in"); - auto moduleOutputGate = module->gate("out"); + cModule *parent = getParentModule(); + int index = gateSize("out"); + // grow this classifier's output gate vector + setGateSize("out", index + 1); + cGate *classifierOutputGate = gate("out", index); + // build the branch and collect the modules whose initialization is deferred until the + // whole chain (including the aggregator connection) is wired + std::vector modulesToInitialize; + cGate *branchOutputGate = createModuleBranch(index, classifierOutputGate, modulesToInitialize); // Wire the branch output into the aggregator's next input gate. An aggregator that has // to take notice of a runtime-added input (a pull scheduler, for example) learns about // it from the model change notification of this very connection, so nothing here needs // to know what kind of aggregator it is. - auto aggregator = parentModule->getSubmodule(aggregatorSubmoduleName); + cModule *aggregator = parent->getSubmodule(aggregatorSubmoduleName); aggregator->setGateSize("in", aggregator->gateSize("in") + 1); - auto aggregatorInputGate = aggregator->gate("in", aggregator->gateSize("in") - 1); - setGateSize("out", submoduleIndex + 1); - auto classifierOutputGate = gate("out", gateSize("out") - 1); - classifierOutputGate->connectTo(moduleInputGate); + cGate *aggregatorInputGate = aggregator->gate("in", aggregator->gateSize("in") - 1); + branchOutputGate->connectTo(aggregatorInputGate); + // the sink references resolve the far end of the path eagerly, so they can only be taken + // now that the whole branch, up to and including the aggregator, is connected outputGates.push_back(classifierOutputGate); PassivePacketSinkRef consumer; consumer.reference(classifierOutputGate, false); @@ -66,11 +68,26 @@ int DynamicClassifier::createBranch() ActivePacketSinkRef collector; collector.reference(classifierOutputGate, false); collectors.push_back(collector); - moduleOutputGate->connectTo(aggregatorInputGate); + for (auto module : modulesToInitialize) + module->callInitialize(); + return index; +} + +cGate *DynamicClassifier::createModuleBranch(int index, cGate *classifierOutputGate, std::vector& modulesToInitialize) +{ + cModule *parent = getParentModule(); + // the vector is only ever extended: it may have been declared larger in NED, and shrinking + // one that still holds submodules is an error + parent->setSubmoduleVectorSize(submoduleName, std::max(parent->getSubmoduleVectorSize(submoduleName), index + 1)); + // the branch is created with its final name and index, so that its parameters (from the + // enclosing NED declaration and from the ini file), its display string and its result + // recording are all resolved for the module path it keeps + cModule *module = moduleType->create(submoduleName, parent, index); + classifierOutputGate->connectTo(module->gate("in")); module->finalizeParameters(); module->buildInside(); - module->callInitialize(); - return submoduleIndex; + modulesToInitialize.push_back(module); + return module->gate("out"); } } // namespace queueing diff --git a/src/inet/queueing/classifier/DynamicClassifier.h b/src/inet/queueing/classifier/DynamicClassifier.h index 3151479fa7d..78b036e28fc 100644 --- a/src/inet/queueing/classifier/DynamicClassifier.h +++ b/src/inet/queueing/classifier/DynamicClassifier.h @@ -8,6 +8,8 @@ #ifndef __INET_DYNAMICCLASSIFIER_H #define __INET_DYNAMICCLASSIFIER_H +#include + #include "inet/queueing/classifier/PacketClassifier.h" namespace inet { @@ -41,6 +43,7 @@ class INET_API DynamicClassifier : public PacketClassifier virtual int classifyPacket(Packet *packet) override; virtual int createBranch(); + virtual cGate *createModuleBranch(int index, cGate *classifierOutputGate, std::vector& modulesToInitialize); }; } // namespace queueing From 060cdbf3d69553ef3c896b1c00515d84539c4f04 Mon Sep 17 00:00:00 2001 From: Gyorgy Szaszko Date: Wed, 12 Aug 2026 17:15:39 +0200 Subject: [PATCH 05/14] queueing: fix DynamicClassifier keying its class map on shifting gate indices The class-to-branch map was keyed on the result of PacketClassifier::classifyPacket(), which maps the classifier function's index through getOutputGateIndex(). With reverseOrder that mapping is relative to the current number of output gates -- which grows with each branch created -- so the same class would be looked up under a different key later, miss, and get a second branch. Key the map on the classifier function's index directly, taken through the new getClassIndex(), which classifies without the branch-creating side effect of classifyPacket(). --- src/inet/queueing/classifier/DynamicClassifier.cc | 12 +++++++++++- src/inet/queueing/classifier/DynamicClassifier.h | 1 + 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/inet/queueing/classifier/DynamicClassifier.cc b/src/inet/queueing/classifier/DynamicClassifier.cc index 11c7993dec3..5fd39e43a1f 100644 --- a/src/inet/queueing/classifier/DynamicClassifier.cc +++ b/src/inet/queueing/classifier/DynamicClassifier.cc @@ -29,9 +29,19 @@ void DynamicClassifier::initialize(int stage) } } +int DynamicClassifier::getClassIndex(Packet *packet) const +{ + // the class of the packet, with no side effect -- unlike classifyPacket() below, which + // creates the branch of a class that is seen for the first time. Note that the class index + // is taken as it is, and not mapped through getOutputGateIndex(): that mapping depends on + // the number of output gates, which grows with each branch, so the same class would end up + // under a different key over time, and get a second branch. + return packetClassifierFunction->classifyPacket(packet); +} + int DynamicClassifier::classifyPacket(Packet *packet) { - int index = PacketClassifier::classifyPacket(packet); + int index = getClassIndex(packet); auto it = classIndexToGateItMap.find(index); if (it != classIndexToGateItMap.end()) return it->second; diff --git a/src/inet/queueing/classifier/DynamicClassifier.h b/src/inet/queueing/classifier/DynamicClassifier.h index 78b036e28fc..039a7d88ffa 100644 --- a/src/inet/queueing/classifier/DynamicClassifier.h +++ b/src/inet/queueing/classifier/DynamicClassifier.h @@ -40,6 +40,7 @@ class INET_API DynamicClassifier : public PacketClassifier protected: virtual void initialize(int stage) override; + virtual int getClassIndex(Packet *packet) const; virtual int classifyPacket(Packet *packet) override; virtual int createBranch(); From ad7b15b0a9b2892dd93179e701736515051c4215 Mon Sep 17 00:00:00 2001 From: Gyorgy Szaszko Date: Wed, 12 Aug 2026 17:16:36 +0200 Subject: [PATCH 06/14] queueing: let DynamicClassifier accept a packet before it has any branch canPushSomePacket() is inherited as "one of the existing branches can take a packet", which is false for a classifier that has not built any branch yet. An active source in front of such a classifier stops, waits for the notification that would tell it packets can be pushed again, and never gets it, because nothing else creates the first branch. Answer true instead: a packet of a class that has not been seen yet is taken by the branch created for it, and the range of the classifier function is not known here, so there may always be such a class. canPushPacket() is worse than useless in its inherited form here: it classifies the packet, and for this classifier classifying creates the branch of a new class, so a query that is supposed to be a query builds submodules, grows gate vectors, wires connections and initializes the new modules. Look the class up instead, and only delegate to the branch that already exists. (The pull side classifies in canPullPacket() too, and is left alone: there the query is what drives branch creation, and this classifier has no pull user.) The module test covers this -- the producer is connected to the classifier directly, so without the fix it never produces and no branch is built -- along with the rest of the contract: two branches built on demand, an ini file assignment addressing a submodule of a branch taking effect, and the statistics of the branch submodules being recorded under the branch path. --- .../queueing/classifier/DynamicClassifier.cc | 19 +++ .../queueing/classifier/DynamicClassifier.h | 4 + tests/queueing/DynamicClassifier_1.test | 130 ++++++++++++++++++ 3 files changed, 153 insertions(+) create mode 100644 tests/queueing/DynamicClassifier_1.test diff --git a/src/inet/queueing/classifier/DynamicClassifier.cc b/src/inet/queueing/classifier/DynamicClassifier.cc index 5fd39e43a1f..c005b7296d9 100644 --- a/src/inet/queueing/classifier/DynamicClassifier.cc +++ b/src/inet/queueing/classifier/DynamicClassifier.cc @@ -50,6 +50,25 @@ int DynamicClassifier::classifyPacket(Packet *packet) return branchIndex; } +bool DynamicClassifier::canPushSomePacket(const cGate *gate) const +{ + // Not the inherited "one of the existing branches can take a packet": a packet of a class + // that has not been seen yet is taken by the branch created for it, and there may always be + // such a class, the range of the classifier function not being known here. Without this, a + // classifier that has no branch yet answers that it cannot accept anything, and an active + // source in front of it stops before the first branch is ever created. Whether a particular + // packet can be pushed is answered by canPushPacket() below. + return true; +} + +bool DynamicClassifier::canPushPacket(Packet *packet, const cGate *gate) const +{ + // deliberately not the inherited implementation: that one classifies the packet, which + // creates the branch of a new class as a side effect of what is supposed to be a query + auto it = classIndexToGateItMap.find(getClassIndex(packet)); + return it == classIndexToGateItMap.end() || consumers[it->second].canPushPacket(packet); +} + int DynamicClassifier::createBranch() { cModule *parent = getParentModule(); diff --git a/src/inet/queueing/classifier/DynamicClassifier.h b/src/inet/queueing/classifier/DynamicClassifier.h index 039a7d88ffa..79c6bc0d448 100644 --- a/src/inet/queueing/classifier/DynamicClassifier.h +++ b/src/inet/queueing/classifier/DynamicClassifier.h @@ -45,6 +45,10 @@ class INET_API DynamicClassifier : public PacketClassifier virtual int createBranch(); virtual cGate *createModuleBranch(int index, cGate *classifierOutputGate, std::vector& modulesToInitialize); + + public: + virtual bool canPushSomePacket(const cGate *gate) const override; + virtual bool canPushPacket(Packet *packet, const cGate *gate) const override; }; } // namespace queueing diff --git a/tests/queueing/DynamicClassifier_1.test b/tests/queueing/DynamicClassifier_1.test new file mode 100644 index 00000000000..cb0ef14bedd --- /dev/null +++ b/tests/queueing/DynamicClassifier_1.test @@ -0,0 +1,130 @@ +%description: + +In this test, packets are produced periodically by an active packet source (ActivePacketSource) +and are classified into two classes by a dynamic classifier (DynamicClassifier). The classifier +creates the branch of a class when the first packet of that class arrives, as one element of the +branch submodule vector, and wires it into the packet multiplexer that aggregates the branches. + +The producer is connected to the classifier directly, so the test also covers that a classifier +which has no branch yet accepts a packet, rather than stopping the producer before the first +branch is created. + +The branch is created with its final name and index, so the test checks that an ini file +assignment addressing a submodule of a branch (the delay of the packet delayer in it) takes +effect, and that the statistics of the branch submodules are recorded under the branch path. +Empty output vectors are turned off, so a vector appears in the result file only if data was +recorded into it. + +%file: test.ned + +import inet.queueing.classifier.DynamicClassifier; +import inet.queueing.common.BackPressureBarrier; +import inet.queueing.common.PacketDelayer; +import inet.queueing.common.PacketMultiplexer; +import inet.queueing.sink.PassivePacketSink; +import inet.queueing.source.ActivePacketSource; + +module TestBranch +{ + gates: + input in; + output out; + submodules: + first: BackPressureBarrier { + @display("p=100,100"); + } + second: PacketDelayer { + delay = default(0s); + @display("p=200,100"); + } + connections: + in --> first.in; + first.out --> second.in; + second.out --> out; +} + +module TestDemultiplexer +{ + gates: + input in; + output out; + submodules: + classifier: DynamicClassifier { + moduleType = "TestBranch"; + submoduleName = "branch"; + @display("p=100,100"); + } + branch[0]: TestBranch { // grown on demand, one branch per class + @display("p=250,100,column,80"); + } + multiplexer: PacketMultiplexer { + @display("p=400,100"); + } + connections allowunconnected: + in --> classifier.in; + multiplexer.out --> out; +} + +network TestDynamicClassifier +{ + submodules: + producer: ActivePacketSource { + @display("p=100,100"); + } + demultiplexer: TestDemultiplexer { + @display("p=200,100"); + } + consumer: PassivePacketSink { + @display("p=300,100"); + } + connections: + producer.out --> demultiplexer.in; + demultiplexer.out --> consumer.in; +} + +%file: Test.cc +#include "inet/queueing/function/PacketClassifierFunction.h" +#include "inet/common/packet/Packet.h" + +using namespace inet; + +static int testClassify(Packet *packet) +{ + return packet->getId() % 2; +} + +Register_Packet_Classifier_Function(TestClassifier, testClassify); + +%inifile: omnetpp.ini + +[General] +network = TestDynamicClassifier +sim-time-limit = 10s +cmdenv-event-banners = false +cmdenv-log-prefix = "At %ts %N: " +**.vector-record-empty = false +*.producer.packetLength = 1B +*.producer.productionInterval = 1s +*.demultiplexer.classifier.classifierClass = "TestClassifier" +*.demultiplexer.branch[*].second.delay = 2s + +%# remove formatting +%subst: /\x1B\[[0-9;]*m// +%# remove method call lines added in OMNeT++ 6.4 +%subst: /^At \S+ \S+: Method call [^\n]*\n//m +%#-------------------------------------------------------------------------------------------------------------- +%# the delay assigned to the branch submodule from the ini file must be applied +%contains-regex: stdout +At 0s producer: Producing packet, .*?producer-0.*? +At 1s producer: Producing packet, .*?producer-1.*? +At 2s consumer: Consuming packet, .*?producer-0.*? +At 3s consumer: Consuming packet, .*?producer-1.*? +%#-------------------------------------------------------------------------------------------------------------- +%# the modules that have data in an output vector, and the branch submodules among them +%postrun-command: grep "^vector " results/*.vec | cut -d ' ' -f 3 | sort -u > modules.out +%postrun-command: grep -E "\.branch\[" modules.out > branchmodules.out || true +%#-------------------------------------------------------------------------------------------------------------- +%contains: branchmodules.out +TestDynamicClassifier.demultiplexer.branch[0].first +TestDynamicClassifier.demultiplexer.branch[1].first +%#-------------------------------------------------------------------------------------------------------------- From d427993de88b69139454b918d4919bafbc377f3a Mon Sep 17 00:00:00 2001 From: Gyorgy Szaszko Date: Thu, 13 Aug 2026 12:01:55 +0200 Subject: [PATCH 07/14] queueing: let a classifier grow an output gate for a packet, outside classification classifyPacket() is a query: the capacity checks (canPushPacket(), canPullPacket()) classify the very packet whose delivery classifies it again, and the pull path classifies it on every peek, so classification must be free of side effects. The contract is now spelled out, together with the convention -- already followed by PriorityClassifier, WrrClassifier and the diffserv classifiers -- that -1 means "no existing output gate suits this packet" rather than an out-of-range error. What happens to such a packet is decided on the delivery path only: pushPacket() and startPacketStreaming() ask the new createGateForPacket() hook, whose default refuses the packet just as the range check did, and canPushPacket() asks its side-effect-free query pair, canCreateGateForPacket(). A classifier that extends itself on demand creates its new output gate in the hook, never from a query. The pull-side callers pass -1 through unchanged: canPullPacket() already answers "not for this gate", the pullPacket() family already refuses, and handleCanPullPacketChanged() now skips the notification instead of indexing collectors[-1]. The stateful classifyPacket() implementations that remain (WrrClassifier, TokenBucketClassifier, MultiTokenBucketClassifier) are out of scope here; they are why callClassifyPacket() keeps its const_cast kludge. --- .../queueing/base/PacketClassifierBase.cc | 20 +++++++++++++++- src/inet/queueing/base/PacketClassifierBase.h | 24 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/inet/queueing/base/PacketClassifierBase.cc b/src/inet/queueing/base/PacketClassifierBase.cc index c221362e2c5..39a64b584f8 100644 --- a/src/inet/queueing/base/PacketClassifierBase.cc +++ b/src/inet/queueing/base/PacketClassifierBase.cc @@ -60,11 +60,21 @@ int PacketClassifierBase::callClassifyPacket(Packet *packet) const { // KLUDGE int index = const_cast(this)->classifyPacket(packet); - if (index < 0 || static_cast(index) >= outputGates.size()) + if (index < -1 || index >= (int)outputGates.size()) throw cRuntimeError("Packet is classified to invalid output gate: %d", index); return index; } +int PacketClassifierBase::createGateForPacket(Packet *packet) +{ + throw cRuntimeError("Packet cannot be classified to any output gate"); +} + +bool PacketClassifierBase::canCreateGateForPacket(Packet *packet) const +{ + return false; +} + void PacketClassifierBase::checkPacketStreaming(Packet *packet) { if (inProgressStreamId != -1 && (packet == nullptr || packet->getTreeId() != inProgressStreamId)) @@ -76,6 +86,8 @@ void PacketClassifierBase::startPacketStreaming(Packet *packet) EV_INFO << "Classifying packet" << EV_FIELD(packet) << EV_ENDL; inProgressStreamId = packet->getTreeId(); inProgressGateIndex = callClassifyPacket(packet); + if (inProgressGateIndex == -1) + inProgressGateIndex = createGateForPacket(packet); } void PacketClassifierBase::endPacketStreaming(Packet *packet) @@ -97,6 +109,8 @@ bool PacketClassifierBase::canPushSomePacket(const cGate *gate) const bool PacketClassifierBase::canPushPacket(Packet *packet, const cGate *gate) const { int index = callClassifyPacket(packet); + if (index == -1) + return canCreateGateForPacket(packet); return consumers[index].canPushPacket(packet); } @@ -107,6 +121,8 @@ void PacketClassifierBase::pushPacket(Packet *packet, const cGate *gate) checkPacketStreaming(nullptr); EV_INFO << "Classifying packet" << EV_FIELD(packet) << EV_ENDL; int index = callClassifyPacket(packet); + if (index == -1) + index = createGateForPacket(packet); handlePacketProcessed(packet); emit(packetPushedSignal, packet); pushOrSendPacket(packet, outputGates[index], consumers[index]); @@ -219,6 +235,8 @@ void PacketClassifierBase::handleCanPullPacketChanged(const cGate *gate) auto packet = provider.canPullPacket(); if (packet != nullptr) { int index = callClassifyPacket(packet); + if (index == -1) + return; // the packet routes to no existing gate, so there is no collector to notify auto collector = collectors[index]; if (collector != nullptr) collector.handleCanPullPacketChanged(); diff --git a/src/inet/queueing/base/PacketClassifierBase.h b/src/inet/queueing/base/PacketClassifierBase.h index 7556ec6b89e..1732771f38a 100644 --- a/src/inet/queueing/base/PacketClassifierBase.h +++ b/src/inet/queueing/base/PacketClassifierBase.h @@ -43,9 +43,33 @@ class INET_API PacketClassifierBase : public PacketProcessorBase, public Transpa virtual void mapRegistrationForwardingGates(cGate *gate, std::function f) override; virtual size_t getOutputGateIndex(size_t i) const { return reverseOrder ? outputGates.size() - i - 1 : i; } + + /** + * Returns the index of the output gate the packet is classified to, or -1 + * if no existing output gate suits the packet. Classification is a query + * and must be free of side effects: the capacity checks (canPushPacket(), + * canPullPacket()) classify the same packet as its eventual delivery, and + * the pull path classifies it more than once. + */ virtual int classifyPacket(Packet *packet) = 0; virtual int callClassifyPacket(Packet *packet) const; + /** + * Called when a packet being pushed is classified to no existing output + * gate. This is where side effects of taking such a packet belong: a + * classifier that extends itself on demand creates the new output gate + * here and returns its index. Called from packet delivery only, never + * from a query. The default refuses the packet with an error. + */ + virtual int createGateForPacket(Packet *packet); + + /** + * Returns true if createGateForPacket() would provide an output gate for + * the packet: the query pair of createGateForPacket(), consulted by + * canPushPacket() when classifyPacket() finds no gate. + */ + virtual bool canCreateGateForPacket(Packet *packet) const; + virtual bool isStreamingPacket() const { return inProgressStreamId != -1; } virtual void startPacketStreaming(Packet *packet); virtual void endPacketStreaming(Packet *packet); From 4a91aeb1f360f5418fa6346428df058819ebd8b5 Mon Sep 17 00:00:00 2001 From: Gyorgy Szaszko Date: Thu, 13 Aug 2026 12:02:40 +0200 Subject: [PATCH 08/14] queueing: make DynamicClassifier classification free of side effects classifyPacket() now only answers where a packet of an already-seen class goes, and -1 for a class that has no branch yet; the branch is created in createGateForPacket(), which the base class calls on packet delivery only. The canPushPacket() override disappears: the inherited implementation, with canCreateGateForPacket() answering that a branch can be created for every class, gives the same answers -- an unseen class is always welcome, a seen one asks its branch. --- .../queueing/classifier/DynamicClassifier.cc | 40 ++++++++++--------- .../queueing/classifier/DynamicClassifier.h | 3 +- 2 files changed, 23 insertions(+), 20 deletions(-) diff --git a/src/inet/queueing/classifier/DynamicClassifier.cc b/src/inet/queueing/classifier/DynamicClassifier.cc index c005b7296d9..644f0416edf 100644 --- a/src/inet/queueing/classifier/DynamicClassifier.cc +++ b/src/inet/queueing/classifier/DynamicClassifier.cc @@ -31,25 +31,34 @@ void DynamicClassifier::initialize(int stage) int DynamicClassifier::getClassIndex(Packet *packet) const { - // the class of the packet, with no side effect -- unlike classifyPacket() below, which - // creates the branch of a class that is seen for the first time. Note that the class index - // is taken as it is, and not mapped through getOutputGateIndex(): that mapping depends on - // the number of output gates, which grows with each branch, so the same class would end up - // under a different key over time, and get a second branch. + // The class of the packet, taken as the classifier function returns it, and not mapped + // through getOutputGateIndex(): that mapping depends on the number of output gates, which + // grows with each branch, so the same class would end up under a different key over time, + // and get a second branch. return packetClassifierFunction->classifyPacket(packet); } int DynamicClassifier::classifyPacket(Packet *packet) { - int index = getClassIndex(packet); - auto it = classIndexToGateItMap.find(index); - if (it != classIndexToGateItMap.end()) - return it->second; + // a class seen for the first time has no gate yet; its branch is created by + // createGateForPacket(), which the base class calls on packet delivery only + auto it = classIndexToGateItMap.find(getClassIndex(packet)); + return it != classIndexToGateItMap.end() ? it->second : -1; +} + +int DynamicClassifier::createGateForPacket(Packet *packet) +{ int branchIndex = createBranch(); - classIndexToGateItMap[index] = branchIndex; + classIndexToGateItMap[getClassIndex(packet)] = branchIndex; return branchIndex; } +bool DynamicClassifier::canCreateGateForPacket(Packet *packet) const +{ + // a branch can be created for every class, so every packet gets an output gate + return true; +} + bool DynamicClassifier::canPushSomePacket(const cGate *gate) const { // Not the inherited "one of the existing branches can take a packet": a packet of a class @@ -57,18 +66,11 @@ bool DynamicClassifier::canPushSomePacket(const cGate *gate) const // such a class, the range of the classifier function not being known here. Without this, a // classifier that has no branch yet answers that it cannot accept anything, and an active // source in front of it stops before the first branch is ever created. Whether a particular - // packet can be pushed is answered by canPushPacket() below. + // packet can be pushed is answered by the inherited canPushPacket(), through + // canCreateGateForPacket() above. return true; } -bool DynamicClassifier::canPushPacket(Packet *packet, const cGate *gate) const -{ - // deliberately not the inherited implementation: that one classifies the packet, which - // creates the branch of a new class as a side effect of what is supposed to be a query - auto it = classIndexToGateItMap.find(getClassIndex(packet)); - return it == classIndexToGateItMap.end() || consumers[it->second].canPushPacket(packet); -} - int DynamicClassifier::createBranch() { cModule *parent = getParentModule(); diff --git a/src/inet/queueing/classifier/DynamicClassifier.h b/src/inet/queueing/classifier/DynamicClassifier.h index 79c6bc0d448..66dc3f59998 100644 --- a/src/inet/queueing/classifier/DynamicClassifier.h +++ b/src/inet/queueing/classifier/DynamicClassifier.h @@ -42,13 +42,14 @@ class INET_API DynamicClassifier : public PacketClassifier virtual void initialize(int stage) override; virtual int getClassIndex(Packet *packet) const; virtual int classifyPacket(Packet *packet) override; + virtual int createGateForPacket(Packet *packet) override; + virtual bool canCreateGateForPacket(Packet *packet) const override; virtual int createBranch(); virtual cGate *createModuleBranch(int index, cGate *classifierOutputGate, std::vector& modulesToInitialize); public: virtual bool canPushSomePacket(const cGate *gate) const override; - virtual bool canPushPacket(Packet *packet, const cGate *gate) const override; }; } // namespace queueing From fe51badfeed42bb0b04df58d0c99ce4e7d4e3db1 Mon Sep 17 00:00:00 2001 From: Gyorgy Szaszko Date: Thu, 13 Aug 2026 14:08:50 +0200 Subject: [PATCH 09/14] queueing: fix overflow drops being counted twice in compound queue statistics On overflow, CompoundPacketQueueBase removed the victim packet with its own removePacket(), which emits packetRemoved, and then dropped it with dropPacket(), which emits packetDropped. The queueLength statistic subtracts both signals, so every overflow drop was subtracted twice and the recorded queue length drifted downwards. The victim was also still owned by the submodule it was queued in, so the delete inside dropPacket() warned about deleting an object owned by another module. Remove the victim directly from the underlying collection -- the submodule it leaves emits its own packetRemoved, which the compound's localSignal() statistics rightly ignore -- and take it before the delete. Affects every compound queue configured with a dropperClass. --- src/inet/queueing/queue/CompoundPacketQueueBase.cc | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/inet/queueing/queue/CompoundPacketQueueBase.cc b/src/inet/queueing/queue/CompoundPacketQueueBase.cc index d52d3842a69..99fdf212d19 100644 --- a/src/inet/queueing/queue/CompoundPacketQueueBase.cc +++ b/src/inet/queueing/queue/CompoundPacketQueueBase.cc @@ -61,7 +61,14 @@ void CompoundPacketQueueBase::pushPacket(Packet *packet, const cGate *gate) while (isOverloaded()) { auto packet = packetDropperFunction->selectPacket(this); EV_INFO << "Dropping packet" << EV_FIELD(packet) << EV_ENDL; - removePacket(packet); + // Remove the victim directly from the underlying collection instead of via + // removePacket(): removePacket() also emits packetRemoved, which would count the + // drop as both a removal and a drop, subtracting it twice from the queue-length + // statistic. The victim is still owned by the submodule it was queued in, so take + // it before dropPacket() deletes it. + collection->removePacket(packet); + if (packet->getOwner() != this) + take(packet); dropPacket(packet, QUEUE_OVERFLOW); } } From d2cf0203d0c5f75cd82fdd81d1d006a3454f1478 Mon Sep 17 00:00:00 2001 From: Gyorgy Szaszko Date: Thu, 6 Aug 2026 14:57:40 +0200 Subject: [PATCH 10/14] ieee80211: report the on-air time of each transmitted frame Adds an Ieee80211AirtimeInd-carrying frameTransmittedAirtime signal, emitted by ~Dcf and ~Hcf from transmissionComplete() for every unicast data/mgmt frame, naming the receiver and the on-air duration. The duration is computed from the mode actually selected for the frame and the frame length, so it is exact rather than estimated, and because it is emitted per completed transmission rather than per queued frame, retransmissions are reported individually. Control frames and group-addressed frames are not reported: they are not attributable to a single peer. This is the input an airtime-fair transmit scheduler needs in order to charge a station for what it actually consumed on the medium. --- .../common/Ieee80211TransmittedAirtime.msg | 24 +++++++++++++++++++ .../ieee80211/mac/coordinationfunction/Dcf.cc | 17 +++++++++++++ .../ieee80211/mac/coordinationfunction/Dcf.h | 3 +++ .../mac/coordinationfunction/Dcf.ned | 1 + .../ieee80211/mac/coordinationfunction/Hcf.cc | 16 +++++++++++++ .../ieee80211/mac/coordinationfunction/Hcf.h | 1 + .../mac/coordinationfunction/Hcf.ned | 1 + 7 files changed, 63 insertions(+) create mode 100644 src/inet/linklayer/ieee80211/mac/common/Ieee80211TransmittedAirtime.msg diff --git a/src/inet/linklayer/ieee80211/mac/common/Ieee80211TransmittedAirtime.msg b/src/inet/linklayer/ieee80211/mac/common/Ieee80211TransmittedAirtime.msg new file mode 100644 index 00000000000..cbbca1fda35 --- /dev/null +++ b/src/inet/linklayer/ieee80211/mac/common/Ieee80211TransmittedAirtime.msg @@ -0,0 +1,24 @@ +// +// Copyright (C) 2026 OpenSim Ltd. +// +// SPDX-License-Identifier: LGPL-3.0-or-later +// + + +import inet.common.INETDefs; +import inet.linklayer.common.MacAddress; + +namespace inet::ieee80211; + +// +// Carries the on-air time actually consumed by a transmitted frame, together with +// the receiver it was sent to. Emitted by the coordination function (~Dcf, ~Hcf) +// as the object value of the `frameTransmittedAirtime` signal, once per on-air +// transmission (so retries are counted), and consumed by ~AirtimeFairnessQueue to +// charge the receiver's airtime deficit a-posteriori. +// +class Ieee80211TransmittedAirtime extends cObject +{ + MacAddress receiverAddress; + simtime_t airtime; +} diff --git a/src/inet/linklayer/ieee80211/mac/coordinationfunction/Dcf.cc b/src/inet/linklayer/ieee80211/mac/coordinationfunction/Dcf.cc index 0da41104ace..4841707ec32 100644 --- a/src/inet/linklayer/ieee80211/mac/coordinationfunction/Dcf.cc +++ b/src/inet/linklayer/ieee80211/mac/coordinationfunction/Dcf.cc @@ -9,6 +9,7 @@ #include "inet/common/ModuleAccess.h" #include "inet/linklayer/ieee80211/mac/Ieee80211Mac.h" +#include "inet/linklayer/ieee80211/mac/common/Ieee80211TransmittedAirtime_m.h" #include "inet/linklayer/ieee80211/mac/framesequence/DcfFs.h" #include "inet/linklayer/ieee80211/mac/rateselection/RateSelection.h" #include "inet/linklayer/ieee80211/mac/recipient/RecipientAckProcedure.h" @@ -18,6 +19,8 @@ namespace ieee80211 { using namespace inet::physicallayer; +simsignal_t Dcf::frameTransmittedAirtimeSignal = cComponent::registerSignal("frameTransmittedAirtime"); + Define_Module(Dcf); void Dcf::initialize(int stage) @@ -239,6 +242,20 @@ void Dcf::transmissionComplete(Packet *packet, const PtrisSequenceRunning()) { + // Account the on-air time of the just-transmitted unicast data/mgmt frame to its + // receiver, a-posteriori (so each retransmission is charged), for airtime-fair + // transmit scheduling in AirtimeFairnessQueue. Control frames and multicast are + // excluded; the duration is exact (computed from the selected mode and length). + if (auto dataOrMgmtHeader = dynamicPtrCast(header)) { + auto receiver = dataOrMgmtHeader->getReceiverAddress(); + if (!receiver.isMulticast()) { + auto mode = rateSelection->computeMode(packet, header); + Ieee80211TransmittedAirtime info; + info.setReceiverAddress(receiver); + info.setAirtime(mode->getDuration(packet->getDataLength())); + emit(frameTransmittedAirtimeSignal, &info); + } + } frameSequenceHandler->transmissionComplete(); } else diff --git a/src/inet/linklayer/ieee80211/mac/coordinationfunction/Dcf.h b/src/inet/linklayer/ieee80211/mac/coordinationfunction/Dcf.h index a4fb17cb284..1d455d8fccc 100644 --- a/src/inet/linklayer/ieee80211/mac/coordinationfunction/Dcf.h +++ b/src/inet/linklayer/ieee80211/mac/coordinationfunction/Dcf.h @@ -39,6 +39,9 @@ class Ieee80211Mac; */ class INET_API Dcf : public ICoordinationFunction, public IFrameSequenceHandler::ICallback, public IChannelAccess::ICallback, public ITx::ICallback, public IProcedureCallback, public ModeSetListener { + public: + static simsignal_t frameTransmittedAirtimeSignal; + protected: Ieee80211Mac *mac = nullptr; IRateControl *dataAndMgmtRateControl = nullptr; diff --git a/src/inet/linklayer/ieee80211/mac/coordinationfunction/Dcf.ned b/src/inet/linklayer/ieee80211/mac/coordinationfunction/Dcf.ned index eeab697b3a2..3f0dae6640b 100644 --- a/src/inet/linklayer/ieee80211/mac/coordinationfunction/Dcf.ned +++ b/src/inet/linklayer/ieee80211/mac/coordinationfunction/Dcf.ned @@ -44,6 +44,7 @@ module Dcf extends Module like IDcf @signal[frameSequenceStarted]; @signal[frameSequenceFinished]; @signal[datarateSelected](type=double); + @signal[frameTransmittedAirtime](type=inet::ieee80211::Ieee80211TransmittedAirtime); // receiver + on-air time, once per transmitted frame @statistic[packetSentToPeer](title="packets sent"; record=count,sum(packetBytes),vector(packetBytes); interpolationmode=none); @statistic[packetSentToPeerUnicast](title="packets sent: unicast"; source=ieee80211Unicast(packetSentToPeer); record=count,sum(packetBytes),vector(packetBytes); interpolationmode=none); @statistic[packetSentToPeerMulticast](title="packets sent: multicast"; source=ieee80211Multicast(packetSentToPeer); record=count,sum(packetBytes),vector(packetBytes); interpolationmode=none); diff --git a/src/inet/linklayer/ieee80211/mac/coordinationfunction/Hcf.cc b/src/inet/linklayer/ieee80211/mac/coordinationfunction/Hcf.cc index b3d11467343..be55a103dfc 100644 --- a/src/inet/linklayer/ieee80211/mac/coordinationfunction/Hcf.cc +++ b/src/inet/linklayer/ieee80211/mac/coordinationfunction/Hcf.cc @@ -12,6 +12,7 @@ #include "inet/linklayer/ieee80211/mac/blockack/OriginatorBlockAckAgreementHandler.h" #include "inet/linklayer/ieee80211/mac/blockack/OriginatorBlockAckProcedure.h" #include "inet/linklayer/ieee80211/mac/blockack/RecipientBlockAckAgreementHandler.h" +#include "inet/linklayer/ieee80211/mac/common/Ieee80211TransmittedAirtime_m.h" #include "inet/linklayer/ieee80211/mac/framesequence/HcfFs.h" #include "inet/linklayer/ieee80211/mac/recipient/RecipientAckProcedure.h" #include "inet/physicallayer/wireless/ieee80211/packetlevel/Ieee80211Tag_m.h" @@ -24,6 +25,7 @@ using namespace inet::physicallayer; simsignal_t Hcf::edcaCollisionDetectedSignal = cComponent::registerSignal("edcaCollisionDetected"); simsignal_t Hcf::blockAckAgreementAddedSignal = cComponent::registerSignal("blockAckAgreementAdded"); simsignal_t Hcf::blockAckAgreementDeletedSignal = cComponent::registerSignal("blockAckAgreementDeleted"); +simsignal_t Hcf::frameTransmittedAirtimeSignal = cComponent::registerSignal("frameTransmittedAirtime"); Define_Module(Hcf); @@ -369,6 +371,20 @@ void Hcf::transmissionComplete(Packet *packet, const PtrgetChannelOwner(); if (edcaf) { + // Account the on-air time of the just-transmitted unicast data/mgmt frame to its + // receiver, a-posteriori (so each retransmission is charged), for airtime-fair + // transmit scheduling in AirtimeFairnessQueue. Control frames and multicast are + // excluded; the duration is exact (computed from the selected mode and length). + if (auto dataOrMgmtHeader = dynamicPtrCast(header)) { + auto receiver = dataOrMgmtHeader->getReceiverAddress(); + if (!receiver.isMulticast()) { + auto mode = rateSelection->computeMode(packet, header, edcaf->getTxopProcedure()); + Ieee80211TransmittedAirtime info; + info.setReceiverAddress(receiver); + info.setAirtime(mode->getDuration(packet->getDataLength())); + emit(frameTransmittedAirtimeSignal, &info); + } + } frameSequenceHandler->transmissionComplete(); } else if (hcca->isOwning()) diff --git a/src/inet/linklayer/ieee80211/mac/coordinationfunction/Hcf.h b/src/inet/linklayer/ieee80211/mac/coordinationfunction/Hcf.h index 2060a4e3d2c..7804b714f9f 100644 --- a/src/inet/linklayer/ieee80211/mac/coordinationfunction/Hcf.h +++ b/src/inet/linklayer/ieee80211/mac/coordinationfunction/Hcf.h @@ -51,6 +51,7 @@ class INET_API Hcf : public ICoordinationFunction, public IFrameSequenceHandler: static simsignal_t edcaCollisionDetectedSignal; static simsignal_t blockAckAgreementAddedSignal; static simsignal_t blockAckAgreementDeletedSignal; + static simsignal_t frameTransmittedAirtimeSignal; protected: Ieee80211Mac *mac = nullptr; diff --git a/src/inet/linklayer/ieee80211/mac/coordinationfunction/Hcf.ned b/src/inet/linklayer/ieee80211/mac/coordinationfunction/Hcf.ned index 165edc93513..6cb51e9ac13 100644 --- a/src/inet/linklayer/ieee80211/mac/coordinationfunction/Hcf.ned +++ b/src/inet/linklayer/ieee80211/mac/coordinationfunction/Hcf.ned @@ -51,6 +51,7 @@ module Hcf extends Module like IHcf @signal[datarateSelected](type=double); @signal[blockAckAgreementAdded]; @signal[blockAckAgreementDeleted]; + @signal[frameTransmittedAirtime](type=inet::ieee80211::Ieee80211TransmittedAirtime); // receiver + on-air time, once per transmitted frame @statistic[packetSentToPeer](title="packets sent"; record=count,sum(packetBytes),vector(packetBytes); interpolationmode=none); @statistic[packetSentToPeerUnicast](title="packets sent: unicast"; source=ieee80211Unicast(packetSentToPeer); record=count,sum(packetBytes),vector(packetBytes); interpolationmode=none); @statistic[packetSentToPeerMulticast](title="packets sent: multicast"; source=ieee80211Multicast(packetSentToPeer); record=count,sum(packetBytes),vector(packetBytes); interpolationmode=none); From 93b7c8be34f63817862fbffde28162df0845554c Mon Sep 17 00:00:00 2001 From: Gyorgy Szaszko Date: Wed, 12 Aug 2026 15:42:33 +0200 Subject: [PATCH 11/14] ieee80211: add the airtime-fairness gate and deficit round-robin scheduler The two halves of a per-station airtime-fair transmit scheduler, modelled on the Linux mac80211 airtime fairness feature. They form a matched pair: ~AirtimeFairnessGate sits on one station's transmit path and owns that station's airtime deficit and its open/closed eligibility. It learns which receiver it serves from the first frame that passes through it, and subscribes to frameTransmittedAirtime on the containing network interface to charge the deficit from the frame's actual on-air time. This mirrors the way a ~PeriodicGate gates one sub-queue in a ~GatingPriorityQueue, except that eligibility is driven by consumed airtime instead of by a schedule. ~AirtimeFairnessScheduler owns the rotation: it visits the gates in round-robin order, serves the first backlogged station whose gate is still open, and grants an ineligible-but-backlogged station one quantum * weight of credit before moving on. In fair mode the cursor stays on the served station so it drains its whole airtime quantum -- which is what distinguishes airtime fairness from frame fairness when one station's frames take much longer on the air. It stays there only while the station's deficit actually moves between visits, though: a station that is never charged -- a group-address branch, group frames being deliberately unreported since they are not attributable to a single peer, or a station whose dequeued frame was dropped instead of transmitted -- would otherwise park the cursor and starve every other station. A station that is not being billed gets frame fairness rather than the whole medium. Two consequences of gating a queue this way needed explicit handling. A closed gate would hide its backlog from the generic pull interface, so the gate reports the true upstream backlog through ~IPacketCollection regardless of its state, and forwards backlog-change notifications even while closed -- a station can become backlogged while out of credit, and the scheduler must still learn about it in order to top it up. For the same reason the scheduler reports a pullable packet whenever any station is backlogged, even when every gate is momentarily shut, since it can always grant credit. Stations come and go, so the gates are wired to the scheduler as the receivers first appear (by the ~DynamicClassifier of the enclosing queue), not from the NED topology. The scheduler picks such an input up by listening for the POST_MODEL_CHANGE notification of its own input gate being connected, so nothing has to call into it and no contract is needed between it and whatever builds the branch; it finds the airtime gate of the new branch at the far end of the connection path arriving at its input, which may cross a branch compound's boundary. It deliberately does not notify its downstream collector at that point: the branch is still empty and its modules are not initialized yet, and the frame whose arrival created the station notifies through the branch a moment later anyway. With fairnessEnabled = false the gates stay open and the pair degrades to a plain per-station round robin, which is the frame-fair baseline to contrast against. --- .../mac/queue/AirtimeFairnessGate.cc | 145 +++++++++++++ .../ieee80211/mac/queue/AirtimeFairnessGate.h | 121 +++++++++++ .../mac/queue/AirtimeFairnessGate.ned | 47 +++++ .../mac/queue/AirtimeFairnessScheduler.cc | 192 ++++++++++++++++++ .../mac/queue/AirtimeFairnessScheduler.h | 110 ++++++++++ .../mac/queue/AirtimeFairnessScheduler.ned | 37 ++++ 6 files changed, 652 insertions(+) create mode 100644 src/inet/linklayer/ieee80211/mac/queue/AirtimeFairnessGate.cc create mode 100644 src/inet/linklayer/ieee80211/mac/queue/AirtimeFairnessGate.h create mode 100644 src/inet/linklayer/ieee80211/mac/queue/AirtimeFairnessGate.ned create mode 100644 src/inet/linklayer/ieee80211/mac/queue/AirtimeFairnessScheduler.cc create mode 100644 src/inet/linklayer/ieee80211/mac/queue/AirtimeFairnessScheduler.h create mode 100644 src/inet/linklayer/ieee80211/mac/queue/AirtimeFairnessScheduler.ned diff --git a/src/inet/linklayer/ieee80211/mac/queue/AirtimeFairnessGate.cc b/src/inet/linklayer/ieee80211/mac/queue/AirtimeFairnessGate.cc new file mode 100644 index 00000000000..cad86798bbb --- /dev/null +++ b/src/inet/linklayer/ieee80211/mac/queue/AirtimeFairnessGate.cc @@ -0,0 +1,145 @@ +// +// Copyright (C) 2026 OpenSim Ltd. +// +// SPDX-License-Identifier: LGPL-3.0-or-later +// + + +#include "inet/linklayer/ieee80211/mac/queue/AirtimeFairnessGate.h" + +#include "inet/common/ModuleAccess.h" +#include "inet/linklayer/ieee80211/mac/Ieee80211Frame_m.h" +#include "inet/linklayer/ieee80211/mac/common/Ieee80211TransmittedAirtime_m.h" +#include "inet/networklayer/common/NetworkInterface.h" + +namespace inet { +namespace ieee80211 { + +Define_Module(AirtimeFairnessGate); + +simsignal_t AirtimeFairnessGate::deficitChangedSignal = cComponent::registerSignal("deficitChanged"); + +void AirtimeFairnessGate::initialize(int stage) +{ + PacketGateBase::initialize(stage); + if (stage == INITSTAGE_LOCAL) { + quantum = par("quantum"); + weight = par("weight"); + fairnessEnabled = par("fairnessEnabled"); + if (quantum <= SIMTIME_ZERO) + throw cRuntimeError("The quantum parameter must be positive"); + if (weight <= 0) + throw cRuntimeError("The weight parameter must be positive"); + frameTransmittedAirtimeSignal = registerSignal("frameTransmittedAirtime"); + isOpen_ = isEligible(); // deficit starts at zero -> eligible -> open + WATCH(stationAddress); + WATCH(deficit); + } + else if (stage == INITSTAGE_LINK_LAYER) + // The coordination function (Dcf/Hcf) emits frameTransmittedAirtime from within the + // containing network interface; subscribing there scopes the accounting to this + // interface's transmissions (the frame's receiver disambiguates the stations). + getContainingNicModule(this)->subscribe(frameTransmittedAirtimeSignal, this); +} + +void AirtimeFairnessGate::processPacket(Packet *packet) +{ + PacketGateBase::processPacket(packet); + if (stationAddress.isUnspecified()) { + const auto& header = packet->peekAtFront(); + stationAddress = header->getReceiverAddress(); + } +} + +void AirtimeFairnessGate::setDeficit(simtime_t value) +{ + if (deficit != value) { + deficit = value; + emit(deficitChangedSignal, deficit.dbl()); + } +} + +void AirtimeFairnessGate::addQuantum() +{ + Enter_Method("addQuantum"); + setDeficit(deficit + quantum * weight); + updateGateState(); +} + +void AirtimeFairnessGate::updateGateState() +{ + bool eligible = isEligible(); + if (eligible && isClosed()) + open(); + else if (!eligible && isOpen()) + close(); +} + +bool AirtimeFairnessGate::isBacklogged() const +{ + return provider != nullptr && provider.canPullSomePacket(); +} + +Packet *AirtimeFairnessGate::peekPacket() const +{ + return provider != nullptr ? provider.canPullPacket() : nullptr; +} + +void AirtimeFairnessGate::handleCanPullPacketChanged(const cGate *gate) +{ + Enter_Method("handleCanPullPacketChanged"); + // Forward even while the gate is closed: a station can become backlogged while its + // deficit is negative (gate shut), and the scheduler must still learn about it so it + // can top the station up and eventually serve it. PacketGateBase would swallow this + // notification while the gate is closed. + if (collector != nullptr) + collector.handleCanPullPacketChanged(); +} + +int AirtimeFairnessGate::getNumPackets() const +{ + return queueing::PacketFlowBase::getNumPackets(); +} + +b AirtimeFairnessGate::getTotalLength() const +{ + return queueing::PacketFlowBase::getTotalLength(); +} + +Packet *AirtimeFairnessGate::getPacket(int index) const +{ + return queueing::PacketFlowBase::getPacket(index); +} + +bool AirtimeFairnessGate::isEmpty() const +{ + return queueing::PacketFlowBase::isEmpty(); +} + +void AirtimeFairnessGate::removePacket(Packet *packet) +{ + queueing::PacketFlowBase::removePacket(packet); +} + +void AirtimeFairnessGate::removeAllPackets() +{ + queueing::PacketFlowBase::removeAllPackets(); +} + +void AirtimeFairnessGate::receiveSignal(cComponent *source, simsignal_t signalID, cObject *object, cObject *details) +{ + if (signalID == frameTransmittedAirtimeSignal) { + Enter_Method("%s", cComponent::getSignalName(signalID)); + auto info = check_and_cast(object); + // charge only frames sent to the station this gate serves + if (!stationAddress.isUnspecified() && info->getReceiverAddress() == stationAddress) { + setDeficit(deficit - info->getAirtime()); + EV_DEBUG << "Charged " << info->getAirtime() << " airtime to " << stationAddress + << ", deficit now " << deficit << EV_ENDL; + updateGateState(); // may close the gate if the deficit went negative + } + } +} + +} // namespace ieee80211 +} // namespace inet diff --git a/src/inet/linklayer/ieee80211/mac/queue/AirtimeFairnessGate.h b/src/inet/linklayer/ieee80211/mac/queue/AirtimeFairnessGate.h new file mode 100644 index 00000000000..9a7d6e6b31c --- /dev/null +++ b/src/inet/linklayer/ieee80211/mac/queue/AirtimeFairnessGate.h @@ -0,0 +1,121 @@ +// +// Copyright (C) 2026 OpenSim Ltd. +// +// SPDX-License-Identifier: LGPL-3.0-or-later +// + + +#ifndef __INET_AIRTIMEFAIRNESSGATE_H +#define __INET_AIRTIMEFAIRNESSGATE_H + +#include "inet/linklayer/common/MacAddress.h" +#include "inet/queueing/base/PacketGateBase.h" + +namespace inet { +namespace ieee80211 { + +/** + * A per-station airtime-fairness gate for the ~AirtimeFairnessQueue compound queue. + * One gate sits on the transmit path of exactly one receiver (destination station); + * it holds that station's airtime `deficit` and is *open* while the station is + * eligible to transmit (deficit >= 0) and *closed* otherwise. This mirrors the way a + * ~PeriodicGate gates one sub-queue in a ~GatingPriorityQueue, except that eligibility + * is driven by on-air time rather than by a schedule. + * + * The deficit is charged a-posteriori from the actual on-air duration of each + * transmitted frame (retries included), reported by the coordination function + * (~Dcf/~Hcf) via the `frameTransmittedAirtime` signal (see ~Ieee80211TransmittedAirtime). The + * gate learns which station it serves from the receiver address of the first frame that + * passes through it, so that later airtime charges can be matched to it. + * + * The gate forms a matched pair with ~AirtimeFairnessScheduler: the scheduler owns the + * deficit round-robin rotation and tops up an ineligible-but-backlogged gate by one + * `quantum * weight` when its turn comes (see addQuantum()); the gate owns the deficit + * state and the open/closed eligibility. Because a closed gate hides its backlog from + * the generic pull interface, the gate exposes isBacklogged()/peekPacket() for the + * scheduler and forwards backlog-change notifications even while closed. + * + * With `fairnessEnabled = false` the gate stays permanently open, degrading the compound + * queue to a plain per-station round robin (the frame-fair anomaly baseline). + * + * See the corresponding NED file for more details. + * + * @see AirtimeFairnessQueue, AirtimeFairnessScheduler, Ieee80211TransmittedAirtime + */ +class INET_API AirtimeFairnessGate : public queueing::PacketGateBase, public cListener +{ + public: + static simsignal_t deficitChangedSignal; + + protected: + // parameters + simtime_t quantum; // airtime top-up granted per round-robin turn (scaled by weight) + double weight = 1.0; // per-station airtime weight; equal weight means equal airtime share + bool fairnessEnabled = true; // when false the gate stays open (plain round robin) + + // state + MacAddress stationAddress; // receiver this gate serves; learned from the first frame passing through + simtime_t deficit = SIMTIME_ZERO; // current airtime deficit; the gate is open while this is non-negative + + simsignal_t frameTransmittedAirtimeSignal = SIMSIGNAL_NULL; + + protected: + virtual void initialize(int stage) override; + virtual void processPacket(Packet *packet) override; + + virtual bool isEligible() const { return !fairnessEnabled || deficit >= SIMTIME_ZERO; } + virtual void updateGateState(); + virtual void setDeficit(simtime_t value); + + public: + /** @name Matched-pair interface used by ~AirtimeFairnessScheduler */ + //@{ + /** + * Returns the station's current airtime deficit; a non-negative value means the + * station is eligible to transmit. + */ + virtual simtime_t getDeficit() const { return deficit; } + /** + * Grants one round-robin quantum (`quantum * weight`) of airtime credit and reopens + * the gate if the deficit thereby became non-negative. + */ + virtual void addQuantum(); + /** + * Returns whether the upstream sub-queue holds a frame, regardless of whether this + * gate is currently open or closed. + */ + virtual bool isBacklogged() const; + /** + * Returns the head frame of the upstream sub-queue (regardless of open/closed), or + * nullptr if the sub-queue is empty. + */ + virtual Packet *peekPacket() const; + /** + * Returns the receiver address this gate serves, or an unspecified address until the + * first frame has passed through. + */ + virtual const MacAddress& getStationAddress() const { return stationAddress; } + //@} + + virtual void handleCanPullPacketChanged(const cGate *gate) override; + + virtual void receiveSignal(cComponent *source, simsignal_t signalID, cObject *object, cObject *details) override; + + /** @name IPacketCollection: report the true upstream backlog even while the gate is closed */ + //@{ + // PacketGateBase hides the backlog (reports 0) while closed; here the gate is only an + // airtime-eligibility valve, so the enclosing compound queue must still see the frames + // buffered behind it for correct queue-length and capacity accounting. + virtual int getNumPackets() const override; + virtual b getTotalLength() const override; + virtual Packet *getPacket(int index) const override; + virtual bool isEmpty() const override; + virtual void removePacket(Packet *packet) override; + virtual void removeAllPackets() override; + //@} +}; + +} // namespace ieee80211 +} // namespace inet + +#endif diff --git a/src/inet/linklayer/ieee80211/mac/queue/AirtimeFairnessGate.ned b/src/inet/linklayer/ieee80211/mac/queue/AirtimeFairnessGate.ned new file mode 100644 index 00000000000..72c48ac9a93 --- /dev/null +++ b/src/inet/linklayer/ieee80211/mac/queue/AirtimeFairnessGate.ned @@ -0,0 +1,47 @@ +// +// Copyright (C) 2026 OpenSim Ltd. +// +// SPDX-License-Identifier: LGPL-3.0-or-later +// + + +package inet.linklayer.ieee80211.mac.queue; + +import inet.queueing.base.PacketGateBase; +import inet.queueing.contract.IPacketGate; + +// +// A per-station airtime-fairness gate for the ~AirtimeFairnessQueue compound queue. One +// gate sits on the transmit path of exactly one receiver (destination station); it holds +// that station's airtime deficit and is open while the station is eligible to transmit +// (deficit >= 0) and closed otherwise. This mirrors the way a ~PeriodicGate gates one +// sub-queue in a ~GatingPriorityQueue, except that eligibility is driven by on-air time +// rather than by a schedule. +// +// The deficit is charged a-posteriori from the actual on-air duration of each transmitted +// frame (retries included), reported by the coordination function (~Dcf/~Hcf) via the +// `frameTransmittedAirtime` signal (see ~Ieee80211TransmittedAirtime). The gate learns which +// station it serves from the receiver address of the first frame that passes through it. +// +// The gate forms a matched pair with ~AirtimeFairnessScheduler: the scheduler owns the +// deficit round-robin rotation and tops up an ineligible-but-backlogged gate by one +// `quantum * weight` when its turn comes; the gate owns the deficit state and the +// open/closed eligibility. +// +// With `fairnessEnabled = false` the gate stays permanently open, degrading the compound +// queue to a plain per-station round robin (the frame-fair anomaly baseline). +// +// @see ~AirtimeFairnessQueue, ~AirtimeFairnessScheduler, ~DynamicClassifier +// +simple AirtimeFairnessGate extends PacketGateBase like IPacketGate +{ + parameters: + double quantum @unit(s) = default(1500us); // airtime top-up granted per round-robin turn (scaled by weight); ~one max-length frame's airtime + double weight = default(1); // per-station airtime weight; equal weight means equal airtime share + bool fairnessEnabled = default(true); // when false the gate stays open (plain per-station round robin) + displayStringTextFormat = default("deficit {deficit}\npassed %p pk (%l)"); + @class(AirtimeFairnessGate); + @signal[deficitChanged](type=double); + // the statistical value is the current airtime deficit of the served station + @statistic[deficit](title="airtime deficit"; source=deficitChanged; record=vector; unit=s; interpolationmode=sample-hold); +} diff --git a/src/inet/linklayer/ieee80211/mac/queue/AirtimeFairnessScheduler.cc b/src/inet/linklayer/ieee80211/mac/queue/AirtimeFairnessScheduler.cc new file mode 100644 index 00000000000..03eed1754a6 --- /dev/null +++ b/src/inet/linklayer/ieee80211/mac/queue/AirtimeFairnessScheduler.cc @@ -0,0 +1,192 @@ +// +// Copyright (C) 2026 OpenSim Ltd. +// +// SPDX-License-Identifier: LGPL-3.0-or-later +// + + +#include "inet/linklayer/ieee80211/mac/queue/AirtimeFairnessScheduler.h" + +#include "inet/linklayer/ieee80211/mac/queue/AirtimeFairnessGate.h" + +namespace inet { +namespace ieee80211 { + +Define_Module(AirtimeFairnessScheduler); + +void AirtimeFairnessScheduler::initialize(int stage) +{ + PacketSchedulerBase::initialize(stage); + if (stage == INITSTAGE_LOCAL) { + fairnessEnabled = par("fairnessEnabled"); + resolveGates(); + // The per-station branches are wired up as the stations appear (see + // ~DynamicClassifier), so watch for an input gate of ours being connected. Only + // connections made after this point are reported; the ones coming from the NED + // topology are already in inputGates. + subscribe(POST_MODEL_CHANGE, this); + WATCH(cursor); + } +} + +void AirtimeFairnessScheduler::receiveSignal(cComponent *source, simsignal_t signalID, cObject *object, cObject *details) +{ + if (signalID == POST_MODEL_CHANGE) { + // A connection ending at one of our input gates was just created; the notification + // is fired on the module owning the path end gate, that is, on us. + if (auto notification = dynamic_cast(object)) { + cGate *inputGate = notification->pathEndGate; + if (inputGate->getOwnerModule() == this && !strcmp(inputGate->getBaseName(), "in")) + addInput(inputGate); + } + } +} + +void AirtimeFairnessScheduler::resolveGates() +{ + // Resolve the AirtimeFairnessGate feeding each input gate (matched pair). The gate is at + // the far end of the connection path (branch[i].gate.out --> ... --> in[i]), which is not + // the same as the previous gate: the branch is a compound module. + gates.resize(inputGates.size()); + for (size_t i = 0; i < inputGates.size(); i++) + gates[i] = check_and_cast(inputGates[i]->getPathStartGate()->getOwnerModule()); +} + +void AirtimeFairnessScheduler::addInput(cGate *inputGate) +{ + Enter_Method("addInput"); + inputGates.push_back(inputGate); + queueing::PassivePacketSourceRef provider; + provider.reference(inputGate, false); + providers.push_back(provider); + queueing::ActivePacketSourceRef producer; + producer.reference(inputGate, false); + producers.push_back(producer); + gates.push_back(check_and_cast(inputGate->getPathStartGate()->getOwnerModule())); + checkPacketOperationSupport(inputGate); + // The downstream is deliberately not notified here: the branch is still empty (and its + // modules are not initialized yet, so it must not be looked into), and the frame whose + // arrival created the station will notify through the branch a moment later anyway. +} + +int AirtimeFairnessScheduler::schedulePacket() +{ + int n = (int)inputGates.size(); + if (n == 0) + throw cRuntimeError("The scheduler has no input gates"); + // Deficit round robin: starting at the cursor, find the next backlogged station whose + // gate is still eligible (open), topping up ineligible-but-backlogged stations by one + // quantum as we pass them. Guaranteed to terminate because quantum * weight > 0 and at + // least one station is backlogged whenever pullPacket is called. + int emptyStreak = 0; + while (true) { + int index = cursor % n; + auto gate = getGate(index); + if (gate->isBacklogged()) { + emptyStreak = 0; + if (!fairnessEnabled || gate->getDeficit() >= SIMTIME_ZERO) { + // Eligible -> serve this station. Airtime-fair keeps the cursor on it so it + // drains its whole airtime quantum (many small frames) before yielding; the + // async airtime charge lands before the next pull and eventually closes its + // gate, at which point it is topped up and rotated. Frame-fair rotates now. + // + // Pinning the cursor is only safe while the station is actually being charged + // for what it sends. Serving it again without its deficit having moved means + // the previous frame was never billed -- group-addressed frames are not + // reported by ~Dcf/~Hcf, and neither is a frame that is dequeued but never + // transmitted -- and holding the cursor there would starve every other + // station forever. Rotate away instead, so an unbilled station gets frame + // fairness rather than the whole medium. + bool charged = index != lastServedIndex || gate->getDeficit() < lastServedDeficit; + cursor = fairnessEnabled && charged ? index : (index + 1) % n; + lastServedIndex = index; + lastServedDeficit = gate->getDeficit(); + return index; + } + else { + // Backlogged but out of airtime credit -> grant one quantum and move on. + gate->addQuantum(); + cursor = (index + 1) % n; + } + } + else { + cursor = (index + 1) % n; + if (++emptyStreak >= n) + throw cRuntimeError("No backlogged input gate available to schedule a packet"); + } + } +} + +bool AirtimeFairnessScheduler::canPullSomePacket(const cGate *gate) const +{ + // A packet is pullable whenever any station is backlogged, even if all gates are + // momentarily closed (negative deficit): the scheduler can always top a station up. + for (int i = 0; i < (int)inputGates.size(); i++) + if (getGate(i)->isBacklogged()) + return true; + return false; +} + +Packet *AirtimeFairnessScheduler::canPullPacket(const cGate *gate) const +{ + for (int i = 0; i < (int)inputGates.size(); i++) { + auto g = getGate(i); + if (g->isBacklogged()) + return g->peekPacket(); + } + return nullptr; +} + +int AirtimeFairnessScheduler::getNumPackets() const +{ + int size = 0; + for (auto gate : gates) + size += gate->getNumPackets(); + return size; +} + +b AirtimeFairnessScheduler::getTotalLength() const +{ + b totalLength(0); + for (auto gate : gates) + totalLength += gate->getTotalLength(); + return totalLength; +} + +Packet *AirtimeFairnessScheduler::getPacket(int index) const +{ + int origIndex = index; + for (auto gate : gates) { + int numPackets = gate->getNumPackets(); + if (index < numPackets) + return gate->getPacket(index); + else + index -= numPackets; + } + throw cRuntimeError("Index %i out of range", origIndex); +} + +void AirtimeFairnessScheduler::removePacket(Packet *packet) +{ + Enter_Method("removePacket"); + for (auto gate : gates) { + int numPackets = gate->getNumPackets(); + for (int j = 0; j < numPackets; j++) { + if (gate->getPacket(j) == packet) { + gate->removePacket(packet); + return; + } + } + } + throw cRuntimeError("Cannot find packet"); +} + +void AirtimeFairnessScheduler::removeAllPackets() +{ + Enter_Method("removeAllPackets"); + for (auto gate : gates) + gate->removeAllPackets(); +} + +} // namespace ieee80211 +} // namespace inet diff --git a/src/inet/linklayer/ieee80211/mac/queue/AirtimeFairnessScheduler.h b/src/inet/linklayer/ieee80211/mac/queue/AirtimeFairnessScheduler.h new file mode 100644 index 00000000000..b19bd5ec092 --- /dev/null +++ b/src/inet/linklayer/ieee80211/mac/queue/AirtimeFairnessScheduler.h @@ -0,0 +1,110 @@ +// +// Copyright (C) 2026 OpenSim Ltd. +// +// SPDX-License-Identifier: LGPL-3.0-or-later +// + + +#ifndef __INET_AIRTIMEFAIRNESSSCHEDULER_H +#define __INET_AIRTIMEFAIRNESSSCHEDULER_H + +#include + +#include "inet/queueing/base/PacketSchedulerBase.h" +#include "inet/queueing/contract/IPacketCollection.h" + +namespace inet { +namespace ieee80211 { + +class AirtimeFairnessGate; + +/** + * The scheduling half of the ~AirtimeFairnessQueue compound queue; the counterpart of + * ~AirtimeFairnessGate. It runs a deficit round robin (DRR) over the per-station gates, + * as in Linux `mac80211` airtime fairness: it visits the gates in round-robin order and + * serves the first backlogged station that is still eligible (its gate is open, i.e. its + * airtime deficit is non-negative); an ineligible-but-backlogged station is granted one + * `quantum * weight` of airtime credit (see AirtimeFairnessGate::addQuantum()) and the + * cursor moves on. Over time every backlogged station gets an equal share of on-air time. + * + * The airtime deficit itself lives in the gates, charged a-posteriori from the actual + * on-air duration of each transmitted frame; this scheduler only owns the rotation and + * the top-up trigger, so the two form a matched pair. Because a closed gate hides its + * backlog from the generic pull interface, the scheduler consults the gates through their + * typed interface (isBacklogged()/getDeficit()/peekPacket()) rather than the plain + * provider references, and reports a pullable packet whenever *any* station is backlogged + * (even if every gate is momentarily closed) since it can always top a station up. + * + * Stations come and go, so the input gates are not all known at network setup time: the + * ~DynamicClassifier of the enclosing queue wires up a new branch as each receiver first + * appears. The scheduler picks such an input up by listening for the model change + * notification of its own input gate being connected, rather than by being called into. + * + * Like the other schedulers used inside a compound queue, it also implements + * ~IPacketCollection, aggregating the per-station backlog so the enclosing + * ~CompoundPacketQueueBase can report queue length and enforce the shared capacity. + * + * With `fairnessEnabled = false` the gates stay open and this degrades to a plain + * per-station round robin (the frame-fair anomaly baseline). + * + * See the corresponding NED file for more details. + * + * @see AirtimeFairnessQueue, AirtimeFairnessGate + */ +class INET_API AirtimeFairnessScheduler : public queueing::PacketSchedulerBase, public virtual queueing::IPacketCollection, public cListener +{ + protected: + // parameters + bool fairnessEnabled = true; // when false, degrades to a plain per-station round robin + + // state + int cursor = 0; // round-robin position: the input gate index considered first + int lastServedIndex = -1; // input gate index served by the previous schedulePacket() call + simtime_t lastServedDeficit = SIMTIME_ZERO; // deficit of that station when it was served + std::vector gates; // the gate feeding each input gate, parallel to inputGates + + protected: + virtual void initialize(int stage) override; + virtual int schedulePacket() override; + virtual void resolveGates(); + + virtual AirtimeFairnessGate *getGate(int index) const { return gates[index]; } + + /** + * Takes an input gate connected at runtime (a per-station branch created on demand by + * ~DynamicClassifier) into the rotation: appends the provider/producer references and + * the matched gate. + */ + virtual void addInput(cGate *inputGate); + + public: + /** + * Notices this scheduler's own input gates being connected at runtime; see addInput(). + */ + virtual void receiveSignal(cComponent *source, simsignal_t signalID, cObject *object, cObject *details) override; + + /** @name Pull interface */ + //@{ + virtual bool canPullSomePacket(const cGate *gate) const override; + virtual Packet *canPullPacket(const cGate *gate) const override; + //@} + + /** @name IPacketCollection: aggregate per-station backlog for the compound queue */ + //@{ + virtual int getMaxNumPackets() const override { return -1; } + virtual int getNumPackets() const override; + + virtual b getMaxTotalLength() const override { return b(-1); } + virtual b getTotalLength() const override; + + virtual bool isEmpty() const override { return getNumPackets() == 0; } + virtual Packet *getPacket(int index) const override; + virtual void removePacket(Packet *packet) override; + virtual void removeAllPackets() override; + //@} +}; + +} // namespace ieee80211 +} // namespace inet + +#endif diff --git a/src/inet/linklayer/ieee80211/mac/queue/AirtimeFairnessScheduler.ned b/src/inet/linklayer/ieee80211/mac/queue/AirtimeFairnessScheduler.ned new file mode 100644 index 00000000000..29289758c47 --- /dev/null +++ b/src/inet/linklayer/ieee80211/mac/queue/AirtimeFairnessScheduler.ned @@ -0,0 +1,37 @@ +// +// Copyright (C) 2026 OpenSim Ltd. +// +// SPDX-License-Identifier: LGPL-3.0-or-later +// + + +package inet.linklayer.ieee80211.mac.queue; + +import inet.queueing.base.PacketSchedulerBase; +import inet.queueing.contract.IPacketScheduler; + +// +// The scheduling half of the ~AirtimeFairnessQueue compound queue; the counterpart of +// ~AirtimeFairnessGate. It runs a deficit round robin over the per-station gates, as in +// Linux `mac80211` airtime fairness: it serves the first backlogged station whose gate is +// still open (airtime deficit non-negative), and grants an ineligible-but-backlogged +// station one `quantum * weight` of airtime credit before moving the cursor on. Over time +// every backlogged station gets an equal share of on-air time. +// +// The airtime deficit lives in the gates (charged a-posteriori from actual on-air time); +// this scheduler owns only the rotation and the top-up trigger, so the two form a matched +// pair and the scheduler must be wired to ~AirtimeFairnessGate inputs. Those inputs are +// wired up as the stations appear, so the scheduler also takes an input gate of its own +// being connected at runtime as a new station joining the rotation. +// +// With `fairnessEnabled = false` the gates stay open and this degrades to a plain +// per-station round robin (the frame-fair anomaly baseline). +// +// @see ~AirtimeFairnessQueue, ~AirtimeFairnessGate, ~DynamicClassifier +// +simple AirtimeFairnessScheduler extends PacketSchedulerBase like IPacketScheduler +{ + parameters: + bool fairnessEnabled = default(true); // when false, degrades to a plain per-station round robin + @class(AirtimeFairnessScheduler); +} From e7ea5b994722d5d34bf0a2913f667a5a5e37e7d5 Mon Sep 17 00:00:00 2001 From: Gyorgy Szaszko Date: Thu, 6 Aug 2026 14:58:43 +0200 Subject: [PATCH 12/14] ieee80211: add the receiver-address classifier and drop-from-longest policy Two small registered functions that an airtime-fair queue needs to divide traffic into per-station branches and to stay fair under overload. Ieee80211ReceiverAddressClassifier assigns a dense class index per receiver MAC address in first-seen order, so a dynamic classifier can open one branch per destination station. Ieee80211LongestFlowDropper is the overflow policy: it drops the tail frame of the station with the most queued frames, rather than the frame that has just arrived. Under a shared capacity this matters -- a slow station drains slowly and would otherwise fill the whole queue and lock the other stations out, defeating the fairness the scheduler provides. It is the drop-from-longest rule used by FQ-CoDel, for the same reason. --- .../mac/queue/Ieee80211LongestFlowDropper.cc | 50 +++++++++++++++++++ .../Ieee80211ReceiverAddressClassifier.cc | 44 ++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 src/inet/linklayer/ieee80211/mac/queue/Ieee80211LongestFlowDropper.cc create mode 100644 src/inet/linklayer/ieee80211/mac/queue/Ieee80211ReceiverAddressClassifier.cc diff --git a/src/inet/linklayer/ieee80211/mac/queue/Ieee80211LongestFlowDropper.cc b/src/inet/linklayer/ieee80211/mac/queue/Ieee80211LongestFlowDropper.cc new file mode 100644 index 00000000000..fa0b49c22b1 --- /dev/null +++ b/src/inet/linklayer/ieee80211/mac/queue/Ieee80211LongestFlowDropper.cc @@ -0,0 +1,50 @@ +// +// Copyright (C) 2026 OpenSim Ltd. +// +// SPDX-License-Identifier: LGPL-3.0-or-later +// + + +#include + +#include "inet/linklayer/common/MacAddress.h" +#include "inet/linklayer/ieee80211/mac/Ieee80211Frame_m.h" +#include "inet/queueing/contract/IPacketCollection.h" +#include "inet/queueing/function/PacketDropperFunction.h" + +namespace inet { +namespace ieee80211 { + +/** + * The overflow policy for the ~AirtimeFairnessQueue compound queue. When the shared + * capacity is exceeded it drops from the station (receiver) with the most queued frames: + * the tail frame of the longest per-station backlog. This caps a slow station whose + * slowly-draining backlog would otherwise fill the shared capacity and lock the other + * stations out (the FQ-CoDel drop-from-longest rule), rather than dropping the just-arrived + * frame -- which is what lets every station keep getting served under overload. + */ +static Packet *selectPacketFromLongestFlow(queueing::IPacketCollection *collection) +{ + int numPackets = collection->getNumPackets(); + std::map counts; + std::map lastPacket; // tail frame of each receiver, in collection order + for (int i = 0; i < numPackets; i++) { + auto packet = collection->getPacket(i); + MacAddress receiver = packet->peekAtFront()->getReceiverAddress(); + counts[receiver]++; + lastPacket[receiver] = packet; + } + MacAddress longest; + int most = 0; + for (auto& element : counts) + if (element.second > most) { + most = element.second; + longest = element.first; + } + return most > 0 ? lastPacket[longest] : nullptr; +} + +Register_Packet_Dropper_Function(Ieee80211LongestFlowDropper, selectPacketFromLongestFlow); + +} // namespace ieee80211 +} // namespace inet diff --git a/src/inet/linklayer/ieee80211/mac/queue/Ieee80211ReceiverAddressClassifier.cc b/src/inet/linklayer/ieee80211/mac/queue/Ieee80211ReceiverAddressClassifier.cc new file mode 100644 index 00000000000..a320d9481d9 --- /dev/null +++ b/src/inet/linklayer/ieee80211/mac/queue/Ieee80211ReceiverAddressClassifier.cc @@ -0,0 +1,44 @@ +// +// Copyright (C) 2026 OpenSim Ltd. +// +// SPDX-License-Identifier: LGPL-3.0-or-later +// + + +#include + +#include "inet/linklayer/common/MacAddress.h" +#include "inet/linklayer/ieee80211/mac/Ieee80211Frame_m.h" +#include "inet/queueing/contract/IPacketClassifierFunction.h" + +namespace inet { +namespace ieee80211 { + +/** + * A packet classifier function that classifies IEEE 802.11 frames by receiver (destination + * station) MAC address, assigning a dense class index in first-seen order. Used with + * ~DynamicClassifier to give one per-receiver branch (sub-queue + airtime gate) in + * ~AirtimeFairnessQueue. + */ +class INET_API Ieee80211ReceiverAddressClassifier : public cObject, public virtual queueing::IPacketClassifierFunction +{ + protected: + mutable std::map addressToIndex; // receiver MAC -> dense class index, first-seen order + + public: + virtual int classifyPacket(Packet *packet) const override + { + MacAddress address = packet->peekAtFront()->getReceiverAddress(); + auto it = addressToIndex.find(address); + if (it != addressToIndex.end()) + return it->second; + int index = (int)addressToIndex.size(); + addressToIndex[address] = index; + return index; + } +}; + +Register_Class(Ieee80211ReceiverAddressClassifier); + +} // namespace ieee80211 +} // namespace inet From 96619e2ab622a10595989be6c1e1fe38f29effef Mon Sep 17 00:00:00 2001 From: Gyorgy Szaszko Date: Wed, 12 Aug 2026 15:42:57 +0200 Subject: [PATCH 13/14] ieee80211: add AirtimeFairnessQueue, a per-station airtime-fair transmit queue Assembles the parts into a drop-in replacement for the pendingQueue of an ~Edcaf or ~Dcaf: a ~DynamicClassifier routes each frame to a per-receiver sub-queue, each sub-queue is followed by an ~AirtimeFairnessGate, and an ~AirtimeFairnessScheduler serves the gates so that every backlogged station gets an equal share of on-air time rather than an equal share of frames. This addresses the downlink form of the 802.11 rate anomaly: when an access point saturates a mix of fast and slow clients, a FIFO -- or even a frame-fair round robin -- lets the slow client's long frames drag the fast clients down toward its throughput, because a frame is a frame regardless of how long it occupies the medium. The per-station branches are created on demand as receivers appear, so the queue follows the set of stations an access point actually serves instead of requiring it to be declared up front. A branch is one ~PerStationAirtimeQueue compound (sub-queue -> gate) in the branch submodule vector; the scheduler finds the airtime gate of a branch at the far end of the connection path arriving at its input gate, across the compound boundary. The branch is created with its final name and index, so a single station can also be configured individually, as in branch[2].weight = 2, and its statistics are recorded under pendingQueue.branch[k].queue and pendingQueue.branch[k].gate. packetCapacity defaults to 100, the same limit as the ~PendingQueue this queue replaces: ~CompoundPacketQueueBase defaults it to -1, no limit, which would silently turn a 100-frame queue into an unbounded one where isOverloaded() never fires and the shared-capacity overflow policy is dead code. ~AirtimeFairnessCompoundQueue only publishes the number of per-station branches created so far as a numStations watch, for the queue's display string. The module test drives the airtime disparity with frame sizes rather than rates, which keeps it independent of per-receiver rate configuration: the access point saturates one station with long frames and two with short ones, all at 54 Mbps, and the short-frame stations must recover to well over 0.25x the long-frame station's bytes (frame fairness leaves them at 0.14x). The quantum is set fine enough (100us) for the deficit to bind on every frame, because the coordination function pulls the next frame before the previous frame's airtime is reported, so at the default quantum a whole burst passes between charges and the schedule degrades toward frame fairness. --- .../mac/queue/AirtimeFairnessCompoundQueue.cc | 30 ++++++ .../mac/queue/AirtimeFairnessCompoundQueue.h | 35 +++++++ .../mac/queue/AirtimeFairnessQueue.ned | 83 ++++++++++++++++ .../mac/queue/PerStationAirtimeQueue.ned | 43 ++++++++ tests/module/AirtimeFairnessQueue_1.test | 97 +++++++++++++++++++ 5 files changed, 288 insertions(+) create mode 100644 src/inet/linklayer/ieee80211/mac/queue/AirtimeFairnessCompoundQueue.cc create mode 100644 src/inet/linklayer/ieee80211/mac/queue/AirtimeFairnessCompoundQueue.h create mode 100644 src/inet/linklayer/ieee80211/mac/queue/AirtimeFairnessQueue.ned create mode 100644 src/inet/linklayer/ieee80211/mac/queue/PerStationAirtimeQueue.ned create mode 100644 tests/module/AirtimeFairnessQueue_1.test diff --git a/src/inet/linklayer/ieee80211/mac/queue/AirtimeFairnessCompoundQueue.cc b/src/inet/linklayer/ieee80211/mac/queue/AirtimeFairnessCompoundQueue.cc new file mode 100644 index 00000000000..3d5120be992 --- /dev/null +++ b/src/inet/linklayer/ieee80211/mac/queue/AirtimeFairnessCompoundQueue.cc @@ -0,0 +1,30 @@ +// +// Copyright (C) 2026 OpenSim Ltd. +// +// SPDX-License-Identifier: LGPL-3.0-or-later +// + + +#include "inet/linklayer/ieee80211/mac/queue/AirtimeFairnessCompoundQueue.h" + +#include "inet/common/InitStages.h" + +namespace inet { +namespace ieee80211 { + +Define_Module(AirtimeFairnessCompoundQueue); + +void AirtimeFairnessCompoundQueue::initialize(int stage) +{ + CompoundPacketQueueBase::initialize(stage); + if (stage == INITSTAGE_LOCAL) { + // Number of per-station branches created so far (= stations served). Watched on this + // module so the display string can use {numStations}; a cross-submodule reference like + // {classifier.numStations} would make ModuleMixin call getModuleByPath(), which throws + // in OMNeT++ 6 and breaks every Qtenv refreshDisplay (Cmdenv never evaluates it). + WATCH_EXPR("numStations", (int)getSubmoduleVectorSize("branch")); + } +} + +} // namespace ieee80211 +} // namespace inet diff --git a/src/inet/linklayer/ieee80211/mac/queue/AirtimeFairnessCompoundQueue.h b/src/inet/linklayer/ieee80211/mac/queue/AirtimeFairnessCompoundQueue.h new file mode 100644 index 00000000000..2d2156bc575 --- /dev/null +++ b/src/inet/linklayer/ieee80211/mac/queue/AirtimeFairnessCompoundQueue.h @@ -0,0 +1,35 @@ +// +// Copyright (C) 2026 OpenSim Ltd. +// +// SPDX-License-Identifier: LGPL-3.0-or-later +// + + +#ifndef __INET_AIRTIMEFAIRNESSCOMPOUNDQUEUE_H +#define __INET_AIRTIMEFAIRNESSCOMPOUNDQUEUE_H + +#include "inet/queueing/queue/CompoundPacketQueueBase.h" + +namespace inet { +namespace ieee80211 { + +/** + * The compound-module class behind the airtime-fairness compound queue. It only publishes the + * number of per-station branches created so far as a `numStations` watch, so the queue's + * display string can refer to it as {numStations}. + * + * See the corresponding NED file for the submodule structure. + * + * @see DynamicClassifier, AirtimeFairnessGate, AirtimeFairnessScheduler, + * Ieee80211LongestFlowDropper + */ +class INET_API AirtimeFairnessCompoundQueue : public queueing::CompoundPacketQueueBase +{ + protected: + virtual void initialize(int stage) override; +}; + +} // namespace ieee80211 +} // namespace inet + +#endif diff --git a/src/inet/linklayer/ieee80211/mac/queue/AirtimeFairnessQueue.ned b/src/inet/linklayer/ieee80211/mac/queue/AirtimeFairnessQueue.ned new file mode 100644 index 00000000000..2976680f945 --- /dev/null +++ b/src/inet/linklayer/ieee80211/mac/queue/AirtimeFairnessQueue.ned @@ -0,0 +1,83 @@ +// +// Copyright (C) 2026 OpenSim Ltd. +// +// SPDX-License-Identifier: LGPL-3.0-or-later +// + + +package inet.linklayer.ieee80211.mac.queue; + +import inet.queueing.classifier.DynamicClassifier; +import inet.queueing.contract.IPacketQueue; +import inet.queueing.queue.CompoundPacketQueueBase; + +// +// A per-station airtime deficit round-robin (DRR) transmit queue for IEEE 802.11, modelled +// on the Linux `mac80211` airtime-fairness feature. It is built as a compound queue: a +// classifier routes each frame to a per-receiver sub-queue, each sub-queue is followed by a +// per-station airtime gate, and a deficit-round-robin scheduler serves the gates so that, +// over time, every backlogged station gets an equal share of on-air *time* (scaled by an +// optional per-station weight) rather than an equal share of frames. +// +// The per-station branches (sub-queue + gate) are created on demand as new receivers +// appear, so the queue adapts to the dynamic set of stations an access point serves. The +// sub-queue type is configurable via `subqueueTypename`. +// +// This fixes the downlink form of the 802.11 rate anomaly: when an access point saturates a +// mix of fast and slow clients, a plain FIFO (or frame-fair round robin) lets the slow +// client's long frames drag the fast clients down to its throughput; airtime fairness holds +// the slow client to its fair *time* share and lets the fast clients recover. +// +// Each station's airtime deficit is charged a-posteriori from the actual on-air duration of +// its transmitted frames (retries included), reported by the coordination function via the +// `frameTransmittedAirtime` signal. A shared `packetCapacity` is enforced across all +// stations with a drop-from-longest overflow policy (see ~Ieee80211LongestFlowDropper), so a +// slow station's backlog cannot lock the others out. With `fairnessEnabled = false` the +// queue degrades to a plain per-station round robin (the frame-fair anomaly baseline). +// +// Drops into the `pendingQueue` slot of an ~Edcaf or ~Dcaf via a typename override, e.g.: +// *.host.wlan[*].mac.dcf.channelAccess.pendingQueue.typename = "AirtimeFairnessQueue" +// +// @see ~DynamicClassifier, ~AirtimeFairnessGate, ~AirtimeFairnessScheduler, +// ~Ieee80211LongestFlowDropper, ~PendingQueue +// +module AirtimeFairnessQueue extends CompoundPacketQueueBase +{ + parameters: + @class(AirtimeFairnessCompoundQueue); + double quantum @unit(s) = default(1500us); // base DRR quantum in airtime units; ~one max-length frame's airtime + double weight = default(1); // per-station airtime weight; equal weight means equal airtime share + bool fairnessEnabled = default(true); // when false, degrades to a plain per-station round robin (for OFF/ON contrast) + string subqueueTypename = default("inet.queueing.queue.PacketQueue"); // NED type of each per-station sub-queue + packetCapacity = default(100); // shared across all per-station sub-queues; same limit as ~PendingQueue, which this replaces + dropperClass = default("inet::ieee80211::Ieee80211LongestFlowDropper"); // on overflow, drop the tail frame of the longest per-station backlog + displayStringTextFormat = default("{numStations} stations\ncontains %p pk (%l) dropped %d"); + scheduler.fairnessEnabled = default(this.fairnessEnabled); + submodules: + // Generic dynamic classifier: routes by receiver MAC (Ieee80211ReceiverAddressClassifier) + // and creates a PerStationAirtimeQueue (queue -> gate) for each new station as branch[k], + // wiring it into the scheduler. + classifier: DynamicClassifier { + classifierClass = default("inet::ieee80211::Ieee80211ReceiverAddressClassifier"); + moduleType = default("inet.linklayer.ieee80211.mac.queue.PerStationAirtimeQueue"); + submoduleName = default("branch"); + aggregatorSubmoduleName = default("scheduler"); + @display("p=100,150"); + } + // Grown on demand, one sub-queue and airtime gate per receiver. The branch is created + // with its final name and index, so a station can also be configured individually, as + // in branch[2].weight = 2. + branch[0]: PerStationAirtimeQueue { + quantum = default(parent.quantum); + weight = default(parent.weight); + fairnessEnabled = default(parent.fairnessEnabled); + subqueueTypename = default(parent.subqueueTypename); + @display("p=400,150,column,80"); + } + scheduler: AirtimeFairnessScheduler { + @display("p=700,150"); + } + connections allowunconnected: + in --> { @display("m=w"); } --> classifier.in; + scheduler.out --> { @display("m=e"); } --> out; +} diff --git a/src/inet/linklayer/ieee80211/mac/queue/PerStationAirtimeQueue.ned b/src/inet/linklayer/ieee80211/mac/queue/PerStationAirtimeQueue.ned new file mode 100644 index 00000000000..df66f8c11ad --- /dev/null +++ b/src/inet/linklayer/ieee80211/mac/queue/PerStationAirtimeQueue.ned @@ -0,0 +1,43 @@ +// +// Copyright (C) 2026 OpenSim Ltd. +// +// SPDX-License-Identifier: LGPL-3.0-or-later +// + + +package inet.linklayer.ieee80211.mac.queue; + +import inet.queueing.contract.IPacketQueue; + +// +// One per-station branch of the ~AirtimeFairnessQueue: a FIFO sub-queue followed by an +// ~AirtimeFairnessGate. ~AirtimeFairnessQueue's ~DynamicClassifier instantiates one of these +// per receiver on demand, as `branch[k]`, and the ~AirtimeFairnessScheduler finds the gate of +// each branch at the far end of the connection into its own input gate. The +// `quantum`/`weight`/`fairnessEnabled`/`subqueueTypename` parameters default to those of the +// enclosing ~AirtimeFairnessQueue, and may also be set per station, as in `branch[2].weight`. +// +module PerStationAirtimeQueue like IPacketQueue +{ + parameters: + // the queue icon the other ~IPacketQueue modules carry (inherited from + // ~PacketQueueBase, which this one cannot extend, being a compound module) + @display("i=block/queue"); + double quantum @unit(s) = default(1500us); + double weight = default(1); + bool fairnessEnabled = default(true); + string subqueueTypename = default("inet.queueing.queue.PacketQueue"); + gate.quantum = default(this.quantum); + gate.weight = default(this.weight); + gate.fairnessEnabled = default(this.fairnessEnabled); + gates: + input in @labels(push); + output out @labels(pull); + submodules: + queue: like IPacketQueue; + gate: AirtimeFairnessGate; + connections: + in --> queue.in; + queue.out --> gate.in; + gate.out --> out; +} diff --git a/tests/module/AirtimeFairnessQueue_1.test b/tests/module/AirtimeFairnessQueue_1.test new file mode 100644 index 00000000000..75a33d15359 --- /dev/null +++ b/tests/module/AirtimeFairnessQueue_1.test @@ -0,0 +1,97 @@ +%description: +Tests that AirtimeFairnessQueue shares the access point's downlink airtime fairly +among stations instead of sharing frames fairly. + +An access point saturates three stations at the same 54 Mbps PHY rate; sta[0] +receives long (1400 B) frames while sta[1]/sta[2] receive short (200 B) ones, so +one frame to sta[0] occupies several times the airtime of a frame to the others. +A frame-fair round robin (fairnessEnabled = false) serves roughly equal frames +per station, which gives sta[0] about 7x the bytes of a short-frame station +(measured byte ratio 0.14). With airtime fairness, and a quantum fine enough for +the deficit to bind on every frame, the short-frame stations recover to well +over 0.25x of sta[0]'s bytes (measured 0.37), while every station keeps being +served. + +%#-------------------------------------------------------------------------------------------------------------- +%file: test.ned +import inet.networklayer.configurator.ipv4.Ipv4NetworkConfigurator; +import inet.node.ethernet.Eth100M; +import inet.node.inet.StandardHost; +import inet.node.inet.WirelessHost; +import inet.node.wireless.AccessPoint; +import inet.physicallayer.wireless.ieee80211.packetlevel.Ieee80211ScalarRadioMedium; + +network AirtimeFairnessTest +{ + parameters: + int numStations = default(3); + submodules: + configurator: Ipv4NetworkConfigurator; + radioMedium: Ieee80211ScalarRadioMedium; + server: StandardHost; + accessPoint: AccessPoint; + sta[numStations]: WirelessHost; + connections: + accessPoint.ethg++ <--> Eth100M <--> server.ethg++; +} + +%#-------------------------------------------------------------------------------------------------------------- +%inifile: omnetpp.ini +[General] +network = AirtimeFairnessTest +ned-path = .;../../../../src +sim-time-limit = 2.5s +warmup-period = 1s +cmdenv-express-mode = true +**.vector-recording = false + +*.radioMedium.sameTransmissionStartTimeCheck = "ignore" +*.*.ipv4.arp.typename = "GlobalArp" + +# equally good links, all stations ~8-9 m from the AP +*.sta[*].mobility.typename = "StationaryMobility" +**.mobility.initFromDisplayString = false +*.sta[*].mobility.initialX = 9m +*.sta[*].mobility.initialY = 8m + parentIndex() * 2m +*.sta[*].mobility.initialZ = 0m +*.accessPoint.mobility.initialX = 17m +*.accessPoint.mobility.initialY = 12m +*.accessPoint.mobility.initialZ = 0m + +# 802.11g, one shared rate for every frame +*.sta[*].wlan[*].opMode = "g(erp)" +*.accessPoint.wlan[*].opMode = "g(erp)" +*.sta[*].wlan[*].bitrate = 54Mbps +*.accessPoint.wlan[*].bitrate = 54Mbps +*.sta[*].wlan[*].radio.transmitter.power = 100mW +*.accessPoint.wlan[*].radio.transmitter.power = 100mW + +# saturating downlink: long frames to sta[0], short ones to sta[1]/sta[2] +*.server.numApps = 3 +*.server.app[*].typename = "UdpBasicApp" +*.server.app[*].destAddresses = "sta[" + string(index) + "]" +*.server.app[*].destPort = 5000 + index +*.server.app[*].messageLength = index == 0 ? 1400B : 200B +*.server.app[*].sendInterval = index == 0 ? 0.3ms : 0.1ms +*.sta[*].numApps = 1 +*.sta[*].app[0].typename = "UdpSink" +*.sta[*].app[0].localPort = 5000 + parentIndex() + +# unrelated to this test: UtilizationFilter asserts in debug builds when an Ethernet +# transmission is in progress at the moment the warmup period expires +**.eth[*].**.statistic-recording = false + +# the airtime-fair transmit queue under test; the fine-grained quantum makes the +# airtime deficit bind on every frame +*.accessPoint.wlan[*].mac.dcf.channelAccess.pendingQueue.typename = "AirtimeFairnessQueue" +*.accessPoint.wlan[*].mac.dcf.channelAccess.pendingQueue.packetCapacity = 50 +*.accessPoint.wlan[*].mac.dcf.channelAccess.pendingQueue.quantum = 100us +%#-------------------------------------------------------------------------------------------------------------- +%postrun-command: gawk '/sta\[[0-9]+\]\.app\[0\] packetReceived:sum\(packetBytes\)/ { match($2, /sta\[([0-9]+)\]/, m); b[m[1]] = $4 } END { printf "allServed=%s\n", (b[0]>0 && b[1]>0 && b[2]>0) ? "YES" : "NO"; printf "airtimeShared=%s\n", (b[1] > b[0]*0.25 && b[2] > b[0]*0.25) ? "YES" : "NO"; printf "values sta0=%d sta1=%d sta2=%d\n", b[0], b[1], b[2] }' results/*.sca > verdict.out +%contains: verdict.out +allServed=YES +airtimeShared=YES +%#-------------------------------------------------------------------------------------------------------------- +%postrun-command: grep "undisposed object:" test.out > test_undisposed.out || true +%not-contains: test_undisposed.out +undisposed object: From ff3907c5eea31e6008d033d5749a97e5ae24c291 Mon Sep 17 00:00:00 2001 From: Gyorgy Szaszko Date: Wed, 12 Aug 2026 16:22:38 +0200 Subject: [PATCH 14/14] ieee80211: show the state of a station on its branch module While the branch was flattened into the queue, the airtime deficit of a station and the frames waiting for it were written above the gate and the sub-queue directly. Now that a branch is a compound module, put the same information on the branch itself: PerStationAirtimeQueue extends Module and states its displayStringTextFormat, with the ModuleMixin expression resolver reading the state from the submodules -- {.gate.deficit} for the deficit held by the gate, {.queue.numPackets} and {.queue.totalLength} for the frames waiting in the sub-queue. The submodule references need the leading dot: getModuleByPath() takes a bare `gate.deficit` as an absolute path in OMNeT++ 6, so it throws instead of finding the gate submodule, and that would break every refreshDisplay. --- .../ieee80211/mac/queue/PerStationAirtimeQueue.ned | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/inet/linklayer/ieee80211/mac/queue/PerStationAirtimeQueue.ned b/src/inet/linklayer/ieee80211/mac/queue/PerStationAirtimeQueue.ned index df66f8c11ad..a1b5f5c9007 100644 --- a/src/inet/linklayer/ieee80211/mac/queue/PerStationAirtimeQueue.ned +++ b/src/inet/linklayer/ieee80211/mac/queue/PerStationAirtimeQueue.ned @@ -7,6 +7,7 @@ package inet.linklayer.ieee80211.mac.queue; +import inet.common.Module; import inet.queueing.contract.IPacketQueue; // @@ -17,12 +18,17 @@ import inet.queueing.contract.IPacketQueue; // `quantum`/`weight`/`fairnessEnabled`/`subqueueTypename` parameters default to those of the // enclosing ~AirtimeFairnessQueue, and may also be set per station, as in `branch[2].weight`. // -module PerStationAirtimeQueue like IPacketQueue +module PerStationAirtimeQueue extends Module like IPacketQueue { parameters: // the queue icon the other ~IPacketQueue modules carry (inherited from // ~PacketQueueBase, which this one cannot extend, being a compound module) @display("i=block/queue"); + // the state of the station, which was visible on the sub-queue and the gate directly + // while the branch was flattened into the queue: the airtime deficit held by the + // gate, and the frames waiting in the sub-queue; the leading dot makes the submodule + // references relative (a bare `gate.deficit` would be looked up as an absolute path) + displayStringTextFormat = default("deficit {.gate.deficit}\ncontains {.queue.numPackets} pk ({.queue.totalLength})"); double quantum @unit(s) = default(1500us); double weight = default(1); bool fairnessEnabled = default(true);