There are some tutorials about using named pipes in Delphi but after much searching during my own use of them, I wasn’t able to find any information to solve my problem, so here is what I learned.
First up, lets look at the problem… as I’m streaming more, I’m finding there are things I might like to do, sometimes automagically during my stream and whilst I find the Streamlabs OBS Remote Control app OK, it is just OK. It shows every scene in the list of available scenes and when you switch to that scene, it shows every item in it allowing you to switch them on and off, which is great until you get fat finger syndrome and turn on the webcam when you’re sat there naked… thankfully that hasn’t happened, but you get the point.
To address my requirements, I figured I would write my own stream helper app and interface directly to Streamlabs OBS (SLOBS) using their handy API. For this you have two options… web sockets using what is a poorly documented seemingly proprietary (to the sockJS library) protocol or a named pipes API. Both use JSON RPC but one I can connect to and the other I get rejected, based on the name of this article I’m sure you can guess which one I’m using 🙂
This is my first foray into named pipes and in some respects it’s been easier than I was expecting but in one respect, it’s been a complete nightmare. So lets look at some very simple example code.
01: function slobsRPC(message: string): string; 02: 03: function bufToStr(start:PByte;len:integer):string; 04: var 05: loop: integer; 06: begin 07: result := StringOfChar(#0,len); 08: for loop := 1 to len do 09: begin 10: result[loop] := chr(byte(start^)); 11: inc(start); 12: end; 13: end; 14: 15: procedure strToBuf(str:string;start:PByte); 16: var 17: loop: integer; 18: begin 19: for loop := 1 to length(str) do 20: begin 21: start^ := ord(str[loop]); 22: inc(start); 23: end; 24: end; 25: 26: var 27: inBuf: array[0..32767] of byte; 28: outBuf: array[0..4095] of byte; 29: bytesRead: DWORD; 30: handle: THandle; 31: begin 32: fillChar(inBuf,sizeOf(inBuf),0); 33: fillChar(outBuf,sizeOf(outBuf),0); 34: 35: strToBuf(message,@outBuf); 36: 37: handle := FileOpen('\\.\pipe\slobs', 2); 38: FileWrite(handle, outBuf, length(message)); 39: bytesRead := FileRead(handle,inBuf,sizeOf(inBuf)); 40: FileClose(handle); 41: 42: if (bytesRead > 0) then 43: begin 44: result := bufToStr(@inBuf, bytesRead); 45: end 46: else 47: begin 48: result := ''; 49: end; 50: end;
I’m sure there are routines in Delphi to do the conversions from byte array to string and vice versa but I was unable to find them readily so I drafted my own (which incidentally are still in my implementation). You will notice these are Delphi file handling routines.
From this simple example, which is designed to send a command (preformatted) and return the response, it’s fairly straightforward. This approach worked perfectly for many commands, but one of the great things about the API is that there are events to which you can subscribe. Such as when the active scene changes. Using the approach above, it’s possible to poll SLOBS and have it tell you the active scene, but how quickly do you poll to stay as up to date as possible without causing problems? What about other events you may be interested in?
A better solution was needed, and so I began crafting one.
The solution consists of an interface class (TSLOBSInterface). This will be used to issue commands such as though required to change the scene, or hide an item in a scene or mute an audio source. To handle the events, it uses a thread (TSLOBSComsThread) that connects to the named pipe itself, subscribes to the events and then uses windows messaging to pass the events out to TSLOBSInterface for processing in the main VCL thread. Once connected this was working brilliantly until I tried to shutdown.
The reason for this is fileRead.
There is no easy way to see (at least not that I could find) whether or not there was data waiting to be read on the pipe, so during normal execution the thread calls ‘fileRead’, and there it sits until data is available. There is no timeout, the procedure doesn’t return if you close the pipe handle elsewhere, and even cancelSynchronousIO failed, meaning I was left with a thread waiting for input from the pipe (and consequently an application that wouldn’t shut down except with the brute force of the debugger applying it’s ‘Reset’ hammer).
The solution I found was to switch entirely to the windows API file handling routines and use some specifically geared towards named pipes. This snippet shows the ‘createFile’ call I’m using to establish my connection with the pipe.
1: fPipe := createFile( 2: '\\.\pipe\slobs', 3: GENERIC_READ or GENERIC_WRITE, 4: FILE_SHARE_WRITE, 5: nil, 6: OPEN_EXISTING, 7: FILE_ATTRIBUTE_NORMAL, 8: 0);
Reading from the pipe is very similar to the fileRead version.
1: bResult := readFile(fPipe, inBuf, sizeOf(inBuf), bytesIn, nil);
Writing is also similar to the fileWrite version.
1: writeFile(fPipe, outBuf, length(msg), bytesWritten, nil);
So how did this fix the problem? Unfortunately, switching to windows API file handling didn’t… directly, but in reading up about it I came across this little gem.
1: setNamedPipeHandleState(fPipe, pipeMode, nil, nil);
Using this call, you can do some internal jiggery pokery on the pipe’s handle, including switching it’s behaviour to blocking (PIPE_WAIT) and non-blocking (PIPE_NOWAIT) during readFile operations.
01: procedure TSLOBSComsThread.pipeNoWait; 02: var 03: pipeMode: cardinal; 04: begin 05: pipeMode := PIPE_READMODE_BYTE or PIPE_NOWAIT; 06: setNamedPipeHandleState(fPipe, pipeMode, nil, nil); 07: end; 08: 09: procedure TSLOBSComsThread.pipeWait; 10: var 11: pipeMode: cardinal; 12: begin 13: pipeMode := PIPE_READMODE_BYTE or PIPE_WAIT; 14: setNamedPipeHandleState(fPipe, pipeMode, nil, nil); 15: end;
By switching the pipe to blocking when issuing commands (such as those the thread uses to subscribe to and un-subscribe from events) you can wait for the response, but then switch to non-blocking within the main thread process to have readFile return immediately if there is nothing available to read. Thus, the thread doesn’t get hung up and can be properly terminated.