See WIP.md for the cross-package maintenance guide. Sister packages: WinServicesLib, WinEventLogLib.
An IOCP-based async pipe framework. Four user-facing classes — NamedPipeServer and NamedPipeServerConnection on the server side, NamedPipeClientManager and NamedPipeClientConnection on the client side. The Win32 API wrappers, the package-internal OverlappedTypeConstants enum, the IOCP helper module, and the four INamedPipe*Internal interfaces (each declared alongside its matching public class as a refcount / dispatch helper for the IOCP worker threads) are all plumbing and get no doc page.
The four classes are each tagged [COMCreatable(False)] — only the manager / server classes can be instantiated by user code (with New); the two Connection classes are constructed internally and handed back through events / return values.
Public user-facing surface (four classes — two on each side):
| Class | Role |
|---|---|
NamedPipeServer |
The server. User-instantiated. Sets PipeName, calls Start, listens for events; one server hosts many clients. |
NamedPipeServerConnection |
One server-side per-client connection. Surfaced through NamedPipeServer events; carries AsyncRead / AsyncWrite / AsyncClose. |
NamedPipeClientManager |
The client-side coordinator. User-instantiated. Owns the IOCP worker threads; the Connect method returns a NamedPipeClientConnection. |
NamedPipeClientConnection |
One client-side connection. Returned by NamedPipeClientManager.Connect; carries Connected / Disconnected / MessageReceived / MessageSent events and AsyncRead / AsyncWrite / AsyncClose. |
Tagged [COMCreatable(False)], [InterfaceId(...)], [EventInterfaceId(...)], [ClassId(...)]. No [Description("...")] on the class itself.
Public fields (each carries a [Description("...")]):
PipeName As String— "the discoverable pipe name". Set this beforeStart()orStart()raises run-time error 5 ("cannot start without specifying a pipe name"). The Win32 pipe namespace path is\\.\pipe\<PipeName>(the package prepends\\.\pipe\itself).NumThreadsIOCP As Long = 1— "the number of IOCP worker threads that will be created". Read once whenStart()is called; the in-sourceFIXMEnotes that this should become read-only once started.FreeThreadingEvents As Boolean = False— "set to TRUE to allow the server events ClientConnected / ClientReceivedDataAsync etc to be fired directly from the IOCP worker threads. set to FALSE to ensure the events get fired on the main UI thread." The free-threaded path skips a Win32 message-loop round-trip; the marshalled path is safer because the events fire on the UI thread.ContinuouslyReadFromPipe As Boolean = True— "set to TRUE to ensure ClientReceivedDataAsync events always fire without having to call AsyncRead manually." WhenFalse, the consumer must callAsyncReadafter eachClientMessageReceivedto keep receiving.MessageBufferSize As Long = 131072— "sets the initial size for ReadFile() buffers. does not affect the maximum message receive size, but can affect performance." OnERROR_MORE_DATAthe IOCP loop allocates a larger overflow buffer and re-issues the read, so messages larger than this size do work — but with one extra allocation per overflowed message.
Public events:
ServerReady()— fires once afterStart()when every IOCP worker has joined.ClientConnected(Connection As NamedPipeServerConnection)— a new client connection has completed.ClientDisconnected(Connection As NamedPipeServerConnection)— the connection has dropped and every outstanding async operation has returned.ClientMessageReceived(Connection As NamedPipeServerConnection, ByRef Cookie As Variant, ByRef Data() As Byte)— a message arrived. Data is a transient view over the IOCP read buffer (a hand-rolledSAFEARRAYwhose backing memory is reused after the event); copy it if you need to keep it past the event handler.ClientMessageSent(Connection As NamedPipeServerConnection, ByRef Cookie As Variant)— a previously-issuedAsyncWritehas completed.
Public methods:
Sub New()— constructor; creates the hidden marshalling-window used for UI-thread event delivery.Public Sub Start()— creates the IOCP completion port andNumThreadsIOCPworker threads, then issues the first connection listener. Idempotent: callingStart()while already started is a no-op.Public Sub Stop()— cancels every outstanding I/O, joins the IOCP threads, closes pipe handles. Idempotent. Called automatically fromClass_Terminate.Sub AsyncBroadcast(ByRef Data() As Byte, Optional ByRef Cookie As Variant = Empty)— issuesAsyncWriteagainst every currently-connectedNamedPipeServerConnection.Public Sub ManualMessageLoopEnter()/Public Sub ManualMessageLoopLeave()— drive a Win32 message loop manually (rare; only needed when the host process does not naturally pump messages — e.g. an unattended Windows service that wants the marshalled-event semantics rather than the free-threaded ones).LeavepostsWM_USER_QUITTING, whichEnterreads to break the loop.
Tagged [COMCreatable(False)]. Not directly user-instantiable.
Public fields:
Handle As LongPtr— the underlying Win32 pipe handle. Exposed but not normally needed; useful for low-level operations or debugging.IsOpening As Boolean— true whileOpen()is in progress (race-condition window between adding to the linked list and finishingConnectNamedPipe).IsConnected As Boolean— true between the client connecting and the connection dropping.CustomData As Variant— "free for use": opaque per-connection slot the consumer can attach state to.
Public methods:
Sub New(...)— internal constructor; takes the parent server + pipe info. Never called by user code.Public Sub AsyncClose()— cancels outstanding I/O and closes the pipe handle. Called automatically fromClass_Terminate.Public Sub AsyncWrite(ByRef Data() As Byte, Optional ByRef Cookie As Variant = Empty)— writes a message back to this specific client. Returns immediately;NamedPipeServer.ClientMessageSentfires when the write completes.Public Sub AsyncRead(Optional ByRef Cookie As Variant = Empty, Optional OverlappedStruct As LongPtr)— manually issues a read. Only needed whenNamedPipeServer.ContinuouslyReadFromPipe = False; otherwise the server keeps the read pump fed automatically.
No public events — message-received and connection-dropped notifications come through the parent NamedPipeServer. The class declares an internal INamedPipeServerConnectionInternal interface that the IOCP loop uses for refcounting; that interface is Private and gets no doc page.
Tagged [COMCreatable(False)], [InterfaceId(...)], [EventInterfaceId(...)], [ClassId(...)].
Public fields (each carries [Description("...")], mirror the server's):
NumThreadsIOCP As Long = 1MessageBufferSize As Long = 131072FreeThreadingEvents As Boolean = FalseContinuouslyReadFromPipe As Boolean = True
These four are read once on the first Connect() call and propagated to every NamedPipeClientConnection created through that manager; subsequent changes do not affect connections that already exist. Source comment in NamedPipeClientConnection confirms: "tip: set it in NamedPipeClientConnections before Connect()".
Public methods:
Sub New()— constructor; creates the hidden marshalling window.Public Function Connect(ByVal PipeName As String) As NamedPipeClientConnection— opens a connection to a server (\\.\pipe\<PipeName>). Lazy on first call: creates the IOCP port and the worker threads. Returns a connection object that firesConnectedonce the asyncCreateFileWcompletes.Public Sub Stop()— cancels every outstanding I/O on every managed connection, joins the IOCP threads, frees the resources. Idempotent. Called automatically fromClass_Terminate.Public Function FindNamedPipes(Optional Pattern As String = "*") As Collection— enumerates the named pipes currently published on the local machine (viaFindFirstFileW("\\.\pipe\<Pattern>")). Returns aCollectionofString. Useful as a discovery helper before callingConnect.
No events on the manager itself — per-connection events live on the returned NamedPipeClientConnection objects.
Tagged [COMCreatable(False)], [InterfaceId(...)], [ClassId(...)], [EventInterfaceId(...)]. Not directly user-instantiable — NamedPipeClientManager.Connect returns it.
Public fields:
PipeName As String— the pipe name the connection targets.Handle As LongPtr— the underlying Win32 file handle. Same caveats as on the server-side connection.CustomData As Variant— "free for use".
Public events:
Connected()— the asyncCreateFileWhas succeeded.Disconnected()— the connection has dropped and every outstanding async operation has returned.MessageReceived(ByRef Cookie As Variant, ByRef Data() As Byte)— a message arrived. Data has the same transient-view semantics as on the server.MessageSent(ByRef Cookie As Variant)— a previously-issuedAsyncWritehas completed.
Public methods:
Sub New(...)— internal constructor; never called by user code directly.Public Sub AsyncClose()— critical: the README says "you MUST call AsyncClose on the client side, otherwise the connection is left alive when the object goes out of scope". Surface this on every relevant page.Public Sub AsyncWrite(ByRef Data() As Byte, Optional ByRef Cookie As Variant = Empty)— sends a message to the server.Public Sub AsyncRead(Optional ByRef Cookie As Variant = Empty, Optional OverlappedStruct As LongPtr)— manually issues a read. Same gating as on the server-side: only call this whenContinuouslyReadFromPipe = False.
Documented gaps / TODOs from _README.txt (surface on the landing page):
- "we need a method to allow closing a client connection from the server side" — there is no
NamedPipeServerConnection.Disconnector.Closeuser-method today. The server can stop the whole pipe (NamedPipeServer.Stop) but cannot selectively drop one client. - "named pipe error should be raised via Error events (rather than throwing an error on the worker threads)" — internal IOCP errors currently bubble up as VBA run-time errors on worker threads rather than as
Errorevents. NoErrorevent exists on any of the four classes yet. - "remove max size 131072 of messages" — the
MessageBufferSizeinitial-buffer default is 131072 bytes. The IOCP overflow path (ERROR_MORE_DATA→ larger buffer → re-issue read) does handle larger messages, but there may be a hard cap somewhere the author wants to remove; surface this as "see TODO list in_README.txt" rather than making a stronger claim. - "currently a lot of duplicate code in server + client" — internal-refactor note. Not surfaced on the docs.
Cookie pattern. Every AsyncRead and AsyncWrite accepts an optional Cookie (Variant). Whatever the consumer passes in flows through the IOCP completion buffer and is handed back out on the matching MessageReceived / MessageSent event. This is the package's mechanism for correlating individual writes with their completion notifications when many are in flight.
Data() As Byte transience. Inside MessageReceived / ClientMessageReceived, Data is not a real Byte array — it is a hand-rolled SAFEARRAY whose pvData field points at the IOCP overlapped buffer. The buffer is recycled back into a free-list at the end of the event handler. Copy the bytes out (ReDim-and-copy, or CStrConv for text payloads) if you need them after returning from the handler. The source uses PutMemPtr(VarPtr(safeArrayPtr), VarPtr(safeArrayPsuedo)) and clears it afterwards — surface this lifetime caveat on every event-page entry that carries Data.
Hidden message window. Each NamedPipeServer and NamedPipeClientManager instance creates an invisible STATIC-class window with a subclassed WndProc, used to marshal IOCP-thread completions back to the UI thread when FreeThreadingEvents = False. Mention this on each class's intro paragraph — it explains why the consumer's process must be pumping a message loop for the default event-delivery semantics to work, and why ManualMessageLoopEnter / ManualMessageLoopLeave exist on NamedPipeServer for service / console hosts.
tbServiceTest2's Sources\SERVICES\TBSERVICE001.twin shows the standard pattern for a Windows service that hosts a NamedPipeServer. Surface this on the NamedPipeServer.md page (under a "Hosting inside a Windows service" sub-heading) and on the index landing:
' On the service thread (ITbService.EntryPoint):
Set NamedPipeServer = New NamedPipeServer
NamedPipeServer.PipeName = "WaynesPipe_" & CurrentComponentName
ServiceManager.ReportStatus(vbServiceStatusRunning)
NamedPipeServer.Start()
NamedPipeServer.ManualMessageLoopEnter() ' blocks until ManualMessageLoopLeave
NamedPipeServer.Stop()
ServiceManager.ReportStatus(vbServiceStatusStopped)
' On the dispatcher thread (ITbService.ChangeState):
Select Case dwControl
Case vbServiceControlStop, vbServiceControlShutdown
ServiceManager.ReportStatus(vbServiceStatusStopPending)
NamedPipeServer.ManualMessageLoopLeave() ' wakes the service thread
End Select
Key facts that aren't obvious from the per-method [Description]s:
- The service-thread
EntryPointand the dispatcher-threadChangeStateare different threads. TheNamedPipeServermember field is shared between them; the dispatcher-threadChangeStatecallsManualMessageLoopLeaveon it to wake the service thread out ofManualMessageLoopEnter. ManualMessageLoopLeaveis the only way to wakeManualMessageLoopEntercleanly. There is no timeout, no second blocking primitive. If the service needs to react to other wake-up sources (paused state, custom control codes), it sets a shared flag then callsManualMessageLoopLeaveto break out, inspects the flag, and decides whether to re-enter the loop or proceed to shutdown. TheTBSERVICE002variant in the same example demonstrates this withIsPaused/IsStoppingsharedPublicfields and aWhile IsStopping = Falseouter loop.- Pause / continue support uses the same pattern:
ChangeStateflipsIsPaused = Trueand callsManualMessageLoopLeave; the service thread sees the flag, reportsvbServiceStatusPaused, enters aDo While IsPaused : Sleep(500) : Loop, then re-entersManualMessageLoopEnteronceContinueflips the flag back. FreeThreadingEvents = False(the default) is required for this pattern — events are marshalled to whichever thread is currently insideManualMessageLoopEnter. SettingFreeThreadingEvents = Truewould deliver events on the IOCP worker thread instead and bypass the manual loop entirely (advanced; not the documented service idiom).
The non-service equivalent — hosting the same NamedPipeServer inside a Form — is in Sources\FORMS\InProcessNamedPipeServerForm.twin: the Form's regular message loop pumps the marshalling window automatically, so the Form just calls Server.Start() in Form_Load and Server.Stop in Form_Unload without ever touching ManualMessageLoopEnter / Leave. Cross-reference both patterns on the NamedPipeServer.md page so the reader sees the choice point.
Every example serialises structured payloads through the pipe as a PropertyBag.Contents Byte():
' Sender:
Dim propertyBag As New PropertyBag
propertyBag.WriteProperty("CommandID", "WHAT_TIME_IS_IT")
propertyBag.WriteProperty("Data", payload)
SelectedNamedPipe.AsyncWrite propertyBag.Contents
' Receiver (inside MessageReceived event):
Dim propertyBag As New PropertyBag
propertyBag.Contents = Data ' deep-copies the bytes; safe past the event handler
Dim commandID As String = propertyBag.ReadProperty("CommandID")
…
Two reasons this pattern matters and should be surfaced on the docs:
- The transient-
Data()problem is solved byPropertyBag. Assigning toPropertyBag.Contentsdeep-copies the byte buffer; once the assignment returns, the original IOCP buffer can be recycled without invalidating the data. This is the cleanest answer to "how do I keep the data past the event handler?" — call out on everyMessageReceived/ClientMessageReceivedpage entry as the recommended capture mechanism. PropertyBagprovides typed multi-field payloads without the consumer having to design a wire protocol. Both sides agree on the property names ("CommandID","ResponseCommandID","ResponseData","Data") andPropertyBaghandles the encoding / decoding. Cross-linkPropertyBagreference from the index landing.
Surface as the recommended carrier; nothing in the package mandates it, raw Byte() works too, but every worked example uses PropertyBag and the integration story reads much more cleanly with it.
tbServiceTest2's MainForm shows the canonical client-side discovery pattern: a low-frequency Timer (the form uses timerRefreshNamedPipes with a multi-second interval) that calls NamedPipeClients.FindNamedPipes("WaynesPipe_*"), repopulates a ListBox, and preserves the user's current selection:
For Each namePipeName In NamePipeClients.FindNamedPipes("WaynesPipe_*")
If namePipeName = NamedPipeSelected Then namedPipeSelectedIndex = Index
lstNamedPipes.AddItem(namePipeName)
Index += 1
Next
Surface on the NamedPipeClientManager.md page (under the FindNamedPipes entry) as the recommended polling loop — the underlying FindFirstFileW("\\.\pipe\…") call is cheap enough to invoke every few seconds without measurable cost, and pipes appear / disappear too quickly for any event-driven discovery to be reliable. Don't claim there's no faster API; just say "polling is the documented approach".
AsyncBroadcast (on NamedPipeServer) accepts a Byte() payload and issues AsyncWrite against every currently-connected NamedPipeServerConnection. Useful when the server has multiple concurrent connections and wants to push an update to all of them; the alternative is iterating the connections manually.