Call IBM i (AS/400) Service Program Procedures in C# (.NET) with NTi

Introduction

This tutorial shows how to call an exported procedure of an IBM i (AS/400) service program from a C# (.NET) application, with the CallServiceProgram and CallServiceProgramAsync methods of NTi Data Provider (available from NTi 5.0.0).

CallProgram covers programs (*PGM). But a large share of modern ILE logic, and many system APIs, ship as service programs (*SRVPGM): libraries of procedures that a classic CALL cannot reach. CallServiceProgram closes that gap: you name the service program, the export and the parameters, NTi does the rest.

On the agenda:

  • what a service program and an ILE export are, seen from .NET
  • the QZRUCLSP system API the call relies on
  • two complete examples validated against a real server: gethostname then putenv
  • the parameter formats (ByReference, ByValue) and return formats (None, Integer, IntegerAndErrno)
  • export name casing, limits and error handling

Service programs and ILE exports, for a .NET developer

A service program (a *SRVPGM object) is the IBM i equivalent of a shared library: a .NET DLL or a Linux .so. It does not run on its own; it exposes procedures (ILE functions written in RPG, C, COBOL or CL) that other programs bind to and call. The list of externally visible procedures is the export table, which you can inspect with the CL command DSPSRVPGM SRVPGM(MYLIB/MYSRVPGM) DETAIL(*PROCEXP).

Each export has a name, and it is that name, in its exact case, that NTi sends to the server. Unlike a program, a procedure has a real signature: it receives its arguments by value or by reference, and can return a value. CallServiceProgram mirrors those three notions: parameter formats, return format, export name.


The QZRUCLSP API under the hood

Server side, NTi relies on the Call Service Program Procedure (QZRUCLSP) system API: it resolves the service program, looks the name up in the export table, performs the bound call and hands back the return value. NTi composes the call for you (encoded null-terminated export name, format array, return receptacle): on the wire, a CallServiceProgram is an ordinary program call to QSYS/QZRUCLSP.

💡 Like every program call, it executes in the connection's command server job (QZRCSRVS), distinct from the SQL job (QZDASOINIT): QTEMP, CURLIB and environment variables are those of that job. The conn.DatabaseJob and conn.CommandJob properties identify both jobs.


The CallServiceProgram method

NTiProgramParameter? CallServiceProgram(
    string library, string serviceProgram, string procedureName,
    IList parameters,
    NTiServiceProgramReturnFormat returnFormat = NTiServiceProgramReturnFormat.None,
    int procedureNameCcsid = 37);

CallServiceProgramAsync has the same signature, plus a trailing CancellationToken, and rides the same true asynchronous path as CallProgramAsync.

  • library / serviceProgram: library and service program (1 to 10 characters, folded to uppercase; quoted names are not supported)
  • procedureName: the export name, case sensitive (see below)
  • parameters: up to 7 NTiProgramParameter
  • returnFormat: None (default), Integer or IntegerAndErrno
  • procedureNameCcsid: single byte CCSID used to encode the export name, 37 by default

The method returns a receptacle NTiProgramParameter carrying the procedure's return value, or null when returnFormat is None.


Parameter formats: ByReference and ByValue

Every NTiProgramParameter carries a ServiceProgramParameterFormat property (ignored by CallProgram):

Format Semantics Constraint
ByReference (default) the procedure receives the address of the parameter storage: it can read it and write into it none: strings, structures, buffers of any size
ByValue the argument is a BINARY(4) integer passed by value the data must be exactly 4 bytes; never output only

In practice: an int declared by value in the C or RPG prototype (a length, a descriptor, flags) is passed ByValue through new NTiProgramParameter(n), whose constructor produces exactly a BINARY(4). Everything else (strings, structures, buffers the procedure fills) stays ByReference.


Return formats

NTiServiceProgramReturnFormat The procedure returns Reading
None nothing (void) the call returns null
Integer a 4 byte integer rc.GetInt()
IntegerAndErrno a 4 byte integer, along with the errno value rc.GetInt(), then rc.GetInt(4) for errno

IntegerAndErrno is the natural format of the system's UNIX-style APIs, which signal failure with a -1 return and detail the cause in errno.


Example 1: gethostname (QSYS/QSOSRV1)

The gethostname() procedure, exported by the QSOSRV1 service program in QSYS, illustrates mixed formats. Its C prototype:

int gethostname(char *name, socklen_t namelen);

That is: a caller supplied buffer, passed by reference, that the procedure fills with the host name (a null-terminated C string); the buffer length, passed by value; an integer return (0 = success).

using System;
using System.Collections.Generic;
using System.Data;
using Aumerial.Data.Nti;

await using var conn = new NTiConnection("server=serverName;user=userName;password=password");
await conn.OpenAsync();

// char *name: 64 byte buffer filled by the procedure (ByReference, the default)
var buffer = new NTiProgramParameter(new byte[64], ParameterDirection.InputOutput);

// socklen_t namelen: integer passed BY VALUE (BINARY(4), exactly 4 bytes)
var length = new NTiProgramParameter(64, ParameterDirection.Input)
{
    ServiceProgramParameterFormat = NTiServiceProgramParameterFormat.ByValue
};

var rc = await conn.CallServiceProgramAsync("QSYS", "QSOSRV1", "gethostname",
    new List { buffer, length },
    NTiServiceProgramReturnFormat.Integer);

if (rc is null || rc.GetInt() != 0)
    throw new InvalidOperationException("gethostname failed");

// The buffer holds a null-terminated C string: read up to the first 0x00
byte[] data = buffer.GetBytes();
int end = Array.IndexOf(data, (byte)0);
string host = buffer.GetString(0, end < 0 ? data.Length : end);
Console.WriteLine($"Host name: {host}");

Synchronously, same signature without the token: var rc = conn.CallServiceProgram("QSYS", "QSOSRV1", "gethostname", ...);.


Example 2: putenv (QSYS/QP0ZCPA)

The putenv() procedure, exported by QP0ZCPA in QSYS, sets a job environment variable. C prototype:

int putenv(const char *string);

It expects a null-terminated "VAR=value" C string, passed by reference. The C string composes naturally with Append: the text encoded in EBCDIC, followed by a 0x00 byte:

using System;
using System.Collections.Generic;
using System.Data;
using Aumerial.Data.Nti;

await using var conn = new NTiConnection("server=serverName;user=userName;password=password");
await conn.OpenAsync();

// Null-terminated C string: EBCDIC text (CCSID 37), then a 0x00 byte
const string assignment = "NTIVAL=DEMO";
var value = new NTiProgramParameter(assignment, assignment.Length, 37, ParameterDirection.Input)
    .Append(new byte[] { 0x00 });

var rc = await conn.CallServiceProgramAsync("QSYS", "QP0ZCPA", "putenv",
    new List { value },
    NTiServiceProgramReturnFormat.IntegerAndErrno);

if (rc is null || rc.GetInt() != 0)
    Console.WriteLine($"putenv failed: return {rc?.GetInt()}, errno {rc?.GetInt(4)}");
else
    Console.WriteLine("Variable set in the command job");

💡 The variable is set in the command job (QZRCSRVS): an RPG program called afterwards on the same connection reads it with getenv(). The SQL job does not see it: the two jobs of a connection each have their own environment, their own QTEMP and their own CURLIB.


Export name: case matters

Unlike object names (library, service program), which are folded to uppercase, the export name is sent as is and matched byte by byte against the export table: gethostname and GETHOSTNAME are two different exports. That is the QZRUCLSP contract, not an NTi convention.

The name is encoded in CCSID 37 by default. If the export table was generated in another code page (names containing #, @, $ or national characters), pass the right single byte CCSID through procedureNameCcsid; a multi byte CCSID is rejected with an explicit error.


Limits

  • 7 parameters maximum. Beyond that, QZRUCLSP switches to a different calling convention (everything by pointer) that NTi does not cover: the call is refused with a clear error rather than composing a hazardous frame.
  • No pointer return value. A server space pointer has no meaning on the client: it references memory of a job on the IBM i, not memory of your .NET process. Procedures that "return" text are consumed with the supplied buffer pattern: the caller passes a ByReference buffer that the procedure fills, like gethostname above.
  • ByValue = exactly 4 bytes. Only BINARY(4) integers pass by value; any other size is rejected before the call.

Error handling

A failure (service program not found, export missing from the table, error raised by the procedure) surfaces as a NTiCommandException, with the server message stack in Messages. A missing export, for instance, produces the CPF226E message:

using System;
using System.Collections.Generic;
using Aumerial.Data.Nti;

await using var conn = new NTiConnection("server=serverName;user=userName;password=password");
await conn.OpenAsync();

try
{
    // Deliberately wrong case: the export is named "putenv"
    await conn.CallServiceProgramAsync("QSYS", "QP0ZCPA", "putEnv",
        new List());
}
catch (NTiCommandException ex)
{
    // CPF226E: the "putEnv" export does not exist in QP0ZCPA
    foreach (var message in ex.Messages)
        Console.WriteLine($"{message.Id} (severity {message.Severity}): {message.Text}");
}

Summary

Complete code to call an exported service program procedure on IBM i from .NET with NTi:

using System;
using System.Collections.Generic;
using System.Data;
using Aumerial.Data.Nti;

await using var conn = new NTiConnection("server=serverName;user=userName;password=password");
await conn.OpenAsync();

// gethostname: ByReference buffer + ByValue length, Integer return
var buffer = new NTiProgramParameter(new byte[64], ParameterDirection.InputOutput);
var length = new NTiProgramParameter(64, ParameterDirection.Input)
{
    ServiceProgramParameterFormat = NTiServiceProgramParameterFormat.ByValue
};

try
{
    var rc = await conn.CallServiceProgramAsync("QSYS", "QSOSRV1", "gethostname",
        new List { buffer, length },
        NTiServiceProgramReturnFormat.Integer);

    if (rc is null || rc.GetInt() != 0)
        throw new InvalidOperationException("gethostname failed");

    byte[] data = buffer.GetBytes();
    int end = Array.IndexOf(data, (byte)0);
    Console.WriteLine($"Host name: {buffer.GetString(0, end < 0 ? data.Length : end)}");
}
catch (NTiCommandException ex)
{
    foreach (var message in ex.Messages)
        Console.WriteLine($"{message.Id} (severity {message.Severity}): {message.Text}");
}

What's next?

Reconnecting to the server...

The connection to the server was lost. The page will reload.