Questions that came up while reading DmxMeshTest, answered from the source — and then
again, once the same harness existed three times over.
Conventions. File references are relative to the MSCS root (Targetcore/P2Pwin32.cpp,
_Targetcore_UseExamples/DirectExamples/DmxMeshTest/DmxMeshTest.cpp). Line numbers were accurate at the time of
writing and will drift — treat them as a starting point, and search for the quoted code if a
reference misses.
The same eleven harnesses now exist five times, over five different surfaces of one framework — the three C++ ones this document was first written about, and two more that reach the same surfaces from another runtime. Every question in Part I is about the kernel and is true underneath all five; Part II is about what each layer changes.
| Tree | Written against | Links | Finds the implementation |
|---|---|---|---|
DirectExamples |
Targetcore directly — P2PeerHub, P2PeerCon, P2PeerMsg, MFC |
Targetcore.lib + Msgcore.lib |
link time |
FacadeExamples |
TargetFacade — one header, flat vtable ABI, HRESULTs | TargetFacade.lib |
link time |
ComExamples |
TargetCom — ATL dual interfaces, connection-point events | nothing of MSCS — ole32/oleaut32/uuid |
the registry, at run time |
dotNetExamples |
the same TargetCom, from C# | nothing of MSCS — a CCW per object | the registry, at run time |
PanamaJavaExamples |
the same TargetFacade, from Java | nothing at all — Panama reads the raw vtables | LoadLibrary, at run time |
They are stacked, not parallel: TargetCom is a COM layer over TargetFacade, which is a
facade over Targetcore. A message sent from the COM tree traverses all three.
ComExamples, dotNetExamples ──> TargetCom.dll (BSTR / VARIANT / IDispatch)
FacadeExamples, PanamaJavaExamples ─> TargetFacade.dll (flat vtable, HRESULT)
DirectExamples ──> Targetcore.dll ──> Msgcore.dll
Each tree carries its own README.md with the harness-by-harness mapping and its test
status. All five keep the same exit-code contract, so the same harness can be compared
directly across them: 0 success, 1 setup failure, 3 timeout / expectation not met.
(2 used to mean "an MFC/CRT assertion fired"; see Q18.)
Part I — the kernel. True in all three trees, whether or not you can see it.
- Why does the example call
WSAStartupwhen there is no socket? - What does
StartupP2Pmsg(16)do, and why 16? - Hubs vs pumps — what is the difference?
- Why does each hub need its own thread?
- Is it one thread per pump?
- So a hub thread holds both a hub and a pump?
- What happens if a pump thread throws an exception?
- Hub map vs pump map — and is the sender the same thread as the receiver?
- Where does the login handshake actually run?
- How many pumps does a hub really have?
Part II — the layers. What the facade and the COM layer change, hide, or cannot do.
- Which tree should I write against?
- What happened to the startup and shutdown boilerplate?
- Where did
BEGIN_P2PeerMsg_MAPgo? - Which thread runs my callback?
- Does arming order still matter?
- Why must the side that dialled speak first?
- What can the layers not do?
- What happens to errors, exceptions and asserts?
- How do events reach a COM client — and why can't PowerShell sink them?
WSADATA oWsaData;
WSAStartup(MAKEWORD(2, 2), &oWsaData); // DmxMeshTest.cpp:192It is boilerplate, and this particular test does not need it.
StartupP2Pmsg() never touches Winsock. The only place the core initialises it for you is
P2PeerService::Run() (Targetcore/P2PeerService.cpp:678), and a directly-driven hub never
goes through that. Hence the standard block documented in Targetcore/examples.md:131-137:
if (!StartupP2Pmsg(16)) { /* fatal */ }
// StartupP2Pmsg() does NOT call WSAStartup — when you drive a hub directly
// (rather than via P2PeerService::Run) you must init Winsock yourself.
// Required for P2PeerConWsa; harmless for P2PeerConPipe.Every harness in DirectExamples carries the same two lines, and DmxMeshTest was written
as a copy of PipeMeshTest.
Is it needed for Dmx? No. P2PeerConDmx.cpp/.h and P2PeerioDmx.cpp contain no socket(),
no SOCKET member, no htons/inet_*. The only WSA token there is WSAGROUP(hr)
(P2PeerConDmx.cpp:324), an error-category tag for the exception reporter that does not
require an initialised Winsock. The one live gethostname() in the core
(P2Pwin32.cpp:1358) sits in NotifyP2PmsgExp_Hub, reachable only from a P2Pexplorer pump
context, which this harness never creates.
Reasons to keep it anyway: it is refcounted and cheap, correctly paired with
WSACleanup() (DmxMeshTest.cpp:248), keeps the startup block identical across all the mesh
examples so the transport is the only diff, and keeps the harness working if a
P2PeerConWsa is ever mixed into the mesh.
In the Light and COM trees: the question does not arise.
FacadeNetworkcallsWSAStartup(2,2)once when the first network reference is taken andWSACleanup()when the last is dropped (TargetFacade/src/FacadeNetwork.cpp:67), so a client never sees Winsock at all — the Light harnesses do not#include <WinSock2.h>and the COM ones do not linkws2_32.lib. The refcounting means it is still correct if a process holds several networks, which is the same reason the original kept the "unnecessary" call.
StartupP2Pmsg() (P2Pwin32.cpp:1631) is the process-wide one-time boot of the P2Pmsg
kernel. Nothing else in the framework is legal before it. In order:
StartupP2Pevent()— brings up the event subsystem.- Re-entry guard — throws "P2Pmsg environment already started" if
s_apP2PmsgHubMgris already allocated. One live environment per process;CleanupP2Pmsg()before restarting. - Range check —
nMaxHubsmust be1..MAX_P2PmsgHub, whereMAX_P2PmsgHub = 256(P2Peer.h:53). - Creates the three global critical sections the kernel serialises on:
s_oCSectionP2Pmsg,s_oCSectionP2PmsgHub,s_oCSectionP2PmsgPump. - Allocates the hub table —
new P2PmsgHubMgr*[nMaxHubs+7], zero-filled, and recordss_uxP2PmsgHubMgr = nMaxHubs. Hub IDs are 1-based (CleanupP2PmsgwalksnHub = 1 … s_uxP2PmsgHubMgr), so slot 0 is dead and the+7is defensive slack. StartupP2PmsgSink()— idempotent sink-environment init (:6232); itsTRUEbecomes the return value.
It allocates no threads and opens no handles. Threads and IOCPs come later, per hub, in
CreateP2PmsgHub.
nMaxHubs is the hard ceiling on concurrent hubs in this process — not pumps, not
threads. Enforced in exactly one place (P2Pwin32.cpp:1759):
if ( s_ThreadID_P2PmsgHub.GetCount() >= (INT_PTR)s_uxP2PmsgHubMgr )
EVERR->MODULE->Message("Attempt to exceed configured hub limit (%i)", ...)->Throw();Doc bug:
Targetcore/examples.mdglosses the argument as "pump/thread pool hint". That is wrong — it has nothing to do with pumps or a thread pool.
No reason specific to any test. The header default is different — P2Pwin32.h:33 declares
StartupP2Pmsg(UINT nMaxHubs = 64) — but every hand-written harness in the tree passes 16
(dmx_mesh.cpp:156, mix_con.cpp:179, MixConTest, Com232MeshTest, the dsp_* tests,
~30 call sites, all 16). A copied convention, not a tuned figure.
DmxMeshTest creates exactly two hubs, so anything >= 2 works. The only cost of the number
is the array: 16+7 = 23 pointers, roughly 184 bytes on x64. Nothing else scales with it.
The copied convention has since hardened into a real limit: TargetFacade passes the same
16, and a facade or COM client has no way to ask for more. See the callout at the end of
this answer.
Every real failure inside goes out via EVERR->…->Throw(), and StartupP2PmsgSink() returns
TRUE unconditionally once up (see the comment at :6234, which documents a fix for exactly
the case where a re-start reported a spurious FALSE). So the reachable return FALSE is
effectively empty, and main() has no try/catch around the call — a genuine failure
unwinds as an unhandled Msgexception rather than printing the "FATAL: StartupP2Pmsg() failed." line. Same pattern in all the sibling examples.
In the Light and COM trees — and this one is a real constraint, not just a hidden detail.
FacadeNetworkhardcodes the same figure (FacadeNetwork.cpp:62):m_bMsgUp = StartupP2Pmsg ( 16 );and the public API exposes no way to change it. A facade or COM client is therefore capped at 16 hubs per process, not the header's default of 64 and not
MAX_P2PmsgHub's 256. Nothing in either example tree comes close —RouteLoopbackTestbuilds the largest mesh at four hubs — but a client that wants more has to change the facade, not its own code. The 17thcreateHub/CreateHubwould fail withP2PF_E_HUB_SPAWN.The upside of the same line: the failure that "unwinds as an unhandled
Msgexception" above cannot escape a facade client.P2PF_CreateNetworkcatches it and returnsP2PF_E_STARTUP, which is why the Light harnesses have notry/catcharound startup either — and, unlike the originals, do not need one.
A hub is a thread. P2PmsgHubMgr's constructor (P2Pwin32.cpp:745):
m_nHubID = GetCurrentThreadId();
s_ThreadID_P2PmsgHub.SetAt ( m_nHubID, this );The P2PmsgHubID is the Win32 thread ID of whichever thread created it. SpawnHub says so
explicitly (P2PeerHub.cpp:166): "the allocated win32 threadID becomes the P2PmsgHubID". A
hub owns the mesh-visible identity — the P2Paddr, an IOCP, the sink map, the connection
list, and a list of pumps.
A pump is also a thread — the message-processing loop. P2PmsgPump ctor (:211):
m_nThreadId = GetCurrentThreadId();
m_nPumpID = GetCurrentThreadId();
s_ThreadID_P2PmsgPump.SetAt( m_nThreadId, this );Enforced one-per-thread: CreateP2PmsgPump throws "ThreadID=%i already has P2PmsgPump
context" (:2381). Each pump carries its own queue event, OVERLAPPED, IOCP handle and
target.
Strict containment, 1 hub : N pumps, and the hub always has at least one:
Factory ( UINT nPumpsMax ) // P2Pwin32.cpp:886
{
pMgr->m_apP2PmsgPump = new P2PmsgPump* [nPumpsMax+7];
pMgr->m_nPumpsMax = nPumpsMax;
pMgr->CreateP2PmsgPump ( ); // pump #0, on the hub's own thread
}That first pump lands on the hub's thread, which is why CreateP2PmsgHub can immediately do
s_ThreadID_P2PmsgPump.Lookup(GetCurrentThreadId(), pP2PmsgPump) (:1777) and find one to
hang the IOCP off.
The hub is the addressable node; pumps are the execution slots inside it. Connections
attach to a specific pump — the second parameter of
PostP2PeerCon(P2PeerCon* pCon, P2PumpID nPumpID = 0) (P2PeerHub.h:78), defaulting to
pump #0.
See Q10 — in practice N is always 1 in this codebase.
nMaxHubs |
nPumpsMax |
|
|---|---|---|
| Scope | whole process | one hub |
| Set by | StartupP2Pmsg(16) |
CreateHub(addr, nPumpsMax) / SpawnHub |
| Stored in | s_uxP2PmsgHubMgr (global) |
m_nPumpsMax (per P2PmsgHubMgr) |
| Checked at | P2Pwin32.cpp:1759 |
P2Pwin32.cpp:2400 |
| Error | "Attempt to exceed configured hub limit" | "Attempt to exceed configured pump limit (%i) for hub" |
MAX_P2PmsgHub and MAX_P2PmsgPump (both 256, P2Peer.h:53,55) are neither of those —
they are compile-time caps on what you may request. You cannot raise them without
recompiling.
Two hubs, oServer and oClient, each created by SpawnHub() — 2 of 16 hub slots, on two
dedicated threads. Neither passes a pump count, because SpawnHub supplies it
(P2PeerHub.cpp:163):
oContext.nPumpsMax = HubPumpsMaxKnob ( ); // W8: env-tunable, default 15That is the P2PMSG_PUMPS_MAX env knob, clamped to [15, 256] (P2PeerHub.cpp:45-59). Each
hub gets 15 pump slots and uses exactly 1. Total: 2 hub threads for the whole test.
More hubs != more concurrency for one endpoint. A hub is an addressable node in the mesh.
In the Light and COM trees: identical, because the facade does not reimplement any of this —
FacadeHubderives fromP2PeerHuband callsSpawnHub()(TargetFacade/src/FacadeHub.cpp:149). So:
- one
p2pf::Hubis oneP2PeerHubis one thread hosting pump #0;DmxMeshTest(Light) has exactly the same two hub threads as the original;- everything in Q3–Q6 about thread identity,
m_nHubID == GetCurrentThreadId()and the one-pump-per-thread rule is still literally true underneath — you just have no API to observe it, becauseAddress()returns theP2Paddrand nothing returns a HubID.The COM tree adds exactly one thread per hub:
CP2PHubCom::Initstarts a dedicated MTA dispatch thread before creating the kernel hub (TargetFacade/com/ComHub.cpp, the_beginthreadexinInit). A COM hub is therefore two threads — the kernel pump, plus the dispatch thread that replays its callbacks into your apartment. Why that second thread has to exist is Q14.
Strictly it does not need its own thread — it needs to own a thread's context for its lifetime. Three things force that.
Because m_nHubID = GetCurrentThreadId(), the whole framework resolves "which hub/pump am I
in?" by looking up the calling thread. There is no context handle threaded through the API.
GetP2PmsgHubID() (:1801), PumpP2Pmsg() (:4381), QueryP2PmsgExp_Hub() (:1385) all
do a Lookup(GetCurrentThreadId(), …) and throw if it misses:
s_ThreadID_P2PmsgPump.Lookup(GetCurrentThreadId(),pP2PmsgPump);
if ( !pP2PmsgPump )
EVERR->…->Message("P2Pmsg pump not started")->Throw();Hence the invariant asserted on entry and every loop iteration (P2PeerHub.cpp:372, :418):
ASSERT(m_nHubID==GetCurrentThreadId());Two hubs on one thread would collide on the same map key — the second SetAt displaces the
first, and every ambient lookup silently resolves to the wrong hub. The design trades
explicit context-passing for thread affinity, so thread affinity becomes mandatory.
RunHub is a blocking wait loop (P2PeerHub.cpp:383):
while ( (dwResult=PumpP2Pmsg(dwMSec,nSigID)) != 0 ) // dwMSec = 8000PumpP2Pmsg polls timers, drains the FIFO under the pump's m_oCSection, and otherwise
parks on the pump's event or IOCP until timeout. There is no cooperative yield point that
would let a second hub's loop interleave.
ProcHub wraps the whole hub lifecycle in try/catch (P2PeerHub.cpp:330-348) with an
explicit note that this matters because it is a thread proc — an escaping exception calls
std::terminate and kills the process. A per-hub thread lets a hub fault, drain, pause
(P2PsigHub_PAUSE) or close (P2PsigHub_CLOSE) without touching its neighbour, and keeps a
slow user callback in one hub from stalling the other's pump.
CreateHub() exists for when you do not want a spawned thread (P2PeerHub.cpp:190-193):
Creates P2PmsgHub within the context of this thread … Facilitates integration of P2PeerHub processing in 3rd Party environments such as MFC or service thread
That is what P2PeerService::Run() uses in service mode (P2PeerService.cpp:692-696) —
CreateHub() then RunHub() on the service thread, which "locks up this processing
context". Console mode spawns instead. Either way it is one thread per hub; CreateHub just
lets you nominate which one.
The Dmx handshake is a real rendezvous: the server hub must be pumping Listen()/Accept()
at the moment the client hub calls Connect(). That is what the Sleep(750) at
DmxMeshTest.cpp:208 buys:
// Let the server pump run Listen()/Accept() so the client's Connect()
// finds a listening service con in the global Dmx registry.On a single thread this deadlocks — the accept side never runs while the connect side is blocked, the login handshake never completes, and the harness exits 3 (TIMEOUT).
Yes, and it is structurally enforced in both directions.
-
<= 1 pump per thread — the throw in
CreateP2PmsgPump(P2Pwin32.cpp:2380):if ( s_ThreadID_P2PmsgPump.Lookup(GetCurrentThreadId(),pP2PmsgPump) || pP2PmsgPump ) EVERR->…->Message("ThreadID=%i already has P2PmsgPump context", GetCurrentThreadId())->Throw();
-
Exactly 1 thread per pump — by construction:
m_nPumpID = m_nThreadId = GetCurrentThreadId(), and the pump is only reachable throughs_ThreadID_P2PmsgPumpkeyed by that ID.
Neither direction can be violated, because the ID is the thread — there is no separate identifier space that could alias.
But pump #0 is not an extra thread. Factory() creates it inline on whatever thread is
building the hub (:889), so a hub with one pump is one thread total, not two.
Extra pumps each get a CreateThread — P2PeerTarget::SpawnPump
(P2PeerTarget.cpp:136), the pump-level mirror of SpawnHub:
HANDLE hThread = CreateThread ( 0, 0, pfnThreadProc, &oContext, 0, pnP2PumpID );
…
while ( !P2PmsgPumpExists(*pnP2PumpID) ) // spin until the pump registers
YieldForP2PmsgPump ( uSpins );pnP2PumpID is CreateThread's thread-ID out-param — the ID the OS returns is the PumpID.
The new thread runs ProcPump (:206), which calls CreateP2PmsgPump(pTarget->GetHubID(), …)
to attach itself to the hub, then loops in RunPump().
There is no hidden completion-port thread pool. The pump thread does its own dequeue, inside
PumpP2Pmsg Step 3 (:4549):
if ( pP2PmsgPump->m_hIOCP )
{
GetQueuedCompletionStatus ( pP2PmsgPump->m_hIOCP, &dwBytes, &ulCompletionKey, &pOVERLAPPED, dwTimeout );
…
pCon -> On_QueuedCompletionStatus ( 0, dwBytes, pOVERLAPPEDcon );With no IOCP, Step 4 parks on the pump's own event instead (:4601,
WaitForSingleObject(m_hQueEvent, dwTimeout)). Each pump owns its IOCP handle, its
OVERLAPPED, its FIFO and its m_oCSection — thread affinity is what makes that per-pump
state safe to touch without locking on the consumer side.
Not a pump count: the
8inCreateP2PmsgHub(oP2Paddr, pHub, nPumpsMax, 8)(P2PeerHub.cpp:332) isnNumberOfConcurrentThreadsforCreateIoCompletionPort(:1783) — a kernel hint about how many threads the port may keep runnable. It creates nothing.
Yes — and it is the same thread ID serving as both identifiers. On the thread SpawnHub
creates:
Factory ( nPumpsMax ) // P2Pwin32.cpp:886
{
P2PmsgHubMgr *pMgr = new P2PmsgHubMgr ( ); // ctor: m_nHubID = GetCurrentThreadId()
// s_ThreadID_P2PmsgHub.SetAt(m_nHubID, this)
…
pMgr -> CreateP2PmsgPump ( ); // ctor: m_nPumpID = GetCurrentThreadId()
} // s_ThreadID_P2PmsgPump.SetAt(m_nThreadId, this)Both registrations happen on one thread, so m_nHubID == m_nPumpID for pump #0.
CreateP2PmsgHub then reads the pump straight back out of the map on that same thread and
hangs the completion port on it (:1777-1785).
That identity is why PostP2PeerCon's default is not a type confusion
(P2PeerHub.cpp:485):
if ( nPumpID <= 0 )
nPumpID = m_nHubID; // a HubID used as a PumpID — same number, for pump #0and why RunHub is simultaneously the hub loop and pump #0's loop: it asserts
m_nHubID==GetCurrentThreadId() and then calls PumpP2Pmsg, which resolves the pump by
current thread. One while loop, both roles.
Two consequences:
- You cannot create a hub on an existing worker-pump thread.
CreateP2PmsgHubthrows if the calling thread already has a pump (:1741-1747, "P2PmsgPump already exists in contect of thread" — sic). Hub creation must be what establishes pump #0, so it needs a clean thread. - Pump #0 consumes a slot.
CreateP2PmsgPumpdoesm_oCListP2PmsgPump.AddTail(...)and the limit check isGetCount() >= m_nPumpsMax(:2400). With the default 15, a hub has 14 spare slots, not 15 — andCreateP2Pexplorerwould take one of those too.
| Role | In hub map | In pump map | Created by |
|---|---|---|---|
| Hub thread | yes | yes (pump #0) | SpawnHub / CreateHub |
| Worker pump thread | no | yes | SpawnPump |
Outside thread (your main) |
no | no | — |
If you use CreateHub() to host a hub on a thread you already own (the MFC / service-thread
pattern), that thread becomes a pump thread: it cannot host a second pump afterwards, and
you are responsible for calling PumpP2Pmsg on it.
Four nested containment layers. Which one catches it decides whether you lose a connection, the pump, or the process.
DispatchP2Pmsg wraps all On_* map routing in try (P2Pwin32.cpp:3009) with one live
handler (:3279):
catch ( P2Pevent *pEVT )
{
pEVT->Print();
if ( pP2Pmsg->nCode == CN_P2PeerCon || pP2Pmsg->pCon )
pP2Pmsg -> pCon -> Drop ( pEVT->Isolate() );
else
pEVT -> Cancel ( );
}Connection dies, pump survives. Isolate() detaches the event from the thread chain so it
can ride along on the con; the drop then passes through your ON_P2PeerCon_CLOSE handler.
The catch is type-specific — P2Pevent* only. A std::bad_alloc, an MFC CException*,
or anything you throw yourself goes straight past it.
Steps 1 and 3 of PumpP2Pmsg guard their callbacks individually, and these are sealed —
catch(P2Pevent*) and catch(...), both ending in Drop (:4432 for On_PITimer,
:4573 for On_QueuedCompletionStatus):
try { pP2Pmsg->pPeerio->On_PITimer ( … ); }
catch ( P2Pevent *pEVT ) { pP2Pmsg->pPeerio->GetP2PeerCon()->Drop(pEVT->Isolate()); }
catch ( ... ) { … pCon -> Drop ( pEVT->Isolate() ); }Note the asymmetry: Steps 2/2b (normal message dispatch) have no wrapper at this level and
rely entirely on layer 1, which only handles P2Pevent*.
RunPump (P2PeerTarget.cpp:257-292) and RunHub (P2PeerHub.cpp:375-436) each wrap their
whole while (PumpP2Pmsg(...)) loop in catch(P2Pevent*) + catch(...), both ending in
Cancel().
This is where a non-P2Pevent throw from your handler lands — and the recovery is to fall
out of the loop. The pump stops permanently. Nothing restarts it, nothing signals the peer,
and the hub's other pumps keep running as if nothing happened.
ProcPump (P2PeerTarget.cpp:206) and ProcHub (P2PeerHub.cpp:330) guard the
setup/teardown that sits outside layer 3. ProcHub's comment says why:
An exception escaping a thread proc terminates the process (
std::terminateon Linux, likewise on Windows).RunHub()self-guards its own pump loop, but theCreateP2PmsgHub()setup andCloseP2PmsgHub()teardown-drain run outside it and can throw a P2Pevent under socket/port pressure — which aborted the process on a pump thread (Phase-5 teardown stress).
Past layer 4 there is nothing; catch_ALL_Cancel is the backstop.
Normal exception path (layers 1-3): clean. RunPump returns, ProcPump zeroes
*pnPumpID and calls CloseP2PmsgPump(), deregistering from s_ThreadID_P2PmsgPump — so
ClosePump's spin on P2PmsgPumpExists terminates instead of hanging. The hub side is
equivalent, and RunHub does StoreHubID(m_nHubID, 0) as its "Manadatory last operation"
(:441) on both paths, which is what SpawnHub/CloseHub spin-read.
Throw from CreateP2PmsgPump itself (layer 4): CloseP2PmsgPump() is inside the same
try, after the throwing call, so it is skipped and the thread dies. Not a leak in practice,
because SpawnPump polls the thread while waiting for registration
(P2PeerTarget.cpp:162):
if ( !GetExitCodeThread(hThread,&dwExitCode) || dwExitCode != STILL_ACTIVE )
return 0; // Operational failureSame mechanism on SpawnHub — that is the path that produces
"FATAL: server SpawnHub failed.".
Nothing crashes and nothing prints on your console. Cancel() reports through the event/sink
system, not through the harness's wprintf logging. The hub thread silently stops pumping,
the login handshake never completes, WaitForSingleObject(g_hDoneEvent, …) times out, and
you exit 3 (TIMEOUT) — indistinguishable from a genuinely slow handshake.
AssertReportHook (DmxMeshTest.cpp:179) covers CRT asserts (exit 2); it does not see
exceptions. When debugging an unexplained exit 3, add an override of On_P2Pevent to log
what the sink is swallowing, and a log line in ON_P2PeerCon_CLOSE to catch the layer-1
Drop path. The one place the framework prints unconditionally is pEVT->Print() at
:3282, so a dropped connection leaves a trace but a layer-3 pump death does not.
In the Light and COM trees: all four layers are still there and still behave exactly as described — they are inside
Targetcore, and the facade does not intercept them. What changes is the fifth layer the facade adds at its own boundary: every exported method is wrapped, so no exception ever crosses the ABI. AMsgexceptionfrom the kernel becomes anHRESULTreturn; thep2pf::IP2PHubEventssink is invoked inside that wrapper, so a throw from your handler is caught before it can reach the pump loop and kill it (layer 3).That closes the "pump dies quietly" hole from the client's side but not the kernel's: a layer-3 death originating inside Targetcore still stalls the hub silently, and the symptom is still an exit 3 that looks like a slow handshake. The facade gives you one more place to look —
onError, which surfaces the kernel's diagnostics as text. Both Light and COM harnesses register it on every hub for exactly that reason, and it is how Q16 was diagnosed.
Independent global registries, both keyed by thread ID, each with its own lock:
s_ThreadID_P2PmsgHub |
s_ThreadID_P2PmsgPump |
|
|---|---|---|
| Value | P2PmsgHubMgr* |
P2PmsgPump* |
| Lock | s_oCSectionP2PmsgHub |
s_oCSectionP2PmsgPump |
| Holds | m_oP2Paddr, connection list, sink map, pump list, m_nPumpsMax |
FIFO + m_oCSection, timer list, m_hIOCP, m_hQueEvent, m_pTarget |
| Answers | which node is this | which runnable queue is this |
They are not thread-local storage — they are global maps, and the ID you pass need not be your own. That gives each map two uses:
Lookup( GetCurrentThreadId(), … ) // "what context am I in?" — PumpP2Pmsg:4381
Lookup( nPumpID, … ) // "reach into someone else's" — PostP2Pmsg:2538Being in the hub map makes a thread the owner of a mesh identity. Being in the pump map makes a thread addressable as a delivery destination by any other thread.
That is the design, not an accident. Three hops, three answers.
1. Posting to a pump — producer thread != consumer thread. PostP2Pmsg runs on the
caller's thread but resolves the destination pump by ID and appends under that pump's
lock (:2538, 2547), then wakes it (:449):
Wakeup ( DWORD nP2PumpID )
{
if ( nP2PumpID == GetCurrentThreadId() ) return TRUE; // self-post: already running
if ( m_hIOCP ) PostQueuedCompletionStatus( m_hIOCP, 0, 0, m_pOVERLAPPED );
else if ( m_hQueEvent ) return SetEvent ( m_hQueEvent );
}The explicit same-thread elision tells you cross-thread is the expected case.
2. Across the Dmx connection — definitively two threads. oClient.PostP2PeerMsg(...)
runs on the client hub thread. P2PeerioDmx::SendP2PeerMsg (P2PeerioDmx.cpp:74) takes the
global g_oCSectP2PeerConDmx, parks the buffer, then reaches into the peer object:
P2PeerioDmx *pThat = (P2PeerioDmx *)hFile;
…
pThat -> m_pOVERLAPPEDrecv = 0;
pThat -> m_pCon -> PostOVERLAPPED ( pOVERLAPPEDrecv );PostOVERLAPPED (P2PeerCon.cpp:838) is a PostQueuedCompletionStatus(m_hCPort, …) onto the
server's completion port.
The comment above the send is literal about what this buys: "Receiver does the copy. The buffer effectively sits idle locally until the receiver becomes available." Nothing is copied on the sending thread — Dmx hands over a pointer plus a completion, and the receiving thread does the work. That is the whole "direct memory exchange" idea.
3. After arrival, within the endpoint — same thread. Once the frame lands, the recv
completion and your On_P2PeerMsg handler run on the same pump thread. That is what the W3
optimisation exploits (P2Pwin32.cpp:2589):
The recv completion (
On_QueuedCompletionStatus) already runs on the destination pump thread, so re-posting the parsed frame to that pump's FIFO only to dequeue and dispatch it a loop-iteration later is a redundant same-thread hop.
client hub thread server hub thread
───────────────── ─────────────────
PostP2PeerMsg
SendP2PeerMsg ──[g_oCSectP2PeerConDmx]──► (buffer handed over)
PostOVERLAPPED ──[PostQueuedCompletionStatus → server m_hCPort]──►
PumpP2Pmsg Step 3
On_QueuedCompletionStatus
→ recv + copy
→ On_P2PeerMsg ← your handler
Related detail: Step 3 stamps pCon->m_hCPortP2PumpID = pP2PmsgPump->m_nThreadId (:4565).
The connection remembers which pump thread serviced it, so subsequent traffic keeps landing on
the same thread — connection-to-pump affinity, established on first completion.
Entirely on the two hub pump threads. The handshake has no thread of its own — it is a state machine that each pump advances one leg at a time, alternating across the Dmx pair.
main thread does setup only — ServiceFactory/ClientFactory, then PostP2PeerCon hands
each con to a hub's pump #0. Then it blocks on g_hDoneEvent.
Client hub thread — connect completes, the pump dispatches CN_P2PeerCon/connect →
On_ConConnect → P2PeerTarget::On_ConConnect (P2PeerTarget.cpp:2348):
pCon -> OnConnect ( );
if ( pCon->GetP2Peerio()->IsEncrypted() ) pCon -> PKeyXChange ( nullptr, 0 );
else pCon -> Login ( strP2PaddrNULL, 0, 0 );Dmx is not encrypted, so it goes straight to Login, which builds a P2Pmsg_Login and posts
it — SendP2PeerMsg → PostOVERLAPPED onto the server's completion port.
Server hub thread — Step 3 dequeues → P2PeerCon::On_QueuedCompletionStatus →
RecvP2PeerMsg parses the frame → the special interception at P2PeerCon.cpp:370:
if ( pMsg->Map_MatchName(P2Pmsg_Login) && !pMsg->GetSource() )
PostP2Pmsg ( m_oThatP2Paddr, CN_P2PeerCon, P2P_Login, this, pMsg, m_hCPortP2PumpID );Note the target m_hCPortP2PumpID — its own pump, the one that just took the completion.
The next pump visit dispatches through ON_P2PeerCon_LOGIN → P2PSig_ConLogin →
On_ConLogin → base (P2PeerTarget.cpp:2510):
pCon -> OnLogin ( strThatP2Paddr );
pCon -> LoginAck ( strThatP2Paddr, 0, 0 );LoginAck (P2PeerCon.cpp:1920) posts P2Pmsg_LoginAck back across Dmx onto the client's
port, then SetState(ConState_Login, 0).
Client hub thread — same recv path, Map_MatchName(P2Pmsg_LoginAck) → posted to its own
pump (:375) → dispatched as P2PSig_ConLoginAck → the harness's On_ConLoginAck
(DmxMeshTest.cpp:93), which calls the base (→ pCon->OnLoginAck) and then
PostTestMessage().
So the BCast is sent from inside a handshake callback, on the client hub thread. It crosses to
the server hub thread the same way, lands in On_P2PeerBCast → PrintMessage →
SetEvent(g_hDoneEvent), which finally releases main.
main client hub thread server hub thread
──── ───────────────── ─────────────────
PostP2PeerCon ─►
◄─── Listen/Accept
On_ConConnect
Login ──────────────────────► On_ConLogin
OnLogin
On_ConLoginAck ◄──────────────── LoginAck
PostTestMessage
BCast ────────────────────► On_P2PeerBCast
WaitForSingle ◄───────────────────────────────── SetEvent
OnLogin, LoginAck and OnLoginAck all open with a context assertion
(P2PeerCon.cpp:2024):
if ( !CheckP2PmsgPumpState(CN_P2PeerCon,P2P_LoginAck) )
EVERR->…->Message("Requires ON_P2PeerCon_LOGINACK handler state")->Throw();CheckP2PmsgPumpState (P2Pwin32.cpp:5085) does Lookup(GetCurrentThreadId(), pPump) then
compares pPump->m_pP2Pmsg->nCode/nMsg — checking three things at once: you are on a pump
thread, that pump is mid-dispatch, and the message it is dispatching is exactly this one.
These methods are only callable from inside their own handler; calling pCon->Login() from
main throws.
DmxMeshHub::Trace already prints tid= (DmxMeshTest.cpp:136). In a successful run you see
On_ConConnect and On_ConLoginAck on one TID, On_ConListen/On_ConAccept/On_ConLogin
on another, both different from the MAIN lines.
It also explains the failure shape: because each leg only advances when the owning pump runs, a pump that dies (layer-3 exception) stalls the alternation with no error on the console.
In the Light and COM trees: the same alternation runs, unchanged, on the same two pump threads. The whole of it collapses into one callback:
hub.onPeerUp([](const wchar_t* peer) { /* the link is logged in */ });
onPeerUpfires from the facade'sOn_ConLoginAck/On_ConLoginhandling, so it is the client's view of the last leg of the diagram above. The sevenOn_Con*trace overrides the originals carried —On_ConStartup,On_ConConnect,On_ConAccept,On_ConListen,On_ConLogin,On_ConClose,On_ConShutdown— have no facade equivalent at all. If you need to watch the handshake leg by leg, that is a reason to use Targetcore directly; the facade deliberately reports only the two edges that matter to an application,onPeerUpandonPeerDown.The consequence that does leak through is Q16:
onPeerUpon the listening side fires when its leg completes, which is not the same instant as the dialling side's.
One, everywhere in this codebase — including both hubs in DmxMeshTest.
The multi-pump structure is real: P2PmsgHubMgr holds CList<P2PmsgPump*> m_oCListP2PmsgPump plus m_nPumpsMax, the global CreateP2PmsgPump enforces
GetCount() >= m_nPumpsMax → "Attempt to exceed configured pump limit (%i) for hub", and
RunHub enumerates the list on shutdown to signal every pump:
P2PumpID nPumpID = 0;
while ( EnumP2PmsgPump(m_nHubID,nPumpID) )
SignalP2PmsgPump ( nPumpID, P2PsigPump_CLOSE );But nothing in the tree ever adds an ordinary second pump. SpawnPump has no call sites
at all — grepping *.cpp/*.h/*.md across the whole repo returns only the declaration
(P2PeerTarget.h:84), the definition (P2PeerTarget.cpp:136), and two doc comments. The
P2PeerTarget::CreatePump() those comments point at (P2PeerTarget.cpp:179,
P2PeerExplorer.cpp:257) does not exist anywhere.
The one live path that gives a hub a second pump is the explorer pump: CreateP2Pexpump
(P2Pwin32.cpp:1019) → CreateP2Pexplorer() → AddTail onto the same list, spawned by
P2PeerExplorer::SpawnExpump. That is on-demand diagnostics, driven by a CREATE/SPAWN
command arriving as a message (P2PeerHub.cpp:1084-1099), not anything a normal hub does. It
is also tracked separately in m_pP2Pexplorer.
So nPumpsMax = 15 is headroom for a facility that exists but is unused, and the
list-plus-enumerate machinery is written for N while N has always been 1. "Concurrency within
a node comes from adding pumps" describes a capability, not a path anything currently takes.
SpawnPump looks complete — thread creation, registration spin-wait, ProcPump guard,
ClosePump — but any use would be the first caller, so treat it as untested.
| Field | Value |
|---|---|
m_oP2Paddr |
DmxMesh.Server / DmxMesh.Client |
| connection list | 1 con each |
m_pP2PmsgSinkmap |
null until a sink is created |
m_oCListP2PmsgPump |
exactly 1 entry — pump #0, on the hub thread |
m_nPumpsMax |
15, of which 1 is used |
m_pP2Pexplorer |
null |
m_apP2PmsgPump — the slot array allocated in Factory (:887) — is zeroed and freed but
never written to. The live collection is m_oCListP2PmsgPump, and that is what the limit
check counts. P2PmsgHubMgr::GetP2PmsgPump(int) (:893) indexes the array and therefore
always returns null. Do not confuse it with the static P2PmsgPump::GetP2PmsgPump(), which
resolves through the thread map and does work.
Start from what your client is, not from what the framework offers.
| If you… | Use | Because |
|---|---|---|
| are writing a script, a VBA macro, a .NET app, or anything you would rather not compile against a C++ SDK | COM | no build-time dependency on MSCS at all |
| are writing C++ and want the messaging without the framework | facade | one header, one import lib, no MFC, no macros |
need PostP2Pmsg pump injection, the P2PeerMsg factories, custom P2PeerCon subclasses, the priority queue, or the On_Con* handshake legs |
Targetcore | none of that exists above it — see Q17 |
| are working the Linux port | Targetcore | the facade is a Windows MFC DLL; COM adds the registry and apartments on top |
| are debugging the kernel itself | Targetcore | the layers hide exactly the machinery you need to see |
The layers are additive in cost and subtractive in reach. Nothing above Targetcore can do anything Targetcore cannot; the question is only how much of it you need.
Concretely, from the three trees' own harnesses (non-comment, non-blank lines, all eleven):
| Targetcore | facade | COM | |
|---|---|---|---|
| harness code | 2376 | 922 | 827 |
| shared helper | — | 79 | 387 |
The COM harnesses are the shortest and the COM stack is the largest. That is the trade being made: the plumbing did not vanish, it moved into one place that a scripting client never sees.
Every original harness carried this, in this order, on every exit path — and there are six or more returns in some of them:
StartupP2Pmsg(16); WSAStartup(MAKEWORD(2,2), &wsa);
… hub.SpawnHub(); …
hub.CloseHub(); WaitForSingleObject(hThread, 3000); CloseHandle(hThread);
CleanupP2Pmsg(); CloseHandle(g_hDoneEvent); WSACleanup();It becomes two objects with destructors:
p2pf::Network net; // StartupP2Pmsg + WSAStartup, refcounted
p2pf::Hub hub = net.createHub(L"Demo.Server"); // P2PeerHub + SpawnHub
// ~Hub closes the pump and joins; ~Network shuts the kernel down. In order.p2pf::Network is a process-wide refcounted singleton inside the DLL: every successful
P2PF_CreateNetwork hands back the same network and adds a reference, and the kernel comes
up on the first call and goes down on the last. So two independent components in one process
can each hold a Network without either one tearing the kernel out from under the other —
which the raw StartupP2Pmsg/CleanupP2Pmsg pair cannot do (Q2's re-entry guard throws
"P2Pmsg environment already started").
The COM tree adds one thing to remember and removes another. Added: the coclass must be
registered, or CoCreateInstance returns REGDB_E_CLASSNOTREG and nothing else works;
run_all.ps1 does it with regsvr32 /n /i:user, which writes HKCU\Software\Classes only
and needs no elevation. Removed: hub lifetime, because the network holds a reference to every
hub it created and releases them all when it goes — the facade's rule, carried through COM.
PipeMsgMapTest is the harness to read across all three trees, because it exists to
demonstrate exactly this. The original:
class PipeMapHub : public P2PeerHub
{
DECLARE_P2PeerMsg_MAP()
msgRESULT On_HubPing(P2PeerMsg* pMsg) { … }
msgRESULT On_HubPong(P2PeerMsg* pMsg) { … }
};
BEGIN_P2PeerMsg_MAP(PipeMapHub, P2PeerHub)
ON_P2PeerMsg(kMsgPing, On_HubPing)
ON_P2PeerMsg(kMsgPong, On_HubPong)
END_P2PeerMsg_MAP()The facade:
server.onTopic(L"HubPing", [](const p2pf::Message& m) { … });
client.onTopic(L"HubPong", [](const p2pf::Message& m) { … });A facade "topic" is the kernel message name. It is not a parallel dispatch system built on
top — the facade keeps one wildcard BEGIN_P2PeerMsg_MAP inside FacadeHub.cpp and routes
through the kernel's own map machinery, which is why the semantics are identical rather than
merely similar. Two rules survive intact and are visible in the public header:
- names beginning with
P2Pmsgare the kernel's, andSend/Broadcastrefuse them withP2PF_E_RESERVED_TOPIC; - a message matching no registered topic falls through to
onMessage, the way an unmatched name falls through the map toOn_P2PeerUCast.
In COM the map stops being a language feature at all. There is one event —
_IP2PHubEvents.OnMessage(source, topic, payload, broadcast) — and "routing by name" is an
ordinary switch on the topic string in the client. That is precisely what makes it
expressible from VBScript or VBA, neither of which has anything a macro-based map could bind
to. common/ComHarness.h does the switch so the COM harnesses still read like a map.
Related: the P2PeerMsg factories (RedirectFactory, ResponseFactory) have no
equivalent above the kernel, and neither does the ownership hazard they came with — see
Q17.
Different in all three trees, and it is the single most important difference between them.
Targetcore — the pump thread that owns the connection. Q8 and Q9 in full: your On_*
handler runs on the hub pump thread, the same one that took the IOCP completion, with
connection-to-pump affinity established on first completion.
Facade — still the pump thread. The facade does not move your callback; it wraps it. From the public header:
Callbacks fire on the hub pump thread; payload pointers are valid only during the callback.
So a slow onTopic handler stalls that hub's pump exactly as a slow On_P2PeerMsg would, and
the payload rule is the kernel's rule unchanged. What the facade does add is the guarantee
that a throw from your handler cannot kill the pump (Q7).
COM — neither. A kernel pump thread is not a COM apartment, so calling a client's sink from it would be illegal for every STA client. Each COM hub therefore:
- copies every callback — strings and payload bytes — into a bounded queue (4096 entries), on the pump thread, doing nothing else there;
- replays them on its own dedicated MTA dispatch thread;
- re-fetches your sink from the Global Interface Table per fire, so COM marshals the call into whatever apartment the sink actually lives in.
Three consequences worth knowing:
- A slow client handler can no longer stall the kernel pump. It stalls the dispatch
thread instead, and the queue absorbs the difference. Overflow past 4096 is dropped and
reported as an
OnErroronce the queue drains — never silently. - The payload-lifetime rule disappears. The queued copy owns its bytes, so the
SAFEARRAYhanded to your sink is valid for as long as you hold it. - An STA client must pump messages. A marshalled call arrives through the message queue,
so a plain
WaitForSingleObjectin an STA hangs forever with the event sitting undelivered. Every COM harness runs STA on purpose andcom::Gate::wait()pumps; you can see it in the logs, where each delivery line carries the sametid=asmain.
| Targetcore | facade | COM (STA client) | |
|---|---|---|---|
| handler runs on | hub pump thread | hub pump thread | your thread, in the pump |
| threads per hub | 1 | 1 | 2 (pump + dispatch) |
| slow handler | stalls the pump | stalls the pump | stalls only the dispatch thread |
| payload valid | during the callback | during the callback | for as long as you hold it |
| must pump messages | no | no | yes |
Less than it did, and the difference is per transport rather than per tree.
Every original harness has a Sleep(750) between arming the listener and dialling it, with a
comment explaining that the server pump must reach Listen()/Accept() first (Q4.4 covers
why, for Dmx). The reason it was needed is that a raw ClientFactory connection dials
exactly once: if the far side is not there yet, that is the end of it.
The facade changes that for the two transports where "not there yet" is a timing question
rather than a configuration error. Its dials set the kernel's m_uAutoRestart — 500 ms,
FacadeHub.cpp:72 — and override HasDroppedOut to return true, because
P2PeerCon::OnClose otherwise shows a modal message box per failed retry, which is fatal
for a library.
| Transport | Facade dial retries? | So arming order… |
|---|---|---|
TCP (connect) |
yes, ~2/s | does not matter |
named pipe (connectPipe) |
yes, ~2/s | does not matter |
Dmx (connectDmx) |
no | listener first |
serial (connectSerial) |
no | listener first |
The split is deliberate and documented on IP2PHub: a missing in-process Dmx service or a
missing COM port is a configuration fault, not a race, so retrying would only hide the
mistake. The Light and COM DmxMeshTest and Com232MeshTest arm the listener first and say
so in a comment; WsaMeshTest and PipeMeshTest do not need to, and the Light WsaMeshTest
completes its whole round trip in about 17 ms where the original spent 750 ms waiting.
One thing that follows from retrying dials: S_OK from Connect* means "the dial is armed",
not "the peer is up". Wait for onPeerUp, or poll IsPeerUp. A peer that never appears
simply never fires onPeerUp and raises no error, because "not up yet" and "not up ever" are
indistinguishable.
Because onPeerUp on the listening side can fire before the dialling side has finished
logging in. Send from it and you race the handshake; the far end answers an early message
with
[ERROR] P2PeerCon::On_QueuedCompletionStatus
Application message received before login
ADVICE : Connection dropped out
and drops the whole connection. The harness then times out with no other symptom — an exit 3 that looks exactly like a slow handshake.
This is the one ordering hazard the layers introduce rather than remove, and it is a
direct consequence of Q9: the handshake is a state machine alternating across two pump
threads, and each side's onPeerUp fires when its own leg completes. Those are different
instants. The original harnesses never hit it because they sent from On_ConLoginAck, which
by construction only exists on the dialling side.
The rule: the first message on a link comes from the side that dialled; the listening
side answers on receipt rather than announcing itself. Every harness in both new trees follows
that shape — LocalInMemoryTest (Light) is where it was found, and its header records the
diagnosis.
If you genuinely need the listener to speak first, wait for traffic from the far side, or
poll isPeerUp from a thread that is not a callback.
Four things, each of which cost a harness something real when the trees were rebuilt.
Pump injection. PostP2Pmsg(pMsg, targetHub.GetHubID()) drops a message straight onto
another hub's pump queue with no connection and no handshake. The facade's model is that hubs
talk over connections, so there is no GetHubID(), no raw queue, and no way to reach one.
LocalInMemoryTest is the harness built on that call: the Light and COM versions reach the
same end state over the Dmx transport and pay one login handshake for it. Note that the
original's threading hazard goes too — "only legal from a non-hub thread, or you trip a
cross-hub-context ASSERT" cannot be expressed above the facade.
The message factories. RedirectFactory / ResponseFactory and the whole question of
which one is safe to standalone-post (the latter inherits the request's routing prefix and
loops the reply back into the sender's own map) have no equivalent. There is one send verb,
it takes a destination and a topic, and there is no envelope to inherit. PipeMsgFactoryTest
keeps what was being demonstrated — a caller-built payload routed to a named handler — and
drops the API navigation. The ownership hazard goes with it: the factory returned a heap
message the caller owned and PostP2PeerMsg then took, whereas send() copies the bytes
before it returns.
The handshake legs and the connection objects. No P2PeerCon, no On_ConStartup /
On_ConAccept / On_ConListen, no custom connection subclass, no pump selection
(PostP2PeerCon's nPumpID), no priority queue. onPeerUp and onPeerDown are the whole
connection API.
Linux. AlexInterop is the Linux port's Phase-3 exit criterion and is written to avoid
every Win32-ism so the same source builds against the io_uring shim with g++. The facade is a
Windows MFC DLL, so the Light rewrite forfeits the one property that harness exists for; the
COM rewrite forfeits it twice over, since COM, the registry, apartments and BSTR have no
Linux counterpart at all. Both versions exist for completeness of the mapping. For the
Linux port, use the original.
One non-limitation worth recording, because it was an open question: the kernel really does
route a tree. RouteLoopbackTest (Light and COM) builds Root / A / B / Leaf over Dmx and
confirms at runtime that Leaf → B is delivered across two hops with no connection between
them, that an address nobody holds is delivered nowhere (with a Bad destination address
diagnostic), and that a broadcast from the root reaches every descendant including the
grandchild. Hierarchical dotted addresses are what make that work, in every tree.
The originals all carry this, and the comment above it is the reason:
_CrtSetReportHook(AssertReportHook); // a debug ASSERT would pop a MODAL DIALOG
// and hang a headless runThat is why the shared exit-code contract has a 2 = an MFC/CRT assertion fired. In the
Light and COM trees, 2 is unreachable by construction: there is no MFC in those processes,
and the facade reports failures as HRESULTs and onError callbacks. Neither new tree
installs an assert hook, and neither needs one. (The modal-box problem does not disappear
from the kernel — the facade's HasDroppedOut override in Q15 exists precisely because
P2PeerCon::OnClose shows one per failed retry.)
The error surface, layer by layer:
| Targetcore | facade | COM | |
|---|---|---|---|
| call failed | BOOL, or a thrown Msgexception |
HRESULT |
HRESULT → COMException / Err.Number |
| duplicate peer | PostP2PeerCon returns FALSE; reason is in the source |
P2PF_E_CON_DUPLICATE |
same value, 0x80040204, out to scripts |
| reserved name | delivered and ignored | P2PF_E_RESERVED_TOPIC |
0x80040205 |
| bad argument | undefined / truncated | typed parameters | E_INVALIDARG (ports are range-checked) |
| kernel diagnostics | pEVT->Print() to the console |
onError(text) |
OnError event |
TwoConTest is the harness that shows the whole column: the original had to read
P2PeerHub.cpp:432-447 to explain a FALSE, the Light version gets a named error, and the
COM version demonstrates the same value arriving in a scripting client that can branch on it.
Two COM-only notes. Ports cross as LONG rather than unsigned short, so the layer
range-checks them — Listen(peer, 70000) is E_INVALIDARG instead of a silently truncated
port. And Broadcast had to change shape: the facade returns S_FALSE for "nobody was up",
which no automation client can see, so the COM method returns Delivered as an out value
instead.
A COM client gets events through a connection point. IP2PHubCom exposes
IConnectionPointContainer; you FindConnectionPoint(DIID__IP2PHubEvents) and Advise an
IDispatch implementing the four-method dispinterface. common/ComHarness.h contains that
sink, hand-written in about 60 lines, and it is the code every early-bound client needs —
whether it writes it (C++), or its host generates it (VB6, C# with an interop assembly).
The delivery path is Q14: pump thread → bounded queue → dispatch thread → GIT re-fetch → your apartment.
The scripts in ComExamples/script do not sink events, and that is a host
limitation rather than a gap in the layer.
- PowerShell / .NET can only bind COM events through an interop assembly for the coclass,
which needs
TlbImpor an early-bound reference. Late-bound, aSystem.__ComObjecthas no events to bind —Get-Memberon a hub shows every method and property and noEventrows at all. This is not specific to TargetCom. - WSH / VBScript can sink events, but only for objects it created itself via
WScript.CreateObject(progid, prefix). Hubs come fromNetwork.CreateHub, so they are outside that mechanism.
What a script can observe end to end is delivery itself, and both scripts use it rather than
claiming more than they prove: IsPeerUp only goes True after the kernel's login handshake
completes, and Broadcast returns True only if at least one peer was up to take a copy.
script/vbs_client.vbs is deliberately the least capable client the layer will ever have — no
byte arrays, no structs, no HRESULT inspection beyond Err.Number. It passes under
cscript, which is the strongest single statement about the COM layer: if it works there, it
works from VBA, from an Excel macro and from a logon script.
| Question | Answer |
|---|---|
| Hubs per process | StartupP2Pmsg(nMaxHubs), hard cap MAX_P2PmsgHub = 256 |
| Pumps per hub | nPumpsMax slots, but always 1 in practice |
| Threads per hub | 1 (the hub thread, which hosts pump #0) |
| Threads per pump | 1, and PumpID == thread ID |
| HubID | GetCurrentThreadId() of the creating thread |
| Can a thread host 2 hubs or 2 pumps? | No — the thread ID is the key in both registries |
| Can a thread host 1 hub and 1 pump? | Yes — that is exactly what a hub thread is |
| Who runs the handshake? | The two hub pump threads, alternating |
Who runs your On_* handlers? |
The pump thread owning that connection |
| Exception in a handler | P2Pevent* → connection dropped; anything else → pump dies quietly |
| Targetcore | facade | COM | |
|---|---|---|---|
| One hub is | a P2PeerHub + 1 thread |
the same, hidden | the same, +1 dispatch thread |
| Hubs per process | your StartupP2Pmsg(n), up to 256 |
16, hardcoded | 16, hardcoded |
| Startup / shutdown | 8 calls, on every exit path | p2pf::Network ctor/dtor |
CoCreateInstance + release |
| Routing by name | BEGIN_P2PeerMsg_MAP macros |
onTopic(name, λ) |
one OnMessage event + your switch |
| Your handler runs on | the hub pump thread | the hub pump thread | your apartment (you must pump) |
| Payload valid | during the callback | during the callback | for as long as you hold it |
| Slow handler | stalls the pump | stalls the pump | stalls only the dispatch thread |
| Dial ordering | always matters (one attempt) | TCP/pipe retry; Dmx/serial do not | same as facade |
| First message on a link | from On_ConLoginAck |
from the side that dialled | from the side that dialled |
| A failed call | FALSE or a throw |
HRESULT |
HRESULT → COMException |
| A debug ASSERT | modal dialog → needs a hook | cannot happen (no MFC) | cannot happen |
| Needs registering | no | no | yes (regsvr32 /n /i:user) |
| Works from a script | no | no | yes (methods; not events) |
| Runs on Linux | the kernel does, via the io_uring shim — a harness needs a portable sibling (AlexInterop, dmx_mesh.cpp, wsa_mesh.cpp) |
no | no |
Copyright 2026 Khrustal & Mann, MELBOURNE, VICTORIA, AUSTRALIA, 3000.
Licensed under the Apache License, Version 2.0. See LICENSE for
the full text.