System

This module is the portable operating-system layer for a process. This chapter covers the file system, threads and processes, event loops, I/O devices, logging, shared libraries, plugins, tar archives, and the clock.

This chapter covers:

File System Access

The systems programming library (Pt::System) provides functionality to identify, create, rename, move or remove files and directories. An iterator based API can be used to traverse through the entries in a directory. It can be used with the iterator based algorithms in the C++ standard library. The FileDevice is an implementation of the IODevice to read and write files.

File Information

The Pt::System::FileInfo class provides operations to query information about files and directories in the file system and to add, remove and modify them. FileInfo objects can be created with a path, are assignable, comparable and can be used as keys for e.g. std::map. The path needs not to refer to existing items in the file system, when a FileInfo object is constructed. It can be checked whether a file exists and what type of file it is, as shown in the following example:

Pt::System::Path path("/tmp/logout.txt");
Pt::System::FileInfo fi(path);
if( fi.type() == Pt::System::FileInfo::File )
{
std::cout << fi.path().toLocal() << ": " << fi.size() << " bytes.\n";
}
else
{
std::cout << fi.path().toLocal() << " is invalid.\n";
}

Most operations are available as non-member functions, so it is not neccessary to create temporary FileInfo objects. Only the paths to files or directories are required to perform file system operations. The next example illustrates some of the non-member functions for file operations:

try
{
Pt::System::Path tmp1("/tmp/tmpfile1");
Pt::System::Path tmp2("/tmp/tmpfile2");
// create a temporary file
Pt::System::FileInfo::createFile(tmp1);
// move it to a new location
Pt::System::FileInfo::move(tmp1, tmp2);
// remove the file
Pt::System::FileInfo::remove(tmp2);
}
catch(const Pt::System::AccessFailed& e)
{
std::cerr << "file error: " << e.resource() << std::endl;
}

The code shown above creates a file, moves it to a new location and finally deletes it. If an operation fails, for example because the file could not be created, an exception of type AccessFailed is thrown. This is also the case for all other operations such as size(), createFile(), createDirectory(), resize() and remove(). The exception reports the name of the resource that could not be accessed.

Directory Contents

The Pt::System::DirectoryIterator can be used to iterate over the contents of directories. It is created with a path to a directory and satisfies the requirements for a forward iterator. The iterator successivly reads the contents of the assoziated directory and returns a FileInfo when dereferenced. Like the stream iterators of the C++ standard library, it changes to a special state when the end of the directory is reached. This state is identical to a default constructed iterator, so instances thereof can serve as the iterator to the end of the directory.

try
{
Pt::System::Path path = "/tmp";
Pt::System::DirectoryIterator it(path);
Pt::System::DirectoryIterator end;
for( ; it != end; ++it)
{
std::cout << "name : " << it->path().toLocal() << std::endl;
}
}
catch(const Pt::System::AccessFailed& e)
{
std::cerr << "failed to access directory" << std::endl;
}

The constructor throws an AccessFailed exception if the path is not a valid directory. The exception can be avoided by checking the path with FileInfo::type() first, to make sure it is valid. The DirectoryIterator can then be advanced to get the FileInfo for the next file in the directory, until the end of the directory contents is reached.

File I/O

A Pt::System::FileDevice reads and writes files in the filesystem either synchronously or asynchronously. If used synchronously, it offers similar functionality like a std::fstream or the file I/O functions from the C library (fopen...). However, most applications need to perform input and output asynchronously, which makes using a FileDevice attractive. Since it inherits Pt::System::IODevice, it can be used as the endpoint of an Pt::System::IOStream or Pt::System::IOBuffer, respectively. If no buffering is required, a FileDevice can be used on its own, as shown in the following example:

void onOpen(Pt::System::FileDevice& file);
void onOutput(Pt::System::IODevice& device);
int main(int argc, char** argv)
{
try
{
Pt::System::MainLoop loop;
Pt::System::FileDevice file;
file.setActive(loop);
file.opened() += Pt::slot( &onOpen );
file.outputReady() += Pt::slot( &onOutput );
Pt::System::Path path = "tmpfile.txt";
file.beginOpen(path, std::ios::out);
loop.run();
return 0;
}
catch(Pt::System::IOError& e)
{
std::cerr << "I/O error: " << e.what() << std::endl;
}
return -1;
}

An EventLoop is required for all asynchronous operations. This includes not only reading and writing, but also opening the file. The function beginOpen() begins to open a file and the signal returned by opened() is sent when the file was opened. It is connected to the slot shown in the next example:

void onOpen(Pt::System::FileDevice& file)
{
file.endOpen();
file.beginWrite("Hello world!", 12);
}

The asynchronous open operation is ended by calling endOpen(), and a write operation is started with beginWrite() to write bytes from a buffer to the file. The signal outputReady() is sent, when data was written to the file. A slot is connected to that signal to handle output:

void onOutput(Pt::System::IODevice& device)
{
std::size_t n = device.endWrite();
std::cout << "wrote: " << n << "bytes." << std::endl;
}

The signal is inherited from IODevice, so the signature of the slot needs a reference to a IODevice as parameter. The write operation is ended by endWrite(), which returns the number of bytes written to the file. This might be less than what was requested by beginWrite(), in which case another write operation has to be started.

All functions to begin or end asynchronous operations throw an exception of type Pt::System::IOError on failure. If IOErrors are not catched and handled in the slots, they will propagate through the EventLoop into the main() function and end the program. Normally, larger applications will need to process errors in the slots, so the EventLoop is not stopped.

Concurrency

A process can run more than one thread of control. Thread is the portable thread. Construction does not start it. start() runs a Callable or an EventLoop. The thread must be joined or detached before it is destroyed. AttachedThread joins in its destructor. DetachedThread runs without a waiter and destroys itself when the entry returns.

Mutex serializes access to shared data. It is not recursive: the same thread must not lock it again. MutexLock locks in the constructor and unlocks in the destructor, including during stack unwinding. RecursiveMutex allows the owning thread to lock again. ReadWriteMutex allows concurrent readers or one writer. SpinMutex is for short critical sections. Atomic integers are documented with the core module.

Condition waits while a Mutex or MutexLock is held. wait() unlocks, suspends the caller, and relocks when the wait ends. signal() wakes one waiter. broadcast() wakes all. Semaphore counts. wait() decrements when the count is positive. post() increments.

Queue is a thread-safe FIFO. get() blocks while the queue is empty. put() blocks when a maximum size is set and the queue is full. A maximum of zero means no limit.

Process starts another program from ProcessInfo. start() runs it. wait() joins it. Redirected stdin, stdout, and stderr are IODevice endpoints.

Threads

Thread is the portable thread. Construction does not start it. start() runs the Callable passed to the constructor or to init(), or it runs an EventLoop. The object must be joined or detached before it is destroyed. Destroying a running joinable thread is an error.

join() blocks until the entry returns. detach() lets the thread run without a waiter. After detach(), join() is not used. sleep() suspends the calling thread. yield() gives up the rest of the time slice. exit() leaves the current thread.

The callable must outlive a joinable thread. AttachedThread joins in its destructor. DetachedThread detaches in the constructor and destroys itself when the entry returns.

class Work
{
public:
void run()
{
}
};
int main()
{
Work work;
Pt::System::AttachedThread thread( Pt::callable(work, &Work::run) );
thread.start();
return 0;
}

Mutexes

Mutex serializes access to shared data. lock() waits if another thread holds it. The same thread must not lock it again. The mutex must be unlocked when it is destroyed.

MutexLock locks in the constructor and unlocks in the destructor, including during stack unwinding.

class Counter
{
public:
Counter()
: _value(0)
{ }
void increment()
{
Pt::System::MutexLock lock(_mutex);
++_value;
}
private:
Pt::System::Mutex _mutex;
int _value;
};

Condition Variables

Condition waits while a Mutex is held. wait() unlocks that mutex, suspends the caller, and relocks when the wait ends. A timed wait returns false on timeout. signal() wakes one waiter. broadcast() wakes all.

Queues

get() returns the next element and blocks while the queue is empty. put() appends an element and blocks when a maximum size is set and the queue is full. A maximum of zero means no limit. Raising the maximum may wake a thread blocked in put().

Processes

Construct with a ProcessInfo that names the command, arguments, and how stdin, stdout, and stderr are handled. start() runs the program. wait() joins it and returns the exit status. tryWait() reports whether it has finished. kill() terminates it. stdInput(), stdOutput(), and stdError() return the redirected IODevice endpoints when redirection was requested.

Exceptions
SystemErrorif start(), kill(), or wait() fails.
ProcessFailedif wait() finds a non-normal termination.

Event Loops

Application is the console process root. There is one instance. It uses an EventLoop. run() enters that loop. exit() leaves it. Command line arguments, environment variables, the working directory, and C signals are Application services.

EventLoop is the dispatch core of a thread or process. It is an EventSink. commitEvent() queues an event and wakes the loop. queueEvent() queues without waking. wake() starts processing. Events are delivered on eventReceived in the thread that called run(). MainLoop is the platform EventLoop.

A Selectable attaches with setActive so the loop can wait for it. detach() removes it and cancels outstanding operations. Timer is not a Selectable. It registers with setActive and emits timeout at an interval.

EventSource sends events to EventSink objects in other threads. Signal is not thread-safe. EventSource is.

Console Applications

Application is the console process root. There is one instance per process. It uses an EventLoop. The default constructors create a MainLoop. A custom loop can be passed in. The application does not own a loop passed by the caller.

run() enters the loop. exit() leaves it. loop() returns the loop so timers and selectables can attach to it. instance() returns the single application.

Command line arguments are available through argc(), argv(), and getArg(). catchSystemSignal() reports a C signal on systemSignal. ignoreSystemSignal() leaves a signal to the default handler. chdir(), cwd(), rootdir(), and tmpdir() are the process directories. setEnvVar(), unsetEnvVar(), and getEnvVar() change the environment.

int main(int argc, char* argv[])
{
Pt::System::Application app(argc, argv);
app.run();
return 0;
}

Event Loop

EventLoop is the dispatch core of a thread or process. It monitors Selectable objects and Timer objects and delivers queued events. A process often runs one loop in the main thread. A second loop can run in another Thread.

The loop is an EventSink. commitEvent() queues an event and wakes the loop. queueEvent() queues without waking, so several events can be added and then released with one wake(). Events are delivered on eventReceived in the thread that called run(), in the order they were queued.

run() enters the loop and returns when exit() stops it. processEvents() delivers queued events without entering run(). exited is emitted when the loop leaves run(). Delivery always happens in the loop thread, including events queued from other threads.

Selectables

Selectable is the attachment between an asynchronous operation and an EventLoop. setActive() attaches it. The loop then monitors the operation. detach() removes it and cancels work that is still running. cancel() cancels without detaching. parent() is the loop.

run() executes the operation when the loop has marked it ready. post() asks the loop to run it from any thread. IODevice and IONotifier are selectables. Timer is not; it registers with the loop on its own.

The loop does not own the selectable. The caller keeps it alive while it is attached.

Timers

Timer emits timeout at a fixed interval. It is not a Selectable. setActive() registers it with an EventLoop. start() begins the interval from the moment it is called. stop() ends it. timeout is not sent until the timer is registered and started.

The interval can be changed while the timer runs. start() again replaces it. detach() removes the timer from the loop. The destructor emits no timeout after that.

void onTimeout()
{
}
Pt::System::MainLoop loop;
Pt::System::Timer timer;
timer.timeout() += Pt::slot(onTimeout);
timer.setActive(loop);
timer.start(1000);
loop.run();

Event Sources

Signal is not thread-safe and is for use inside one thread. EventSource sends a Event to connected EventSink objects and synchronizes connect, disconnect, and send. An EventLoop is an EventSink, so a source in one thread can queue events for a loop in another.

connect() adds a sink. disconnect() removes it. subscribe() limits a sink to one event type. unsubscribe() removes that limit. send() delivers to every connected sink that accepts the type.

I/O

IODevice is the endpoint for I/O. It is a Selectable. read() and write() block. beginRead() and beginWrite() run with an EventLoop. endRead() and endWrite() complete those operations. inputReady and outputReady fire when an asynchronous operation finishes. FileDevice is the file endpoint.

Pipe is a pair of IODevice objects. Bytes written to in() are read from out() in the same order. SerialDevice is a serial port with baud rate, parity, stop bits, and flow control.

IOBuffer is the stream buffer for an IODevice. IOStream, IStream, and OStream attach a device through that buffer.

IONotifier waits on a native handle or file descriptor in an EventLoop.

I/O Devices

IODevice is the endpoint for I/O. It is a Selectable. A file, a pipe end, and a serial port are IODevice types. I/O buffers and I/O streams use this endpoint, so a standard C++ stream can be built at runtime.

read() and write() transfer bytes and may return fewer than requested. They throw IOError on failure. When the device reaches the end of the stream, isEof() is true. setTimeout() limits how long a blocking transfer waits. close() releases the endpoint.

beginRead() and beginWrite() start an asynchronous transfer. The device must be attached to an EventLoop with setActive(). When the transfer finishes, inputReady or outputReady is emitted. endRead() and endWrite() complete the operation and return the number of bytes. Only one read and one write may run at a time.

Some devices can seek. seekable() reports that. seek() and position() throw IOError when the device cannot seek. peek() copies bytes without consuming them. sync() commits written data to the device.

class Reader
{
public:
explicit Reader(Pt::System::IODevice& device)
: _device(&device)
{
_device->inputReady() += Pt::slot(*this, &Reader::onInput);
}
void begin()
{
_device->beginRead(_buffer, sizeof(_buffer));
}
void onInput(Pt::System::IODevice& device)
{
device.endRead();
device.beginRead(_buffer, sizeof(_buffer));
}
private:
Pt::System::IODevice* _device;
char _buffer[256];
};

I/O Buffers

IOBuffer is a std::streambuf that reads and writes an IODevice. attach() binds the buffer to a device. detach() unbinds it. discard() drops buffered bytes. reset() discards and detaches.

beginRead() fills the get area from the device. beginWrite() drains the put area to the device. Those transfers run through the device's EventLoop. inputReady and outputReady fire when a transfer finishes. endRead() and endWrite() complete it. isReading() and isWriting() report a transfer in progress.

IStream, OStream, and IOStream own an IOBuffer. Formatted extraction and insertion then use the attached device.

I/O Streams

IOStream is a C++ iostream whose buffer is an IOBuffer. attach() binds an IODevice. Extraction and insertion then use that device. ioBuffer() returns the buffer so beginRead() and beginWrite() can run through the same EventLoop as the device.

The stream does not own the device. The caller keeps the device alive while the stream is attached. detach() unbinds it. discard() drops buffered bytes. reset() discards and detaches.

Pipes

A pipe is two IODevice objects created together. Bytes written to in() are read from out() in the same order. The constructor opens both ends. The destructor closes them.

Whether a write blocks until a read consumes data is system-dependent. Some platforms buffer a limited number of bytes between the ends. That buffer must not be assumed. Treat a write as complete only when write() or endWrite() returns.

Each end is a Selectable. Attach out() to an EventLoop to read without blocking the thread. Attach in() to write the same way.

Serial Devices

SerialDevice opens a serial port as an IODevice. The path is system-dependent, for example "COM1" on Windows or "/dev/ttyS0" on POSIX. Open the port, then set baud rate, character size, stop bits, parity, and flow control before read or write.

The device supports the blocking and asynchronous transfers of IODevice. Control lines are available as setRts(), setDtr(), isCts(), and isDsr(). setBreak() and sendBreak() generate a break condition.

Pt::System::SerialDevice device("COM1", std::ios_base::in);
device.setBaudRate(Pt::System::SerialDevice::BaudRate9600);
device.setCharSize(8);
device.setStopBits(Pt::System::SerialDevice::OneStopBit);
device.setParity(Pt::System::SerialDevice::ParityNone);
device.setFlowControl(Pt::System::SerialDevice::FlowControlNone);
char buffer[64];
std::size_t n = device.read(buffer, sizeof(buffer));

I/O Notifiers

IONotifier is a Selectable that reports activity on a native endpoint the rest of the I/O API does not own. setHandle() names a Windows handle. setFd() names a POSIX file descriptor. The constructor can pass either.

Attach the notifier to an EventLoop with setActive(). beginWait() starts monitoring for a combination of WaitFlags. When the loop sees activity, eventReady is emitted. endWait() returns the flags that are ready. reset() clears the handle or descriptor.

Use this type when the endpoint is not an IODevice: a socket created outside Pt, or a handle from another library.

Logging

The logging framework offers an efficient, extensible system to log messages from programs with multiple threads to a number of channels. Logging can be completely disabled at compile time, when the logging macros are used. At runtime, log messages are filtered by a level of severity. Filtering is very efficient, because log messages are not even built if their log level is too low. Currently three types of output channels exist, logging to files with file rolling, to the console and to the serial port. The logging framework can be extended by new channels.

Logging is an important feature of many applications. It can be used for debugging during the development process and to trace how a program executes once it is deployed. Crucial features of a logging framework are:

  • Performance
    High peformance can only be achieved if formatting of log records is avoided for disabled log statements. The costs boil down to an atomic integer comparison, and a branch. Further, logging can be disabled at compile time with via the preprocessor, in which case no instruction at all will be generated.

  • Thread-safety
    It is obvious that any modern library needs to be thread-safe. However, great care needs to be taken that synchronization overhead does not affect performance.

  • Easy Formatting
    In order to minimize the impact of a logging framework on the size of the source code, logging statements should be easy to write. This library allows to format log records with stream output operator just like for std::ostream.

  • Configuration
    The logging system should be configurable by both, API calls and configuration files. It must provide fine grained control in which parts of the application logging is enabled. It must be possible route the log records to different logs, for example the console or log files. The format of the records in the log should be configurable, and only contain the required information, which leads to smaller logs and a higher performance.

  • Usable during static initialization and deinitialization
    For example, logging statements may be executed indirectly when global objects are constructed or destructed. This should result in a static initialization fiasco.

  • Portability
    This library is extremly portable and has minimal dependencies. It requires only standard C++ and a standard C++ library.

The heart of the logging framwork is a hierarchy of log targets, which have a unique string ID. Applications format log records and use logger objects to write them to a target. Each target is configured with a threshold log level. Only records that are equally or more severe than the threshold are logged. In this case, the log records are written to the targets channel. Targets can log to the same channel, in fact, often all targets log to the same channel. The log channels perform output of log records i.e. to the console, if a console channel is selected or to a file if a file channel is selected, respectively. The threshold log level and channel of each log target in the hierarchy can be configured at runtime. Here is a typical example:

                       <root> id: ""
                         |    channel: console://
                         |    level: info
                         |
                         |
                       <app> id: "app"
                         |   channel: INHERIT
                         |   level: INHERIT
                         |
                         |
    .--------------------+-------------------.                
    |                                        |      
    |                                        |
<module1> id: "app.module1"              <module2> id: "app.module2"
          channel: file:///log.txt                 channel: INHERIT
          level: trace                             level: INHERIT

In this setup, three log targets are created. The root target is always persent and has the special empty string ID. The root target has one direct child, the target named "app". In this case, no log channel and level have been explicitly set for the target, so it inherits the attributes from its parent. The target "app" has two children, named "app.module1" and "app.module2". The string IDs indicate the position (path) of the target in the hierarchy. The target "app.module2" inherits all attributes from it parent, while the target "app.module1" overrides the log channel and level. With this mechanism based on inheritance, the parts of interest of the hierarchy can be enabled, while other parts of the hierarchy are suppressed.

Applications can use the API to set the threshold log levels and the channels of the targets. Possible log levels are:

Channels are configured by a channel URL. Possible channel URLs are:

The first opens a file channel writing to the file mylog.log. If the file size of 1000000 bytes is reached it will be renamed, and logging continues to a newly created mylog.log file. The parameter 'files' limits file rolling to a total number of five files. The second URL opens a channel to log to the console.

The format of the logging records can be configured with a format pattern string. The format pattern can contain text and specifiers, which are placeholders for the various elements of the log records. Specifiers are escaped with a percent sign in the format pattern string. For example, the pattern "%t %m" would write the time and the message for each log record separated by a space.

Here is a list of possible specifiers:

  • %c logging category
  • %d current date
  • %l log level (severity)
  • %m message text
  • %t current time
  • %F file where the record was logged
  • %L line number where the record was logged
  • %M method/function where the record was logged

TODO:

  • %T thread id
  • %P process id

The following code example above changes the log level and channel of the target named "app" to write records with a threshold level of Pt::System::Info to a log file:

Pt::System::Logger::setLogLevel("app", Pt::System::Info);
Pt::System::Logger::setChannel("app", "file:///myfile.log");
Pt::System::Logger::setPattern("[%c] %t - %m");

A log record pattern is applied to print the target ID, the time and the message text of the records to the logs.

Loggers

The Logger is the central class of the logging framework on the client side. It is used to write log records to a logging target maintained by the logging framework. A logger is created with a category string that identifies it's target to log to. The category string is in dot syntax, so the category string "app.module" refers to the target named "module", which is a child of the target named "app", which is a child of the root target. See LogTarget for more information.

If the target of a logger does not exist yet, it will be created. If several loggers are created with the same target string they will indeed use the same target. The creation of a logger requires a target lookup in the logging manager, so it is beneficial to keep created loggers at the class level for as long as they are needed. The logger provides a set of static methods to configure targets. The following code configures a log level and a channel for a target named "app.module":

Pt::System::Logger::setLogLevel("app.module", Pt::System::Info);
Pt::System::Logger::setChannel("app.module", "console://");

Channels and log levels can either be assigned by the API or in the settings file that is loaded by calling Pt::System::Logger::init. Here is an example of a settings file:

foobar.channel = console://
foobar.pong.logLevel = Trace
foobar.ping.channel = file:///log.txt
foobar.ping.logLevel = Error

In the example, pong would write messages with a log level of Trace or higher to the console channel, which it inherited from its parent. The target ping writes messages with a log level of Error of higher to a file.

The main purpose of the logger is to route log records to its target. If the log level of the record is less severe than the current log level of the target, the logger will discard the record. If a target has a log level of Info, the logger will reject records with the levels Trace or Debug. See LogRecord and LogMessage for more information.

Macros are provided to make logging more convenient. The easiest way to log is to use the logging macros for a static logger instance. A logger instance can be defined for a compilation unit and then logged to:

PT_LOG_DEFINE("app.module")
void foobar(int n)
{
PT_LOG_INFO("foobar was called with: " << n);
}

The advantage of the macros is that they will expand to nothing if NLOG is defined, so logging support can be conditionally compiled. Macros exist for the various log levels:

  • PT_LOG_FATAL()
  • PT_LOG_ERROR()
  • PT_LOG_WARN()
  • PT_LOG_INFO()
  • PT_LOG_DEBUG()
  • PT_LOG_TRACE()

The disadvantage of these macros is that they do not allow more than one logger instance per compilation unit and the logger definition can not be placed in a public header file. A similar set of macros allows to define global logger instances:

#include <Pt/System/Logger.h>
PT_LOG_DEFINE_INSTANCE(mullog, "app.calc.multiply")
PT_LOG_DEFINE_INSTANCE(divlog, "app.calc.divide")
int multiply(int a, int b)
{
PT_LOG_INFO_TO(mullog, "multipy " << a << " by " << b)
return a*b;
}
int divide(int a, int b)
{
PT_LOG_INFO_TO(divlog, "divide " << a << " by " << b)
return a/b;
}

Again, these logging statements can be conditionally compiled and the logging macros expand to nothing if NLOG is defined. Here is a list of the macros for the various log levels:

  • PT_LOG_FATAL_TO()
  • PT_LOG_ERROR_TO()
  • PT_LOG_WARN_TO()
  • PT_LOG_INFO_TO()
  • PT_LOG_DEBUG_TO()
  • PT_LOG_TRACE_TO()

The alternative to using the macros is to use logger objects directly in the code. To do so, a Logger object must be instanciated, for example as a class member variable and then messages can be written to it:

#include <Pt/System/Logger.h>
class Calculator
{
public:
Calculator()
: _logger("app.calc")
{}
int multiply(int a, int b)
{
Pt::System::LogMessage(_logger, Pt::System::Info)
<< "multipy " << a << " by " << b << Pt::System::endlog;
return a*b;
}
private:
Pt::System::Logger _logger;
};

Note that the code above is somewhat inefficient, because the log record is formatted even if its log level is below the threshold of the logger target. It is more efficient if the log level is checked before the record is formatted:

if( _logger.enabled(Pt::System::Info) )
{
Pt::System::LogMessage(_logger, Pt::System::Info) << "multipy " << a << " by " << b;
}

The library offers a set of macros that can be used with logger instances and implement this check:

PT_LOGGER_BEGIN_INFO(_logger) << "multipy " << a << " by " << b;

Here is the list of the available macros:

  • PT_LOGGER_BEGIN_FATAL()
  • PT_LOGGER_BEGIN_ERROR()
  • PT_LOGGER_BEGIN_WARN()
  • PT_LOGGER_BEGIN_INFO()
  • PT_LOGGER_BEGIN_DEBUG()
  • PT_LOGGER_BEGIN_TRACE()

Expansion of these macros is not affected by defining NLOG. These macros can not be used with loggers defined by the previous macros, because construction of global loggers is not trivial when the the static initialization fiasco has to be avoided. Definition of global loggers with the macros does not simply result in global variables.

Log Messages

Log messages can be used to log records with a specific logger. They maintain a log record and a reference to a logger. The log record text can be formatted with the stream output operator, just like for the log records itself:

Pt::System::Logger logger("app.module");
Pt::System::LogMessage msg(logger, Pt::System::Info);
if(msg)
{
msg << "Pi is exactly " << 3
<< " No, I was kidding, its really " << 3.1415
<< Pt::System::endlog;
}

To avoid the costs for formatting, it can be checked if the log level is enabled for the target. A log message can be send mutliple times, so formatting has to be done only once and the logging performance can be increased.

Although it generates more code, it can still make sense to work with log message and log record objects directly. These can improve performance, if the same record has to be logged multiple times, because formatting has to be done only once:

Pt::System::Logger logger("app.module");
Pt::System::LogMessage msg(logger, Pt::System::Info);
msg << "pi is: " << 3.1415;
// log for the first time...
msg.log();
// ... and later the second time
msg.log();

Log Records

Log records represent the text entries that can be added to log. Each log record has a log level, which indicates it's severity. The text of a log record can be formatted with the stream operator, just like writing to std::cout. All stream output operators defined for std::ostream can be used including the manipulators.

Pt::System::LogRecord record(Pt::System::Info);
record << "pi is: " << 3.1415;

Once a log record is created it can be added to a log with a logger. The record might be ignored if the log level is disabled for the logger's target. Unneccessary formatting can be avoided by checking if the record's log level is enabled for the logger's target.

Pt::System::Logger logger("app.module");
Pt::System::LogRecord record(Pt::System::Info);
if( logger.enabled(record) )
{
record << "pi is: " << 3.1415;
logger.log(record);
}

The same log record can be sent multiple times to a logger or to several loggers. This way formatting is only done once and logging performance can be increased.

Similarily, a log record can be sent multiple times to a logger or to several loggers:

Pt::System::Logger logger1("app.module1");
Pt::System::Logger logger2("app.module2");
Pt::System::LogRecord record(Pt::System::Info);
record << "pi is: " << 3.1415;
logger1.log(record);
logger2.log(record);

Shared Libraries

A process can load a shared library after it has started and resolve symbols from it. Library is the portable loader. It opens a library image and returns symbols by name.

typedef int (*MyFunc)();
Pt::System::Path libPath = "MyLib";
Pt::System::Library library(libPath);
Pt::System::Symbol symbol = library.getSymbol("myFunction");
MyFunc func = reinterpret_cast<MyFunc>(symbol.sym());
int result = func();

The path "MyLib" is a basename. Construction and open() look for a file at the given path. If none is there, the path is extended by the platform suffix, then by the shared-library prefix. A basename is enough. An AccessFailed exception is thrown when no image is found. A second open() may close the image loaded before.

prefix() and suffix() return the platform naming pieces so a portable library name can be built without relying on that search. suffix() is ".so" on Linux and ".dll" on Windows. prefix() is "lib" on Linux and empty on Windows.

Pt::System::Path libPath = Pt::System::Library::prefix();
libPath += "MyLib";
libPath += Pt::System::Library::suffix();

getSymbol() returns a Symbol when the name exists. It throws SymbolNotFound otherwise. The address is Symbol::sym(). A Symbol holds the Library that produced it, so the image stays loaded while the symbol exists.

The index operator and resolve() return a void pointer, or a null pointer when the name is missing. They do not throw. getSymbol() treats a missing name as an error. The index operator leaves that test to the caller.

Standard C++ does not allow a cast from a void pointer to a function pointer. Nearly all runtimes implement that as an extension. The caller casts Symbol::sym() to a function pointer to call it.

Plugins

A plugin is a shared library. The application keeps the interface type. The concrete class is compiled into the library. No extra base class is required beyond that interface.

The library exports a null-terminated array of PluginId named PluginList. The export uses C linkage so the loader can resolve a stable symbol. PluginManager looks up that name through a Library.

Each entry is a Plugin that creates and destroys instances of one interface. BasicPlugin is the usual plugin. The first template argument is the concrete class. The second is the interface it implements. It constructs with new and destroys with delete.

The constructor takes a feature string that names the instance for later construction. The address of the BasicPlugin is placed in PluginList.

Several plugins may share one PluginList. A second concrete class for the same interface is another BasicPlugin in the same array.

A plugin that needs another allocator derives from Plugin and overrides create and destroy.

class Greeter
{
public:
virtual ~Greeter() {}
virtual void sayHello() const = 0;
};
class EnglishGreeter : public Greeter
{
public:
void sayHello() const
{ std::cout << "Hello World\n"; }
};
class OtherGreeter : public Greeter
{
public:
void sayHello() const
{ std::cout << "Hi\n"; }
};
static Pt::System::BasicPlugin<EnglishGreeter, Greeter> _enGreeter("en");
static Pt::System::BasicPlugin<OtherGreeter, Greeter> _otherGreeter("other");
extern "C"
{
Pt::System::PluginId* PluginList[] = { &_enGreeter, &_otherGreeter, 0 };
}

PluginManager loads a PluginList through a Library and creates instances by feature. The application loads and unloads plugins through the manager.

Pt::System::PluginManager<Greeter> manager;
manager.loadPlugin("PluginList", "/path/to/plugin.so");
Greeter* greeter = manager.create("en");
if(greeter)
{
greeter->sayHello();
manager.destroy(greeter);
}

The instance is created and destroyed through the manager, not with delete. Loaded libraries unload when the manager is destroyed.

Plugin Interfaces

Plugin is one entry in a PluginList. The template argument is the interface it implements.

PluginId names the interface type, the feature string, and an info string. feature() is the name used later to construct an instance. info() is an optional description.

create() returns a new instance. destroy() releases that instance. They are the allocator for objects that come from the plugin.

PluginManager registers matching plugins and creates instances by feature.

Plugin Managers

PluginManager loads a PluginList from a Library and creates instances of one interface. The first template argument is that interface.

loadPlugin() opens the library and resolves the export name. The name PluginList is the usual export. Another symbol name is valid if the library exports that array.

A loaded plugin is registered when its interface matches the manager. Entries for other interfaces in the same PluginList are skipped. Plugins that already live in the process can be registered with registerPlugin(). They do not require a shared library.

destroy() must be used instead of delete. The allocator in the library may differ from the application. Creation and deletion stay on the same plugin. Remaining instances are destroyed and loaded libraries are unloaded when the manager is destroyed. Instance lifetime must not exceed the manager.

Iteration walks the loaded plugins without creating instances. create() also accepts an iterator from that walk.

Tar Archive I/O

A tar archive is a sequence of members laid out in fixed 512-byte blocks. Each member is a header followed, for a regular file, by the file's bytes padded out to the next block. Two blocks of zeros mark the end. This group reads and writes that sequence in the POSIX Pax/UStar form: a UStar header for the fields that fit, and a Pax extended header in front of the member for the fields that do not.

Three types cover the task. TarEntry is one member: its path, its kind, its size, its link target, its permissions, its modification time, and its owner and group. TarReader pulls entries out of a std::istream, one header or one content chunk per call, and can stop when the stream has nothing further buffered. TarWriter appends entries to a std::ostream. The entry is the value that crosses both boundaries, so metadata read from an archive can be handed to a writer and stored again.

The on-disk form is an implementation choice the caller does not make. A path or a link target of at most 99 bytes, an ASCII owner or group name of at most 31 bytes, a numeric id up to 07777777, a size of at most eleven octal digits, and a modification time in whole seconds all live in the UStar header. Anything beyond that — a longer path, a name that is not ASCII, a larger id, a larger size — is written as a Pax record and applied by the reader before the entry is published. Pax wins where both forms carry the same field. A global Pax header, which would change the defaults for every following member, is skipped. There is no writer-wide default identity either: each entry carries its own owner and group, and an entry that leaves them unset stores neither an id nor a name.

Names are Unicode in the API and UTF-8 in the archive. Numeric owner and group ids use TarEntry::NoId for "not present", which is distinct from id 0. Id 0 is the root user or the root group and is a real value. Permissions use the POSIX mode bits from FileInfo::Perms, including set-user-ID, set-group-ID, and the sticky bit. The modification time is a Pt::DateTime in UTC. A default-constructed time is the Unix epoch, and that is what gets stored when the caller does not set one.

The archive describes ownership. It does not apply it. Nothing in this group creates a directory, writes a file, or changes the owner of a file-system object. A caller who wants that uses the path, permissions, and timestamps from the entry with the file-system API, and decides separately what to do with an owner id that the local system may not have.

std::ofstream out("tree.tar", std::ios::binary);
TarWriter writer(out);
TarEntry dir;
dir.setPath(Path("docs"));
dir.setPermissions(FileInfo::OwnerAll);
dir.setOwnerId(1000);
dir.setOwnerName(String("marc"));
writer.addDirectory(dir);
TarEntry page;
page.setPath(Path("docs/index.txt"));
page.setSize(body.size());
page.setPermissions(FileInfo::OwnerRead);
page.setMtime(DateTime(2024, 6, 1, 12, 0, 0));
page.setOwnerId(dir.ownerId());
page.setOwnerName(dir.ownerName());
writer.addFile(page, body.data(), body.size());
writer.finish();
out.close();
std::ifstream in("tree.tar", std::ios::binary);
TarReader reader(in);
while( ! reader.isEnd() )
{
const TarEntry* entry = reader.advance(4096);
if( ! entry )
break;
// entry->ownerId() and entry->path() describe the member.
// The content window is entry->data() / entry->avail().
}

Archive Entry

A TarEntry is the metadata of one member of a tar archive, together with a window onto that member's content. TarReader publishes an entry after it has parsed the header. TarWriter consumes an entry the caller has filled in. The same type travels in both directions, so a value read from one archive can be handed to a writer and reproduced, including owner, group, permissions, and modification time.

The metadata is the part the caller owns. path() is the name inside the archive. type() distinguishes a regular file, a directory, a symbolic link, and a hard link. A link also has linkTarget(). size() is the content length stored in the header. permissions() holds the POSIX permission bits, including the set-user-ID, set-group-ID, and sticky bits. mtime() is the modification time in UTC. A default-constructed Pt::DateTime means the Unix epoch, and that is what the writer stores when the caller never sets a time.

Owner and group are each an optional numeric id plus an optional name. NoId means the archive did not carry an id, or the caller has not set one. Numeric id 0 is a real value: it is the root user or the root group, and it is written and read as such. An empty Pt::String means the archive did not carry a name. A name that happens to be root is a real name and round-trips. The writer omits an unset id and an empty name. It does not invent root or id 0. Names are Unicode. The archive stores them as UTF-8, in the UStar header when they fit and in a Pax extended header otherwise.

The content window is the part TarReader fills in. After TarReader::advance() returns an entry, up to avail() bytes are readable at data(). Those bytes must be consumed before the next call to advance(), because the reader reuses its buffer and the pointer then refers to a different chunk, or to nothing. isEnd() becomes true once every byte of size() has been delivered. A directory, a symbolic link, and a hard link have no content, so isEnd() is already true when the entry is first published. TarWriter does not read the content window. The bytes of a file are the buffer passed to TarWriter::addFile(), or the chunks passed to TarWriter::writeFile(). size() on the entry is the length the writer declares in the header, and for a streamed file it is the total that writeFile() must deliver before endFile().

An entry returned by the reader stays valid until the next advance() call, which replaces its metadata and its content window. A caller who wants to keep an entry copies the fields out. The content pointer cannot be kept: it addresses the reader's buffer.

Reading Archives

A TarReader walks a Pax/UStar archive held by a std::istream. Each call to advance() does a bounded amount of work and then returns, so the same reader serves a file that is already complete and a socket that delivers the archive in pieces. The caller asks for the next step, processes what came back, and asks again.

advance() returns a TarEntry once a member header has been parsed, and returns the same entry again for each following content chunk. The pointer is null when the stream has no more bytes yet. That is a pause, not the end of the archive. The caller waits for more input and calls advance() again with the same reader. isEnd() becomes true only after the two zero blocks that mark the end of the archive have been read. A null return together with isEnd() means there is nothing further to read.

The entry stays valid until the next advance() call. Its metadata is complete on the first return for that member: path, type, size, link target, permissions, modification time, owner id and name, and group id and name. A Pax extended header that preceded the member is already applied, so the caller never sees the Pax header as an entry of its own. A global Pax header is skipped. A member type the reader does not recognise is skipped as well, including its content, and the next recognised member is what comes back.

Content arrives through the entry's window. TarEntry::data() points at TarEntry::avail() bytes inside the reader's buffer. Copy those bytes out before the next advance(), which reuses the buffer. For a file, repeat until TarEntry::isEnd() is true, then call advance() once more to move to the following member. A directory and a link have no content, so isEnd() is already true on the first return.

The importSize argument bounds how much advance() may pull from the stream. Zero means: use only the bytes the stream buffer already holds, and do not block. That is the right call from an event loop, after the loop has been told that the stream is readable. A positive value allows advance() to read up to that many additional bytes, which may block, and suits a file stream whose data is known to be available.

A header whose checksum does not match throws Pt::IOError. The archive is then unusable from the point of the bad header, because the reader can no longer trust the block boundaries.

TarReader reader(stream);
while( ! reader.isEnd() )
{
const TarEntry* entry = reader.advance();
if( ! entry )
break; // stream starved; call advance() again when more arrives
if(entry->type() == TarEntry::File)
{
do
{
out.write(entry->data(), entry->avail());
if(entry->isEnd())
break;
entry = reader.advance();
}
while(entry);
}
}

Writing Archives

A TarWriter appends archive members to a std::ostream, in order, and blocks on each write. The caller builds a TarEntry for the metadata and passes that entry to the writer. The writer does not invent an owner, a group, or a modification time. Whatever the entry carries is what the archive stores, and whatever the entry leaves unset stays unset.

A complete member is one call. addFile() writes a regular file whose bytes are already in memory. addDirectory() writes a directory. addSymlink() and addHardlink() write a link, and they take the link target, the permissions, the modification time, and the owner and group from the entry, the same fields a file carries. The type flag in the archive comes from the method, so the caller does not have to set TarEntry::type(). The path, the size, and the link target do have to be set, because those are the member's identity.

A file too large to hold in one buffer is written in three steps. beginFile() writes the header and declares TarEntry::size() as the total number of content bytes. writeFile() delivers those bytes in as many chunks as needed. endFile() writes the padding that rounds the member up to a 512-byte block, and it fails if the chunks so far do not add up to the declared size. Starting another member, or calling finish(), while a streamed file is still open fails the same way: the open member has to be closed first.

finish() writes the two zero blocks that mark the end of the archive. A reader treats an archive without that marker as truncated, so finish() belongs before the stream is closed.

Names, ids, sizes, and times that fit in a UStar header are stored there. A path or a link target longer than 99 bytes, a name that is not ASCII, an owner or group name longer than 31 bytes, a numeric id above 07777777, or a size above eleven octal digits is stored in a Pax extended header that precedes the member. The caller does not choose the form. Reading the result back yields the same TarEntry fields either way.

The writer throws Pt::IOError when the stream is missing or a write fails, when writeFile() is asked for more bytes than the header declared, when endFile() is called before those bytes have all been written, and when a new member is started while a streamed file is still open.

std::ofstream out("archive.tar", std::ios::binary);
TarWriter writer(out);
TarEntry dir;
dir.setPath(Path("src"));
dir.setPermissions(FileInfo::OwnerAll);
writer.addDirectory(dir);
TarEntry file;
file.setPath(Path("src/main.cpp"));
file.setSize(source.size());
file.setPermissions(FileInfo::OwnerRead);
file.setMtime(DateTime(2024, 6, 1, 12, 0, 0));
file.setOwnerId(1000);
file.setOwnerName(String("marc"));
writer.addFile(file, source.data(), source.size());
writer.finish();

System Clock

Clock measures elapsed time and reports the system clock. start() begins a measurement. stop() returns the Timespan since start(). A second start() begins a new measurement.

getSystemTime() returns the current UTC time as a DateTime. getLocalTime() returns the local wall time. These values follow the system clock, including adjustments.

getSystemTicks() returns the timespan since a fixed point in the past. That origin is platform-defined. It may be boot time or an epoch. Differences between two tick values are monotonic and are the right way to measure elapsed time across threads.