$BUILTIN xe $FUNCTION xe_builtin $PRODUCES xe.c $SHORT_DOC xe new COMMAND | SID ARGS... | list | kill SID | killall xe - manage persistent child processes Create a new session running an external command, then send data to that command's standard input and display its standard output. Sessions are identified by a session ID (SID) which is the PID of the child process. Commands: xe new COMMAND - Start a new session running COMMAND, prints the SID. xe SID ARGS... - Send ARGS (as a line) to the session with SID, then print the output from that command. xe list - List all active SIDs with their commands. xe kill SID - Terminate the session with the given SID. xe killall - Terminate all active sessions. Example: $ xe new cat # starts 'cat' in the background, returns SID (e.g., 12345) $ xe 12345 hello # sends 'hello' to the cat, which echoes it back $ xe list # shows 12345 cat $ xe kill 12345 # terminates the cat process $ xe killall # terminates all remaining sessions $END /** * xe.c - Bash 5 loadable builtin for managing persistent child processes. * * This builtin provides five operations: * xe new COMMAND - Start a new session running COMMAND, returns a session ID (SID). * xe SID ARGS... - Send ARGS (as a single line) to the process with the given SID, * and print its stdout output back to the user. * xe list - List all active SIDs and the commands they are running. * xe kill SID - Terminate the process with the given SID. * xe killall - Terminate all active sessions. * * The implementation uses a static table to track child processes. Each child is * started with its own session (via setsid()) and is connected to the parent via * two pipes: one for stdin (parent writes, child reads) and one for stdout * (child writes, parent reads). When sending data to a child, the builtin writes * the arguments as a single line (with a trailing newline) to the child's stdin, * then reads from its stdout using a select() loop with a short timeout to * collect all available output. The select() timeout (200ms) provides a simple * way to decide when the child has finished producing output for this input. * * Zombie child processes are reaped opportunistically (e.g., during "list" or * before any operation that uses a SID). * * Compilation (example): * gcc -shared -fPIC -I/usr/include -I /usr/include/bash -I /usr/include/bash/builtins -I/usr/include/bash/include -o xe.so xe.c * * Loading in Bash: * enable -f ./xe.so xe */ #include #include #include #include #include #include #include #include #include #include /* #include #include #include #include */ #include #include "../shell.h" #include "common.h" /* Maximum number of concurrent sessions we can track. */ #define MAX_SIDS 64 /** * Structure to hold information about one child session. */ typedef struct { pid_t pid; /**< Process ID of the child (used as the SID). */ int stdin_fd; /**< File descriptor for writing to child's stdin. */ int stdout_fd; /**< File descriptor for reading from child's stdout. */ int in_use; /**< Flag indicating this slot is occupied. */ char *cmd; /**< Copy of the command name (for listing). */ } sid_entry_t; /* Static table of sessions. */ static sid_entry_t sids[MAX_SIDS]; /* ------------------------------------------------------------------------- */ /* Initialisation and helper functions */ /* ------------------------------------------------------------------------- */ /** * Initialise the session table (called once). */ static void init_sids(void) { static int initialized = 0; if (!initialized) { for (int i = 0; i < MAX_SIDS; i++) { sids[i].in_use = 0; sids[i].pid = -1; sids[i].stdin_fd = -1; sids[i].stdout_fd = -1; sids[i].cmd = NULL; } initialized = 1; } } /** * Find a free slot in the session table. * Returns the index or -1 if the table is full. */ static int find_free_slot(void) { for (int i = 0; i < MAX_SIDS; i++) { if (!sids[i].in_use) return i; } return -1; } /** * Find the slot index that holds a given PID. * Returns the index or -1 if not found. */ static int find_slot_by_pid(pid_t pid) { for (int i = 0; i < MAX_SIDS; i++) { if (sids[i].in_use && sids[i].pid == pid) return i; } return -1; } /** * Reap any zombie child processes and clean up their table entries. * This is called before any operation that uses SIDs. */ static void reap_zombies(void) { for (int i = 0; i < MAX_SIDS; i++) { if (sids[i].in_use) { int status; pid_t ret = waitpid(sids[i].pid, &status, WNOHANG); if (ret == sids[i].pid) { /* Child has terminated – close descriptors and free slot. */ close(sids[i].stdin_fd); close(sids[i].stdout_fd); free(sids[i].cmd); sids[i].in_use = 0; sids[i].pid = -1; sids[i].stdin_fd = -1; sids[i].stdout_fd = -1; sids[i].cmd = NULL; } } } } /* ------------------------------------------------------------------------- */ /* Subcommand implementations */ /* ------------------------------------------------------------------------- */ /** * xe new COMMAND * Creates a new child process running COMMAND, puts it in its own session, * and sets up pipes for stdin/stdout. Prints the child's PID (the SID). */ static int cmd_new(WORD_LIST *list) { if (list == NULL) { builtin_usage(); return EX_USAGE; } char *command = list->word->word; /* The command to execute. */ /* (For simplicity, we ignore any further arguments; the child will get only the command name. Extending this to pass arguments is possible by building an argv array.) */ int stdin_pipe[2], stdout_pipe[2]; if (pipe(stdin_pipe) < 0 || pipe(stdout_pipe) < 0) { perror("xe: pipe"); return EXECUTION_FAILURE; } pid_t pid = fork(); if (pid < 0) { perror("xe: fork"); close(stdin_pipe[0]); close(stdin_pipe[1]); close(stdout_pipe[0]); close(stdout_pipe[1]); return EXECUTION_FAILURE; } if (pid == 0) { /* ---------- Child process ---------- */ /* Create a new session (so the child becomes a session leader). */ if (setsid() < 0) { perror("xe: setsid"); exit(1); } /* Close the ends of the pipes that belong to the parent. */ close(stdin_pipe[1]); /* Close write end of stdin pipe. */ close(stdout_pipe[0]); /* Close read end of stdout pipe. */ /* Redirect stdin to the pipe's read end. */ if (dup2(stdin_pipe[0], STDIN_FILENO) < 0) { perror("xe: dup2 stdin"); exit(1); } /* Redirect stdout to the pipe's write end. */ if (dup2(stdout_pipe[1], STDOUT_FILENO) < 0) { perror("xe: dup2 stdout"); exit(1); } /* stderr is left untouched (inherits the terminal), so error messages from the child will appear on the user's terminal. */ /* Close the original pipe descriptors (they are no longer needed). */ close(stdin_pipe[0]); close(stdout_pipe[1]); /* Execute the requested command. execlp() searches PATH. */ execlp(command, command, (char *)NULL); /* If we get here, exec failed. */ perror("xe: execlp"); exit(1); } else { /* ---------- Parent process ---------- */ /* Close the ends of the pipes used by the child. */ close(stdin_pipe[0]); /* Close read end of stdin pipe. */ close(stdout_pipe[1]); /* Close write end of stdout pipe. */ /* Find a free slot in the session table. */ int slot = find_free_slot(); if (slot < 0) { fprintf(stderr, "xe: maximum number of sessions (%d) reached\n", MAX_SIDS); close(stdin_pipe[1]); close(stdout_pipe[0]); kill(pid, SIGKILL); waitpid(pid, NULL, 0); return EXECUTION_FAILURE; } /* Fill in the table entry. */ sids[slot].pid = pid; sids[slot].stdin_fd = stdin_pipe[1]; /* Parent writes here. */ sids[slot].stdout_fd = stdout_pipe[0]; /* Parent reads here. */ sids[slot].in_use = 1; sids[slot].cmd = strdup(command); if (!sids[slot].cmd) { perror("xe: strdup"); close(stdin_pipe[1]); close(stdout_pipe[0]); kill(pid, SIGKILL); waitpid(pid, NULL, 0); sids[slot].in_use = 0; return EXECUTION_FAILURE; } /* Print the SID (the child's PID) as required. */ printf("%d\n", pid); return EXECUTION_SUCCESS; } } /** * xe SID ARGS... * Sends the arguments (joined with spaces) plus a newline to the child's stdin, * then reads all available output from its stdout (using a select() loop with a * timeout) and prints it to the user's terminal. */ static int cmd_send(pid_t sid, WORD_LIST *list) { /* First, clean up any terminated children to avoid working with dead PIDs. */ reap_zombies(); int slot = find_slot_by_pid(sid); if (slot < 0) { fprintf(stderr, "xe: invalid sid %d\n", sid); return EXECUTION_FAILURE; } /* Build a single string from the remaining words, separated by spaces, and append a newline. */ size_t len = 0; WORD_LIST *w; for (w = list; w; w = w->next) { len += strlen(w->word->word) + 1; /* +1 for space or final newline */ } if (len == 0) { /* No arguments: we will send just a newline. */ len = 1; } char *buf = malloc(len + 1); /* +1 for trailing '\0' (not needed but safe) */ if (!buf) { perror("xe: malloc"); return EXECUTION_FAILURE; } char *p = buf; for (w = list; w; w = w->next) { strcpy(p, w->word->word); p += strlen(w->word->word); if (w->next) { *p++ = ' '; } } *p++ = '\n'; *p = '\0'; /* Write the data to the child's stdin. */ int in_fd = sids[slot].stdin_fd; ssize_t n = write(in_fd, buf, p - buf); if (n < 0) { perror("xe: write to child stdin"); free(buf); return EXECUTION_FAILURE; } /* Now read from the child's stdout. We use select() with a timeout to collect all data that arrives. The loop continues until no more data arrives within 200 ms. This is a heuristic; for real applications one might need a more robust protocol, but it works for simple line‑oriented commands like cat or grep. */ int out_fd = sids[slot].stdout_fd; fd_set readfds; struct timeval tv; int done = 0; char readbuf[4096]; while (!done) { FD_ZERO(&readfds); FD_SET(out_fd, &readfds); tv.tv_sec = 0; tv.tv_usec = 200000; /* 200 ms */ int retval = select(out_fd + 1, &readfds, NULL, NULL, &tv); if (retval == -1) { perror("xe: select"); break; } else if (retval) { /* Data available. */ ssize_t rn = read(out_fd, readbuf, sizeof(readbuf)); if (rn > 0) { /* Write the data directly to stdout (the user's terminal). */ write(STDOUT_FILENO, readbuf, rn); } else if (rn == 0) { /* EOF: child closed stdout (should not happen in persistent mode, but handle it gracefully). */ done = 1; break; } else { if (errno != EAGAIN && errno != EWOULDBLOCK) { perror("xe: read from child stdout"); done = 1; } /* For EAGAIN, just continue the loop (select will wait again). */ } } else { /* Timeout – no more data arrived within 200 ms, assume done. */ done = 1; } } free(buf); return EXECUTION_SUCCESS; } /** * xe list * Prints a table of active SIDs and the commands they are running. */ static int cmd_list(void) { reap_zombies(); /* Clean up any finished children first. */ int count = 0; for (int i = 0; i < MAX_SIDS; i++) { if (sids[i].in_use) { printf("%d\t%s\n", sids[i].pid, sids[i].cmd ? sids[i].cmd : "?"); count++; } } if (count == 0) { printf("No active sessions.\n"); } return EXECUTION_SUCCESS; } /* Timeout for waiting after SIGTERM (in milliseconds) */ #define KILL_TIMEOUT_MS 2000 #define SLEEP_INTERVAL_MS 50 /** * Wait for a child process to terminate, with a timeout. * Returns: * 0 if child exited normally within timeout, * 1 if child was killed by SIGKILL after timeout, * -1 on error (child already gone or waitpid error). */ static int wait_with_timeout(pid_t pid) { int status; int waited_ms = 0; while (waited_ms < KILL_TIMEOUT_MS) { pid_t ret = waitpid(pid, &status, WNOHANG); if (ret == pid) { /* Child exited */ return 0; } else if (ret == 0) { /* Still running, sleep a bit */ usleep(SLEEP_INTERVAL_MS * 1000); waited_ms += SLEEP_INTERVAL_MS; } else { /* waitpid error (e.g., ECHILD) */ return -1; } } /* Timeout reached, send SIGKILL */ if (kill(pid, SIGKILL) == 0) { /* Now wait indefinitely (should be immediate) */ waitpid(pid, &status, 0); return 1; /* Killed by SIGKILL */ } else { /* Kill failed, maybe process already gone */ return -1; } } /** * xe kill SID * Sends SIGTERM to the child process, waits up to KILL_TIMEOUT_MS for it to exit, * and if it doesn't, sends SIGKILL. Then cleans up the table entry. */ static int cmd_kill(pid_t sid) { reap_zombies(); int slot = find_slot_by_pid(sid); if (slot < 0) { fprintf(stderr, "xe: invalid sid %d\n", sid); return EXECUTION_FAILURE; } /* Send SIGTERM */ if (kill(sid, SIGTERM) < 0) { perror("xe: kill (SIGTERM)"); /* Even if kill fails, we still clean up the slot (process may be gone) */ } int result = wait_with_timeout(sid); if (result < 0) { /* Process already gone? */ } else if (result == 1) { fprintf(stderr, "xe: process %d did not terminate, forced kill\n", sid); } /* Close pipes and free slot regardless */ close(sids[slot].stdin_fd); close(sids[slot].stdout_fd); free(sids[slot].cmd); sids[slot].in_use = 0; sids[slot].pid = -1; sids[slot].stdin_fd = -1; sids[slot].stdout_fd = -1; sids[slot].cmd = NULL; return EXECUTION_SUCCESS; } /** * xe killall * Sends SIGTERM to all active child processes, waits up to KILL_TIMEOUT_MS for each, * forces SIGKILL if needed, and cleans up all table entries. */ static int cmd_killall(void) { reap_zombies(); /* Collect all active PIDs first */ pid_t pids[MAX_SIDS]; int count = 0; for (int i = 0; i < MAX_SIDS; i++) { if (sids[i].in_use) { pids[count++] = sids[i].pid; } } if (count == 0) { printf("No active sessions.\n"); return EXECUTION_SUCCESS; } /* Terminate each process */ for (int i = 0; i < count; i++) { pid_t pid = pids[i]; int slot = find_slot_by_pid(pid); if (slot < 0) continue; /* Already gone */ if (kill(pid, SIGTERM) < 0) { perror("xe: kill (SIGTERM)"); } int result = wait_with_timeout(pid); if (result < 0) { /* Process already gone? */ } else if (result == 1) { fprintf(stderr, "xe: process %d did not terminate, forced kill\n", pid); } /* Clean up this slot (re-find in case it changed) */ int cur_slot = find_slot_by_pid(pid); if (cur_slot >= 0) { close(sids[cur_slot].stdin_fd); close(sids[cur_slot].stdout_fd); free(sids[cur_slot].cmd); sids[cur_slot].in_use = 0; sids[cur_slot].pid = -1; sids[cur_slot].stdin_fd = -1; sids[cur_slot].stdout_fd = -1; sids[cur_slot].cmd = NULL; } } return EXECUTION_SUCCESS; } /** * xe kill SID * Sends SIGTERM to the child process, waits for it to terminate, * and cleans up the associated table entry and file descriptors. */ static int cmd_kill0(pid_t sid) { reap_zombies(); /* Ensure we have up‑to‑date information. */ int slot = find_slot_by_pid(sid); if (slot < 0) { fprintf(stderr, "xe: invalid sid %d\n", sid); return EXECUTION_FAILURE; } /* Send SIGTERM. */ //if (kill(sid, SIGTERM) < 0) { if (kill(sid, SIGKILL) < 0) { perror("xe: kill"); return EXECUTION_FAILURE; } /* Wait for the child to exit. This may block; if interrupted by a signal (e.g., SIGINT from Ctrl+C), waitpid returns -1 with errno EINTR. In that case we print an error and continue cleaning up the table entry. The child may or may not have actually exited, but we close the pipes and remove the slot to avoid leaking resources. This matches the observed behavior in your test. */ int status; pid_t ret = waitpid(sid, &status, 0); if (ret < 0) { perror("xe: waitpid"); /* Continue cleaning up anyway. */ } /* Close pipes and free the slot. */ close(sids[slot].stdin_fd); close(sids[slot].stdout_fd); free(sids[slot].cmd); sids[slot].in_use = 0; sids[slot].pid = -1; sids[slot].stdin_fd = -1; sids[slot].stdout_fd = -1; sids[slot].cmd = NULL; return EXECUTION_SUCCESS; } /** * xe killall * Sends SIGTERM to all active child processes, waits for each to terminate, * and cleans up all table entries. */ static int cmd_killall0(void) { reap_zombies(); /* Collect all active PIDs first to avoid modifying the table while iterating. */ pid_t pids[MAX_SIDS]; int count = 0; for (int i = 0; i < MAX_SIDS; i++) { if (sids[i].in_use) { pids[count++] = sids[i].pid; } } if (count == 0) { printf("No active sessions.\n"); return EXECUTION_SUCCESS; } /* Kill each process one by one. For each, we send SIGTERM, wait for it (which may block), and then clean its table entry. If waiting is interrupted by a signal, we print an error but still clean the entry. */ for (int i = 0; i < count; i++) { pid_t pid = pids[i]; int slot = find_slot_by_pid(pid); if (slot < 0) { /* Process already gone? Skip. */ continue; } if (kill(pid, SIGTERM) < 0) { perror("xe: kill"); /* Continue to next process? We'll still try to clean this one. */ } int status; pid_t ret = waitpid(pid, &status, 0); if (ret < 0) { perror("xe: waitpid"); /* Fall through to clean up the slot. */ } /* Clean up the slot (re-find it in case it changed during wait). */ int cur_slot = find_slot_by_pid(pid); if (cur_slot >= 0) { close(sids[cur_slot].stdin_fd); close(sids[cur_slot].stdout_fd); free(sids[cur_slot].cmd); sids[cur_slot].in_use = 0; sids[cur_slot].pid = -1; sids[cur_slot].stdin_fd = -1; sids[cur_slot].stdout_fd = -1; sids[cur_slot].cmd = NULL; } } return EXECUTION_SUCCESS; } /* ------------------------------------------------------------------------- */ /* Builtin main entry point */ /* ------------------------------------------------------------------------- */ /** * xe_builtin - main function called when the builtin is executed. * Parses the command line and dispatches to the appropriate subcommand. */ int xe_builtin(WORD_LIST *list) { init_sids(); /* Ensure the session table is initialised. */ if (list == NULL) { builtin_usage(); return EX_USAGE; } char *cmd = list->word->word; if (strcmp(cmd, "new") == 0) { return cmd_new(list->next); } else if (strcmp(cmd, "list") == 0) { return cmd_list(); } else if (strcmp(cmd, "kill") == 0) { if (list->next == NULL) { fprintf(stderr, "xe kill: missing sid\n"); return EX_USAGE; } char *sid_str = list->next->word->word; pid_t sid = atoi(sid_str); if (sid <= 0) { fprintf(stderr, "xe kill: invalid sid '%s'\n", sid_str); return EX_USAGE; } return cmd_kill(sid); } else if (strcmp(cmd, "killall") == 0) { /* No arguments expected */ if (list->next != NULL) { fprintf(stderr, "xe killall: too many arguments\n"); return EX_USAGE; } return cmd_killall(); } else { /* If the first argument is not a known subcommand, try to interpret it as a SID (numeric). */ pid_t sid = atoi(cmd); if (sid <= 0) { fprintf(stderr, "xe: unknown command '%s'\n", cmd); builtin_usage(); return EX_USAGE; } /* The rest of the arguments are the data to send. */ return cmd_send(sid, list->next); } /* Not reached. */ return EXECUTION_SUCCESS; }