diff --git a/file_cmds/cksum/cksum.c b/file_cmds/cksum/cksum.c index 002b3166..e4815b06 100644 --- a/file_cmds/cksum/cksum.c +++ b/file_cmds/cksum/cksum.c @@ -137,7 +137,8 @@ chksum_main(int argc, char **argv) rval = 1; } else pfncn(fn, val, len); - (void)close(fd); + if (fn != NULL) + (void)close(fd); } while (*argv); exit(rval); } diff --git a/file_cmds/gzip/gzip.c b/file_cmds/gzip/gzip.c index 9c9d7905..12eb9f24 100644 --- a/file_cmds/gzip/gzip.c +++ b/file_cmds/gzip/gzip.c @@ -1355,7 +1355,7 @@ file_compress(char *file, char *outfile, size_t outsize) out = open(outfile, O_WRONLY | O_CREAT | O_EXCL, 0600); if (out == -1) { maybe_warn("could not create output: %s", outfile); - fclose(thread_stdin); + close(in); return (-1); } #ifndef SMALL diff --git a/ios_error.h b/ios_error.h index b4e77d06..ce38ec2a 100644 --- a/ios_error.h +++ b/ios_error.h @@ -78,6 +78,8 @@ extern int ios_killpid(pid_t pid, int sig); // kill the current running command extern void ios_exit(int errorCode) __dead2; // set error code and exits from the thread. extern int ios_execv(const char *path, char* const argv[]); +extern int ios_execv_async(const char *path, char* const argv[]); // returns once the child has started +extern int ios_pidStatus(pid_t pid); // exit status of a finished virtual pid, -1 if unknown extern int ios_execve(const char *path, char* const argv[], char** envlist); extern int ios_dup2(int fd1, int fd2); extern char * ios_getenv(const char *name); diff --git a/ios_spawn.h b/ios_spawn.h new file mode 100644 index 00000000..0c08971f --- /dev/null +++ b/ios_spawn.h @@ -0,0 +1,31 @@ +#ifndef IOS_SPAWN_H +#define IOS_SPAWN_H +#include +#include +#include +#ifdef __cplusplus +extern "C" { +#endif +/* Isolated worker processes inherit only stdin, stdout and stderr. */ +typedef struct { int source, target; } ios_spawn_action; +typedef struct { + ios_spawn_action *actions; + size_t count; + char *cwd; +} ios_posix_spawn_file_actions_t; +int ios_posix_spawn_file_actions_init(ios_posix_spawn_file_actions_t *); +int ios_posix_spawn_file_actions_destroy(ios_posix_spawn_file_actions_t *); +int ios_posix_spawn_file_actions_adddup2(ios_posix_spawn_file_actions_t *, int, int); +int ios_posix_spawn_file_actions_addclose(ios_posix_spawn_file_actions_t *, int); +int ios_posix_spawn_file_actions_addchdir_np(ios_posix_spawn_file_actions_t *, const char *); +int ios_posix_spawn(pid_t *, const char *, const ios_posix_spawn_file_actions_t *, const void *, char *const [], char *const []); +/* Wait for an ios_posix_spawn PID; options is 0 or WNOHANG. Returns PID, 0 while + * running, or -1 with errno. Status uses waitpid encoding (ios_killpid with SIGINT, SIGTERM, SIGKILL, SIGHUP or SIGQUIT: exit 128 + signal; worker transport failure: exit 125). + * Optional usage is the isolated worker's RUSAGE_SELF snapshot at command exit, + * including process startup. availability is required when usage is requested; + * zero means unavailable (including cancellation), never a measured zero. */ +pid_t ios_wait4(pid_t, int *, int, struct rusage *, int *availability); +#ifdef __cplusplus +} +#endif +#endif diff --git a/ios_system.m b/ios_system.m index 17f8bf01..271f6caa 100644 --- a/ios_system.m +++ b/ios_system.m @@ -5,6 +5,9 @@ // Copyright © 2017 N. Holzschuch. All rights reserved. // +#include +#include "ios_spawn.h" +#include #import #include "ios_system.h" @@ -216,14 +219,18 @@ void ios_setBookmarkDictionaryName(NSString* name) { // pointers for sh sessions: char* sh_session = "sh_session"; +static void recordPidStatus(int status); + // replace system-provided exit() by our own: extern void ios_storeCommandStatus(int status); extern void ios_storePidStatus(pid_t pid, int status); +extern int ios_pidStatus(pid_t pid); void ios_exit(int n) { ios_storeCommandStatus(n); if (currentSession != NULL) { currentSession->global_errno = n; } + recordPidStatus(n); pthread_exit(NULL); } @@ -403,8 +410,66 @@ int ios_getCommandStatus(void) { struct _functionParameters *ownedNext; pthread_t ownedThread; bool ownedRunning, commandStarted; + pid_t pid; + bool workerEligible, asyncSpawn; + bool returnedNormally; + int cancellationSignal; + pthread_t signalThread; } functionParameters; +// Set by ios_execv/ios_execv_async for the duration of the ios_system call that creates the child. +static __thread bool spawnedChild = false; +static __thread bool spawnAsync = false; + +// Exit status of each virtual pid, so concurrent commands do not share the session's global_errno. +#define IOS_PID_STATUS_SLOTS 128 // Matches IOS_MAX_THREADS in libc_replacement.c (virtual PIDs 1..127). +// The worker proxy thread for each virtual pid; other threads sharing that pid receive signals normally. +static _Atomic(pthread_t) pidWorkerThread[IOS_PID_STATUS_SLOTS]; +static _Atomic int pidWorkerSignal[IOS_PID_STATUS_SLOTS]; +static pthread_mutex_t pidUsageMutex = PTHREAD_MUTEX_INITIALIZER; +static struct rusage pidUsage[IOS_PID_STATUS_SLOTS]; +static bool pidUsageAvailable[IOS_PID_STATUS_SLOTS]; +static bool pidWasSpawned[IOS_PID_STATUS_SLOTS]; + +// pid of the command running on this thread, so exit() can record its status too. +static __thread pid_t commandPid = -1; + +// Allocation owns adapter state, including slots claimed without ios_system. +void ios_resetPidWorkerState(pid_t pid) { + if (pid < 0 || pid >= IOS_PID_STATUS_SLOTS) return; + atomic_store(&pidWorkerThread[pid], NULL); + atomic_store(&pidWorkerSignal[pid], 0); + pthread_mutex_lock(&pidUsageMutex); + pidWasSpawned[pid] = false; + pidUsageAvailable[pid] = false; + pthread_mutex_unlock(&pidUsageMutex); +} + +static void recordPidStatus(int status) { + ios_storePidStatus(commandPid, status); +} + +pid_t ios_wait4(pid_t pid, int *status, int options, struct rusage *usage, int *available) { + if (available) *available = 0; + if (usage) memset(usage, 0, sizeof(*usage)); + if ((options != 0 && options != WNOHANG) || (usage && !available)) { errno = EINVAL; return -1; } + if (pid < 0 || pid >= IOS_PID_STATUS_SLOTS) { errno = ECHILD; return -1; } + pthread_mutex_lock(&pidUsageMutex); + bool spawned = pidWasSpawned[pid]; + pthread_mutex_unlock(&pidUsageMutex); + if (!spawned) { errno = ECHILD; return -1; } + if (options == WNOHANG && ios_getThreadId(pid) != 0) return 0; + ios_waitpid(pid); + int result = ios_pidStatus(pid); + if (result < 0) { errno = ECHILD; return -1; } + if (status) *status = W_EXITCODE(result, 0); + pthread_mutex_lock(&pidUsageMutex); + if (usage && pidUsageAvailable[pid]) *usage = pidUsage[pid]; + if (available) *available = pidUsageAvailable[pid]; + pthread_mutex_unlock(&pidUsageMutex); + return pid; +} + static pthread_mutex_t commandSignalMutex = PTHREAD_MUTEX_INITIALIZER; static functionParameters *runningSignalCommands = NULL; @@ -424,6 +489,8 @@ static void clearRootThread(sessionParameters *session) { pthread_mutex_unlock(&rootThreadMutex); } extern void ios_releaseBackgroundThread(pthread_t thread); +extern void ios_releaseBackgroundThreadId(pid_t pid); +extern void ios_storeThreadIdForPid(pid_t pid, pthread_t thread); extern int startedPreparingWebAssemblyCommand(void); extern __thread int currentWebAssemblyCommandOrder; @@ -612,6 +679,17 @@ static void cleanup_function(void* parameters) { functionParameters *p = (functionParameters *) parameters; // Cleanup must finish even when another owned descendant is cancelled. pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL); + pthread_mutex_lock(&commandSignalMutex); + bool proxy = p->pid >= 0 && p->pid < IOS_PID_STATUS_SLOTS && + atomic_load(&pidWorkerThread[p->pid]) == current_thread; + int cancelledBySignal = p->returnedNormally || proxy ? 0 : p->cancellationSignal; + pthread_mutex_unlock(&commandSignalMutex); + if (cancelledBySignal) { + int status = 128 + cancelledBySignal; + ios_storeCommandStatus(status); + ios_storePidStatus(p->pid, status); + if (currentSession != nil) currentSession->global_errno = status; + } pthread_mutex_lock(&ownedStreamsMutex); p->ownedRunning = false; pthread_mutex_unlock(&ownedStreamsMutex); @@ -886,10 +964,383 @@ void crash_handler(int sig) { } } +extern char** environmentVariables(pid_t pid); +// Run a spawned command in a free extension worker (a-Shell's ashell_pane_* ABI, found with dlsym). +// Returns false when it did not run there (no API, pool full, old iOS): the caller runs it on a thread. +static bool workerWanted(const char *name) { + const char *list = ios_getenv("ASHELL_WORKER_COMMANDS"); + if (list == NULL || name == NULL) return false; + size_t length = strlen(name); + for (const char *entry = list; *entry; ) { + while (*entry == ' ') entry++; + const char *end = entry; + while (*end && *end != ' ') end++; + if ((size_t)(end - entry) == length && strncmp(entry, name, length) == 0) return true; + entry = end; + } + return false; +} + +static bool workerRequired(const char *name) { + return workerWanted(name) && name && (!strcmp(name, "ssh") || !strcmp(name, "scp") || + !strcmp(name, "sftp") || !strcmp(name, "ssh-keygen")); +} + +// Mirrors ashell_pane_options of a-Shell's NativePane.h (read through dlsym, no link-time dependency). +typedef struct { + size_t size; + int tty, separate_stderr, stderr_fd; +} workerPaneOptions; + +typedef struct { + int fd; + int errFd; + void *job; + bool finished; + pid_t pid; +} workerRun; + +static void *workerAPI(const char *suffix) { + static dispatch_once_t once; + static const char *prefix; + dispatch_once(&once, ^{ + prefix = dlsym(RTLD_DEFAULT, "ashell_worker_spawn_ex") || dlsym(RTLD_DEFAULT, "ashell_worker_spawn") + ? "ashell_worker_" : "ashell_pane_"; + }); + char symbol[96]; + snprintf(symbol, sizeof(symbol), "%s%s", prefix, suffix); + return dlsym(RTLD_DEFAULT, symbol); +} + +static void workerRunCleanup(void *argument) { + pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL); + workerRun *run = argument; + int (*cancel)(void *) = workerAPI("cancel"); + void (*release)(void *) = workerAPI("release"); + if (!run->finished && cancel) { + cancel(run->job); + int signal = run->pid >= 0 && run->pid < IOS_PID_STATUS_SLOTS + ? atomic_load(&pidWorkerSignal[run->pid]) : 0; + int status = 128 + (signal ? signal : SIGKILL); + recordPidStatus(status); + if (currentSession != NULL) currentSession->global_errno = status; + } + // Keep worker ownership until the thread slot is released; late signals must not reach pthread_kill. + close(run->fd); + if (run->errFd >= 0) close(run->errFd); + if (release) release(run->job); +} + +typedef struct { + int fd, errFd; + void *job; + void *ready; +} admittedWorker; + +static bool runInWorker(functionParameters *p, int *status, admittedWorker *admitted) { + int (*spawn)(const char *, const char *, unsigned, unsigned, const char *const *, int *, void **) = workerAPI("spawn"); + int (*spawn_ex)(const char *, const char *, unsigned, unsigned, const char *const *, workerPaneOptions *, int *, void **) = workerAPI("spawn_ex"); + int (*poll_job)(void *, int *) = workerAPI("poll"); + int (*close_stdin)(void *) = workerAPI("close_stdin"); + if ((!spawn && !spawn_ex) || !poll_job || !close_stdin || !workerAPI("cancel") || !workerAPI("release")) { errno = ENOTSUP; return false; } + int fd = admitted ? admitted->fd : -1; + int errFd = admitted ? admitted->errFd : -1; + void *job = admitted ? admitted->job : NULL; + int cancellationState; + pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, &cancellationState); + if (admitted == NULL) { + NSMutableString *line = [NSMutableString new]; + for (int i = 0; i < p->argc; i++) { + // Resolution already selected a registry command. Keep that selection in the worker + // with the stock registry bypass, rather than searching the child's PATH again. + NSString *argument = [NSString stringWithUTF8String:p->argv[i]]; + if (i == 0) argument = [@"\\" stringByAppendingString:argument]; + [line appendFormat:@"%s'%@'", i ? " " : "", [argument stringByReplacingOccurrencesOfString:@"'" withString:@"'\\''"]]; + } + char cwd[MAXPATHLEN]; + if (getcwd(cwd, sizeof(cwd)) == NULL) { + pthread_setcancelstate(cancellationState, NULL); + return false; + } + // The worker reports a terminal and merges stderr unless told otherwise: pass what this command really has. + workerPaneOptions options = { sizeof(options), ios_isatty(STDOUT_FILENO) != 0, + p->stdout != NULL && p->stderr != NULL && fileno(p->stderr) != fileno(p->stdout), -1 }; + char **environment = environmentVariables(p->pid >= 0 ? p->pid : ios_currentPid()); + int spawned = spawn_ex ? spawn_ex(line.UTF8String, cwd, 80, 24, (const char *const *)environment, &options, &fd, &job) + : spawn(line.UTF8String, cwd, 80, 24, (const char *const *)environment, &fd, &job); + if (spawned != 0) { + errno = spawned; + pthread_setcancelstate(cancellationState, NULL); + return false; + } + errFd = spawn_ex ? options.stderr_fd : -1; + } + workerRun run = { fd, errFd, job, false, p->pid }; + pthread_mutex_lock(&commandSignalMutex); + if (p->pid >= 0 && p->pid < IOS_PID_STATUS_SLOTS) { + // An interrupt can arrive while spawn has cancellation disabled. + if (p->cancellationSignal) atomic_store(&pidWorkerSignal[p->pid], p->cancellationSignal); + atomic_store(&pidWorkerThread[p->pid], pthread_self()); + } + pthread_mutex_unlock(&commandSignalMutex); + // Honor explicit background input redirects without consuming inherited terminal input. + int stdinFd = (p->stdin == NULL || (p->asyncSpawn && !p->isPipeIn)) ? -1 : fileno(p->stdin); + if (stdinFd < 0) close_stdin(job); + int exitStatus = 137, state = 0; + size_t inputBytes = 0, outputBytes = 0; + pthread_cleanup_push(workerRunCleanup, &run); + // Publish the PID only after cancellation owns the admitted worker. + if (admitted && admitted->ready) { + dispatch_semaphore_t ready = CFBridgingRelease(admitted->ready); + admitted->ready = NULL; + dispatch_semaphore_signal(ready); + } + // Admission transfers ownership before a pending cancellation can run. + pthread_setcancelstate(cancellationState, NULL); + char buffer[8192], pending[8192]; + size_t pendingLength = 0, pendingOffset = 0; + bool stdinDone = false; + // A blocking write to the worker would stop this loop from draining the worker's output, and a command that + // answers while it is still reading (gzip < file | wc) would wait for us while we wait for it. + fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_NONBLOCK); + for (;;) { + bool wantInput = stdinFd >= 0 && pendingLength == 0; + struct pollfd fds[3] = { { fd, (short)(POLLIN | (pendingLength > 0 ? POLLOUT : 0)), 0 }, { wantInput ? stdinFd : -1, POLLIN, 0 }, { run.errFd, POLLIN, 0 } }; + poll(fds, 3, 50); // a cancellation point: ios_killpid / Ctrl-C end up in workerRunCleanup + // Darwin poll rejects /dev/null with POLLNVAL; select reports its readable EOF. + if (wantInput && (fds[1].revents & POLLNVAL) && stdinFd < FD_SETSIZE) { + fd_set readable; + FD_ZERO(&readable); + FD_SET(stdinFd, &readable); + struct timeval immediate = { 0, 0 }; + if (select(stdinFd + 1, &readable, NULL, NULL, &immediate) > 0) + fds[1].revents = POLLIN; + } + if (wantInput && (fds[1].revents & (POLLIN | POLLHUP))) { + ssize_t n = read(stdinFd, pending, sizeof(pending)); + if (n > 0) { pendingLength = n; pendingOffset = 0; } + else { stdinDone = true; stdinFd = -1; } + } + if (pendingLength > 0) { + ssize_t n = write(fd, pending + pendingOffset, pendingLength - pendingOffset); + if (n > 0) { + inputBytes += n; + pendingOffset += n; + if (pendingOffset == pendingLength) pendingLength = pendingOffset = 0; + } else if (n < 0 && errno != EAGAIN && errno != EINTR) { + pendingLength = pendingOffset = 0; + stdinFd = -1; + stdinDone = true; + } + } + if (stdinDone && pendingLength == 0) { close_stdin(job); stdinDone = false; } + if (fds[0].revents & (POLLIN | POLLHUP)) { + ssize_t n = read(fd, buffer, sizeof(buffer)); + if (n == 0 || (n < 0 && errno != EAGAIN && errno != EINTR)) break; + if (n > 0) { + outputBytes += n; + fwrite(buffer, 1, n, p->stdout); + fflush(p->stdout); + } + } + if (run.errFd >= 0 && (fds[2].revents & (POLLIN | POLLHUP))) { + ssize_t n = read(run.errFd, buffer, sizeof(buffer)); + if (n <= 0) { close(run.errFd); run.errFd = -1; } + else { fwrite(buffer, 1, n, p->stderr); fflush(p->stderr); } + } + } + // The worker closes stdout and stderr together; take what is left on stderr. + while (run.errFd >= 0) { + ssize_t n = read(run.errFd, buffer, sizeof(buffer)); + if (n <= 0) { close(run.errFd); run.errFd = -1; break; } + fwrite(buffer, 1, n, p->stderr); + fflush(p->stderr); + } + for (int tries = 0; tries < 200; tries++) { + state = poll_job(job, &exitStatus); + if (state != 0) break; + usleep(10000); + } + run.finished = (state != 0); + if (state == 1 && p->pid >= 0 && p->pid < IOS_PID_STATUS_SLOTS) { + int (*poll_usage)(void *, int *, struct rusage *, int *) = workerAPI("poll_usage"); + struct rusage usage; + int available = 0; + if (poll_usage && poll_usage(job, &exitStatus, &usage, &available) == 1 && available) { + pthread_mutex_lock(&pidUsageMutex); + pidUsage[p->pid] = usage; + pidUsageAvailable[p->pid] = true; + pthread_mutex_unlock(&pidUsageMutex); + } + } + pthread_cleanup_pop(1); + if (state == 1) *status = exitStatus; + else { + int error = state < 0 ? -state : ETIMEDOUT; + fprintf(p->stderr, "worker connection failed: %s\n", strerror(error)); + fflush(p->stderr); + *status = 125; + } + NSLog(@"Worker pump completed: pid=%d input=%zu output=%zu status=%d", p->pid, inputBytes, outputBytes, *status); + return true; +} + +/* Worker-only spawn: admission completes before a PID is returned; no app-thread fallback. */ +int ios_posix_spawn_file_actions_init(ios_posix_spawn_file_actions_t *actions) { + memset(actions, 0, sizeof(*actions)); + return 0; +} +int ios_posix_spawn_file_actions_destroy(ios_posix_spawn_file_actions_t *actions) { + free(actions->actions); + free(actions->cwd); + memset(actions, 0, sizeof(*actions)); + return 0; +} +static int appendSpawnAction(ios_posix_spawn_file_actions_t *actions, int source, int target) { + ios_spawn_action *next = realloc(actions->actions, (actions->count + 1) * sizeof(*next)); + if (next == NULL) return ENOMEM; + actions->actions = next; + actions->actions[actions->count++] = (ios_spawn_action){ source, target }; + return 0; +} +int ios_posix_spawn_file_actions_adddup2(ios_posix_spawn_file_actions_t *actions, int source, int target) { + if (source < 0 || target < 0) return EBADF; + if (target > 2) return ENOTSUP; + return appendSpawnAction(actions, source, target); +} +int ios_posix_spawn_file_actions_addclose(ios_posix_spawn_file_actions_t *actions, int fd) { + if (fd < 0) return EBADF; + if (fd < 3) return ENOTSUP; + /* Extension workers do not inherit the app's descriptor table. */ + return 0; +} +int ios_posix_spawn_file_actions_addchdir_np(ios_posix_spawn_file_actions_t *actions, const char *cwd) { + char *copy = strdup(cwd); + if (copy == NULL) return ENOMEM; + free(actions->cwd); + actions->cwd = copy; + return 0; +} + +typedef struct { + functionParameters command; + admittedWorker worker; +} spawnedWorker; + +static void spawnedWorkerCleanup(void *argument) { + pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL); + spawnedWorker *spawned = argument; + fflush(spawned->command.stdout); + fflush(spawned->command.stderr); + fclose(spawned->command.stdin); + fclose(spawned->command.stdout); + fclose(spawned->command.stderr); + ios_releaseBackgroundThreadId(spawned->command.pid); + free(spawned); +} +static void *spawnedWorkerMain(void *argument) { + spawnedWorker *spawned = argument; + functionParameters *p = &spawned->command; + commandPid = p->pid; + currentSession = NULL; + thread_stdin = p->stdin; + thread_stdout = p->stdout; + thread_stderr = p->stderr; + ios_storeThreadIdForPid(p->pid, pthread_self()); + pthread_cleanup_push(spawnedWorkerCleanup, spawned); + int status = 127; + runInWorker(p, &status, &spawned->worker); + recordPidStatus(status); + pthread_cleanup_pop(1); + return NULL; +} + +int ios_posix_spawn(pid_t *pid, const char *path, + const ios_posix_spawn_file_actions_t *actions, + const void *attributes, char *const argv[], char *const envp[]) { + if (pid == NULL || path == NULL || argv == NULL || argv[0] == NULL) return EINVAL; + if (attributes != NULL) return ENOTSUP; + int (*spawn)(const char *, const char *, unsigned, unsigned, const char *const *, workerPaneOptions *, int *, void **) = workerAPI("spawn_ex"); + int (*cancel)(void *) = workerAPI("cancel"); + void (*release)(void *) = workerAPI("release"); + if (!spawn || !cancel || !release || !workerAPI("poll") || + !workerAPI("close_stdin")) return ENOTSUP; + int descriptors[3] = { fileno(thread_stdin), fileno(thread_stdout), fileno(thread_stderr) }; + if (actions) { + for (size_t i = 0; i < actions->count; i++) { + ios_spawn_action action = actions->actions[i]; + int source = action.source < 3 ? descriptors[action.source] : action.source; + if (fcntl(source, F_GETFD) < 0) return EBADF; + descriptors[action.target] = source; + } + } + spawnedWorker *child = calloc(1, sizeof(*child)); + if (child == NULL) return ENOMEM; + FILE **streams[] = { &child->command.stdin, &child->command.stdout, &child->command.stderr }; + for (int i = 0; i < 3; i++) { + int fd = dup(descriptors[i]); + if (fd >= 0) *streams[i] = fdopen(fd, i ? "wb" : "rb"); + if (*streams[i] == NULL) { + int error = errno; + if (fd >= 0) close(fd); + for (int j = 0; j < i; j++) fclose(*streams[j]); + free(child); + return error; + } + } + NSMutableString *line = [NSMutableString new]; + for (int i = 0; argv[i]; i++) + [line appendFormat:@"%s'%@'", i ? " " : "", [[NSString stringWithUTF8String:argv[i]] stringByReplacingOccurrencesOfString:@"'" withString:@"'\\''"]]; + char cwd[MAXPATHLEN]; + const char *directory = actions && actions->cwd ? actions->cwd : getcwd(cwd, sizeof(cwd)); + workerPaneOptions options = { sizeof(options), 0, 1, -1 }; + child->worker.fd = -1; + int cancellationState; + pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, &cancellationState); + int error = directory ? spawn(line.UTF8String, directory, 80, 24, + (const char *const *)envp, &options, &child->worker.fd, &child->worker.job) : errno; + if (error != 0) { + for (int i = 0; i < 3; i++) fclose(*streams[i]); + free(child); + pthread_setcancelstate(cancellationState, NULL); + return error; + } + child->worker.errFd = options.stderr_fd; + child->command.pid = ios_fork(); + pthread_mutex_lock(&pidUsageMutex); + pidWasSpawned[child->command.pid] = true; + pidUsageAvailable[child->command.pid] = false; + pthread_mutex_unlock(&pidUsageMutex); + child->command.isPipeIn = true; + pid_t childPid = child->command.pid; + dispatch_semaphore_t ready = dispatch_semaphore_create(0); + child->worker.ready = (__bridge_retained void *)ready; + pthread_t thread; + error = pthread_create(&thread, NULL, spawnedWorkerMain, child); + if (error) { + CFBridgingRelease(child->worker.ready); + ios_storeThreadIdForPid(childPid, NULL); + cancel(child->worker.job); + release(child->worker.job); + close(child->worker.fd); + if (child->worker.errFd >= 0) close(child->worker.errFd); + for (int i = 0; i < 3; i++) fclose(*streams[i]); + free(child); + pthread_setcancelstate(cancellationState, NULL); + return error; + } + dispatch_semaphore_wait(ready, DISPATCH_TIME_FOREVER); + *pid = childPid; + pthread_detach(thread); + pthread_setcancelstate(cancellationState, NULL); + return 0; +} + static void* run_function(void* parameters) { functionParameters *p = (functionParameters *) parameters; currentWebAssemblyCommandOrder = p->webAssemblyOrder; p->commandStarted = true; + commandPid = p->pid; currentOwnedStreams = p->ownedStreams; NSLog(@"Storing thread_id: %x pid: %d isPipeOut: %x isPipeErr: %x stdin %d stdout %d stderr %d command= %s\n", pthread_self(), ios_currentPid(), p->isPipeOut, p->isPipeErr, (p->stdin == nil) ? -1 : fileno(p->stdin), @@ -952,6 +1403,7 @@ void crash_handler(int sig) { } } } + p->signalThread = pthread_self(); p->nextSignalCommand = runningSignalCommands; runningSignalCommands = p; pthread_mutex_unlock(&commandSignalMutex); @@ -963,8 +1415,16 @@ void crash_handler(int sig) { p->ownedThread = pthread_self(); p->ownedRunning = !cancelled; pthread_mutex_unlock(&ownedStreamsMutex); - int retval = cancelled ? 130 : p->function(p->argc, p->argv); + int retval = 130; + if (!cancelled && !(p->workerEligible && runInWorker(p, &retval, NULL))) { + if (p->workerEligible && workerRequired(p->argv[0])) { + fprintf(p->stderr, "worker spawn required for %s: %s\n", p->argv[0], strerror(errno)); + retval = 127; + } else retval = p->function(p->argc, p->argv); + } + p->returnedNormally = true; ios_storeCommandStatus(retval); + recordPidStatus(retval); if (currentSession != nil) currentSession->global_errno = retval; } @catch (NSException *exception) @@ -2333,11 +2793,22 @@ int ios_execv(const char *path, char* const argv[]) { // path and argv[0] are the same (not in theory, but in practice, since Python wrote the command) // start "child" with the child streams: char* cmd = concatenateArgv(argv); + spawnedChild = true; int returnValue = ios_system(cmd); + spawnedChild = false; free(cmd); return returnValue; } +// Same as ios_execv, but returns as soon as the child is started; wait with ios_waitpid(pid) +// and read the status with ios_pidStatus(pid). The child has no standard input. +int ios_execv_async(const char *path, char* const argv[]) { + spawnAsync = true; + int returnValue = ios_execv(path, argv); + spawnAsync = false; + return returnValue; +} + int ios_execve(const char *path, char* const argv[], char* envp[]) { // save the environment (done) and current dir (TODO) storeEnvironment(envp); @@ -2474,6 +2945,10 @@ int ios_kill(void) { if (currentSession == NULL) return ESRCH; if (currentSession->current_command_root_thread > 0) { + for (pid_t pid = 0; pid < IOS_PID_STATUS_SLOTS; pid++) { + if (ios_getThreadId(pid) == currentSession->current_command_root_thread) + atomic_store(&pidWorkerSignal[pid], SIGINT); + } struct sigaction query_action; const char* commandName = ios_progname(); if ((sigaction (SIGINT, NULL, &query_action) >= 0) && @@ -2524,12 +2999,41 @@ int ios_kill(void) extern pthread_t ios_getThreadId(pid_t pid); int ios_killpid(pid_t pid, int sig) { - if (ios_getThreadId(pid) > 0) { - return pthread_kill(ios_getThreadId(pid), sig); + pthread_t thread = ios_getThreadId(pid); + if (thread != NULL && thread != (pthread_t)-1) { + if (pid >= 0 && pid < IOS_PID_STATUS_SLOTS && atomic_load(&pidWorkerThread[pid]) == thread + && (sig == SIGINT || sig == SIGTERM || sig == SIGKILL || sig == SIGHUP || sig == SIGQUIT)) + { + atomic_store(&pidWorkerSignal[pid], sig); + return pthread_cancel(thread); // ends the proxy, which cancels the worker + } + return pthread_kill(thread, sig); } return 0; } +// Cancel only the named registered command, never a process-wide signal handler. +int ios_interruptpid(pid_t pid) { + pthread_t thread = ios_getThreadId(pid); + if (thread == NULL || thread == (pthread_t)-1) return ESRCH; + pthread_mutex_lock(&commandSignalMutex); + for (functionParameters *p = runningSignalCommands; p; p = p->nextSignalCommand) { + if (p->pid != pid || !pthread_equal(p->signalThread, thread)) continue; + if (pid >= 0 && pid < IOS_PID_STATUS_SLOTS && atomic_load(&pidWorkerThread[pid]) == thread) { + // Proxy cleanup records the worker command's actual status. + atomic_store(&pidWorkerSignal[pid], SIGINT); + } else { + p->cancellationSignal = SIGINT; + } + int error = pthread_cancel(thread); + if (error) p->cancellationSignal = 0; + pthread_mutex_unlock(&commandSignalMutex); + return error; + } + pthread_mutex_unlock(&commandSignalMutex); + return ESRCH; +} + void ios_switchSession(const void* sessionId) { char* sessionName = (char*) sessionId; // NSLog(@"Switching to session: %s\n", sessionName); @@ -4198,6 +4702,19 @@ int ios_system(const char* inputCmd) { NSLog(@"params->stdin: %d thread_stdin: %d \n", fileno(params->stdin), fileno(thread_stdin)); params->isPipeErr = (params->stderr != thread_stderr) && (params->stderr != params->stdout); params->storeRootThread = false; + pid_t candidate = ios_currentPid(); + params->pid = ios_getThreadId(candidate) == (pthread_t)-1 ? candidate : -1; + params->asyncSpawn = spawnAsync; + const char *rootMarker = ios_getenv("ASHELL_WORKER_ROOT_PID"); + char *rootEnd = NULL; + long rootPid = rootMarker ? strtol(rootMarker, &rootEnd, 10) : -1; + bool workerRoot = rootMarker && rootEnd != rootMarker && *rootEnd == '\0' && + rootPid >= 0 && params->pid >= 0 && rootPid == params->pid; + params->workerEligible = workerRequired(commandName.UTF8String) + ? (spawnedChild || !workerRoot) + : workerWanted(commandName.UTF8String) && + (spawnedChild || ios_getenv("ASHELL_WORKER_PID") == NULL); + if (params->asyncSpawn) params->backgroundCommand = true; // params->session = currentSession; // Before starting, do we have enough file descriptors available? int numFileDescriptorsOpen = 0; @@ -4253,10 +4770,10 @@ int ios_system(const char* inputCmd) { if (createOwnedCommand(&_tid, params) != 0) return; // ios_storeThreadId(_tid); if (currentSession->mainThreadId == NULL) currentSession->mainThreadId = _tid; - if (!joinMainThread) pthread_detach(_tid); // a thread must be either joined or detached + if (!joinMainThread || params->asyncSpawn) pthread_detach(_tid); // a thread must be either joined or detached }]; // Wait for this process to finish: - if (joinMainThread && (_tid != NULL)) { + if (joinMainThread && !params->asyncSpawn && (_tid != NULL)) { pthread_join(_tid, NULL); // If there are auxiliary process, also wait for them: if (currentSession->lastThreadId > 0) pthread_join(currentSession->lastThreadId, NULL); @@ -4271,7 +4788,7 @@ int ios_system(const char* inputCmd) { // ios_storeThreadId(_tid); if (currentSession->mainThreadId == NULL) currentSession->mainThreadId = _tid; // Wait for this process to finish: - if (joinMainThread) { + if (joinMainThread && !params->asyncSpawn) { pthread_join(_tid, NULL); // If there are auxiliary process, also wait for them: if (currentSession->lastThreadId > 0) pthread_join(currentSession->lastThreadId, NULL); @@ -4290,7 +4807,7 @@ int ios_system(const char* inputCmd) { if (createOwnedCommand(&_tid_local, params) == 0) { // The last command on the command line (with multiple pipes) will be created first // fprintf(stderr, "Started thread = %x\n", _tid_local); - if (currentSession->lastThreadId == 0) currentSession->lastThreadId = _tid_local; // will be joined later + if (currentSession->lastThreadId == 0 && !params->asyncSpawn) currentSession->lastThreadId = _tid_local; // will be joined later else pthread_detach(_tid_local); // a thread must be either joined or detached. } } diff --git a/ios_system.xcodeproj/project.pbxproj b/ios_system.xcodeproj/project.pbxproj index 9237fce8..7d0c58e8 100644 --- a/ios_system.xcodeproj/project.pbxproj +++ b/ios_system.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 601B00000000000000000001 /* ios_spawn.h in Headers */ = {isa = PBXBuildFile; fileRef = 601B00000000000000000002 /* ios_spawn.h */; settings = {ATTRIBUTES = (Public, ); }; }; 2209215C24B3B05A00D3327B /* open.m in Sources */ = {isa = PBXBuildFile; fileRef = 221CD98D21E8B03D00F3B47C /* open.m */; }; 22122E2C21BC130700D8FE74 /* say.m in Sources */ = {isa = PBXBuildFile; fileRef = 22122E2A21BC129000D8FE74 /* say.m */; }; 2216426D2B7B9B5F00043766 /* ssh-add.c in Sources */ = {isa = PBXBuildFile; fileRef = 2216426C2B7B9B5F00043766 /* ssh-add.c */; }; @@ -897,6 +898,7 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 601B00000000000000000002 /* ios_spawn.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ios_spawn.h; sourceTree = SOURCE_ROOT; }; 22122E2A21BC129000D8FE74 /* say.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = say.m; sourceTree = ""; }; 2216426C2B7B9B5F00043766 /* ssh-add.c */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.c; name = "ssh-add.c"; path = "ssh_keygen/ssh-add.c"; sourceTree = SOURCE_ROOT; }; 2217ECD321CBA5F40049B382 /* entropy.c */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.c; name = entropy.c; path = ssh_keygen/entropy.c; sourceTree = ""; }; @@ -2970,6 +2972,7 @@ 2263787D1FDB3EE400AE8827 /* extern.h in Headers */, 226378781FDB3EE400AE8827 /* pathnames.h in Headers */, 223496B01FD5FC71007ED1A9 /* ios_system.h in Headers */, + 601B00000000000000000001 /* ios_spawn.h in Headers */, 226378751FDB3EE400AE8827 /* termcap.h in Headers */, 226378951FDB3EE400AE8827 /* ls.h in Headers */, 225782911FDB4D390050F312 /* curl_config.h in Headers */, diff --git a/ios_system/ios_system.h b/ios_system/ios_system.h index f963afb3..dc8e2042 100644 --- a/ios_system/ios_system.h +++ b/ios_system/ios_system.h @@ -1,3 +1,4 @@ +#include "ios_spawn.h" // // ios_system.h // ios_system @@ -39,6 +40,9 @@ extern int ios_system(const char* inputCmd); // execute this command (executable extern FILE *ios_popen(const char *command, const char *type); // Execute this command and pipe the result extern int ios_kill(void); // kill the current running command extern int ios_killpid(pid_t pid, int sig); // kill the current running command +// Cancel a direct command with status 130; worker proxies retain their own status handling. +// Return ESRCH if absent. Does not deliver SIGINT handlers. +extern int ios_interruptpid(pid_t pid); extern int chdir(const char* path); extern int ios_isatty(int fd); // test whether a file descriptor refers to a terminal diff --git a/libc_replacement.c b/libc_replacement.c index b2217d1d..3a3ba777 100644 --- a/libc_replacement.c +++ b/libc_replacement.c @@ -210,6 +210,10 @@ void ios_storeCommandStatus(int status) { } } +int ios_pidStatus(pid_t pid) { + return pid > 0 && pid < IOS_MAX_THREADS ? atomic_load(&command_status[pid]) : -1; +} + static int wait_status(pid_t pid) { return pid > 0 && pid < IOS_MAX_THREADS ? atomic_load(&command_status[pid]) : ios_getCommandStatus(); @@ -285,6 +289,7 @@ static int pidSlotIsFree(void) { // We do not recycle process ids too quickly to avoid collisions. void storeEnvironment(char* envp[]); +extern void ios_resetPidWorkerState(pid_t pid); static inline const pid_t ios_nextAvailablePid(void) { ios_cleanupWait(); // Don't start a command while another is ending. // fprintf(stderr, "Locking in ios_nextAvailablePid\n"); @@ -296,6 +301,7 @@ static inline const pid_t ios_nextAvailablePid(void) { current_pid = last_allocated_pid + 1; last_allocated_pid = current_pid; command_status[current_pid] = 130; // Cancellation before a recorded exit. + ios_resetPidWorkerState(current_pid); thread_ids[current_pid] = -1; // Not yet started numVariablesSet[current_pid] = 0; environment[current_pid] = NULL; @@ -336,6 +342,7 @@ static inline const pid_t ios_nextAvailablePid(void) { pthread_t thread_pid = ios_getThreadId(current_pid); if (thread_pid == 0) { // We found a not-active pid command_status[current_pid] = 130; // Cancellation before a recorded exit. + ios_resetPidWorkerState(current_pid); thread_ids[current_pid] = -1; // Not yet started numVariablesSet[current_pid] = 0; environment[current_pid] = NULL; @@ -355,17 +362,22 @@ static inline const pid_t ios_nextAvailablePid(void) { } } -inline void ios_storeThreadId(pthread_t thread) { +void ios_storeThreadIdForPid(pid_t pid, pthread_t thread) { // To avoid issues when a command starts a command without forking, // we only store thread IDs for the first thread of the "process". - // fprintf(stderr, "Unlocking pid %d, storing thread %x current value: %x\n", current_pid, thread, thread_ids[current_pid]); - if (thread_ids[current_pid] == -1) { - thread_ids[current_pid] = thread; + // fprintf(stderr, "Unlocking pid %d, storing thread %x current value: %x\n", pid, thread, thread_ids[pid]); + if (thread_ids[pid] == -1) { + thread_ids[pid] = thread; if (!thread) notify_thread_completion(); } pthread_mutex_unlock(&pid_mtx); } +inline void ios_storeThreadId(pthread_t thread) { + ios_storeThreadIdForPid(current_pid, thread); +} + + char* libc_getenv(const char* variableName) { if (environment[current_pid] != NULL) { if (variableName == NULL) { return NULL; } @@ -657,6 +669,14 @@ void ios_releaseBackgroundThread(pthread_t thread) { // fprintf(stderr, "Not found\n"); } +void ios_releaseBackgroundThreadId(pid_t pid) { + if (thread_ids[pid] != 0) { + current_pid = previousPid[pid]; + thread_ids[pid] = 0; + notify_thread_completion(); + } +} + void ios_releaseThreadId(pid_t pid) { // Don't reset the environment; sometimes, commands try to change the environment while it is being erased. // resetEnvironment(pid); @@ -683,6 +703,8 @@ pid_t fork(void) { return ios_nextAvailablePid(); } // increases current_pid by pid_t ios_fork(void) { return ios_nextAvailablePid(); } // increases current_pid by 1. pid_t vfork(void) { return ios_nextAvailablePid(); } + + // simple replacement of waitpid for swift programs // We use "optnone" to prevent optimization, otherwise the while loops never end. __attribute__ ((optnone)) void ios_waitpid(pid_t pid) { diff --git a/ssh-stock/README.md b/ssh-stock/README.md new file mode 100644 index 00000000..2bf44974 --- /dev/null +++ b/ssh-stock/README.md @@ -0,0 +1,29 @@ +# Stock OpenSSH clients + +Builds on a-shell#1089 and holzschu/ios_system#201. The application worker foundation must provide nested isolated spawn with terminal selection and separate stderr. The qualified generic foundation includes that follow-up and explicit root identities. + +This recipe builds OpenSSH 10.6p1 ssh, ssh-keygen, scp and sftp from the verified portable release archive. The adapter uses ios_system command streams and its isolated spawn API for child SSH commands. The application opts the stock client commands into `ASHELL_WORKER_COMMANDS`. Each opted-in invocation needs a fresh extension process; missing worker APIs or worker exhaustion return status 127 instead of running these programs in a shared thread. The application must set ASHELL_WORKER_ROOT_PID to its reserved ios_system PID before executing a worker root command. + +After each remote-source SCP transfer, the adapter waits for the SSH child and checks its exit status before starting the next source. This uses the same wait/status handling as stock SCP's remote-to-remote SFTP path and keeps sequential transfers within one child-worker reservation. + +Download https://cdn.openbsd.org/pub/OpenBSD/OpenSSH/portable/openssh-10.6p1.tar.gz into INPUTS, then run: + +```sh +bash ssh-stock/build.sh iphonesimulator INPUTS FRESH_OUTPUT VERIFIED_FRAMEWORKS +``` + +Use iphoneos for device frameworks. VERIFIED_FRAMEWORKS contains matching ios_system.framework and openssl.framework inputs. The output contains four unsigned frameworks and source/toolchain/input receipts. Signing, embedding the frameworks and command registration belong to the application. + +Strict POSIX spawn remains extension-only. + +The recipe retains the supplied OpenSSL version. The tested input was OpenSSL 1.1.1w; updating that dependency is separate work. + +With a disposable SSH host and identity, the public CLI check runs as: + +```sh +dash ssh-stock/phone-client.sh ABSOLUTE_NEW_CASE_DIRECTORY HOST PORT USER IDENTITY KNOWN_HOSTS +``` + +The check covers Ed25519 generation/readback, strict key login and byte-identical scp/sftp transfers, including multiple remote SCP sources in one invocation, a missing source returning a nonzero status while a later source still transfers, and a successful login afterward. Phone qualification on 2026-10-23 passed the original transfer fixture with the production worker correction. The later multi-source SCP regression first exhausted a three-worker Simulator pool, then passed through the same SSH caller with the same three-worker capacity after the transport-wait correction. A preserved identity, SSH config and known_hosts created by the old 8.5 build also passed strict login. These results qualify the integrated worker build; these phone results do not qualify later public-source revisions. + +Run `dash ssh-stock/home-migration.sh CASE_DIR HOST PORT USER THROWAWAY_IDENTITY KNOWN_HOSTS` through the app to verify default config, Ed25519 identity and strict known_hosts lookup using `SSH_HOME/.ssh`, without `-F`, `-i` or `UserKnownHostsFile`. The adapter uses the existing app `SSH_HOME` root (Documents by default), falling back to HOME when that override is absent. Keep CASE_DIR fresh; the fixture retains its throwaway files for inspection. diff --git a/ssh-stock/ashell-ssh.h b/ssh-stock/ashell-ssh.h new file mode 100644 index 00000000..cd0e8a51 --- /dev/null +++ b/ssh-stock/ashell-ssh.h @@ -0,0 +1,70 @@ +#ifndef ASHELL_SSH_H +#define ASHELL_SSH_H +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#undef iswprint +#define waitpid ios_full_waitpid +#undef stdin +#undef stdout +#undef stderr +#define stdin thread_stdin +#define stdout thread_stdout +#define stderr thread_stderr +#undef STDIN_FILENO +#undef STDOUT_FILENO +#undef STDERR_FILENO +#define STDIN_FILENO fileno(thread_stdin) +#define STDOUT_FILENO fileno(thread_stdout) +#define STDERR_FILENO fileno(thread_stderr) +#define isatty ios_isatty + +#define printf(...) fprintf(thread_stdout, __VA_ARGS__) +static inline int ashell_ssh_spawn(const char *path, char *const argv[], int input, int output, pid_t *pid) { + ios_posix_spawn_file_actions_t actions; + int error = ios_posix_spawn_file_actions_init(&actions); + if (error) return error; + error = ios_posix_spawn_file_actions_adddup2(&actions, input, 0); + if (!error) error = ios_posix_spawn_file_actions_adddup2(&actions, output, 1); + if (!error) error = ios_posix_spawn(pid, path, &actions, NULL, argv, environmentVariables(ios_currentPid())); + ios_posix_spawn_file_actions_destroy(&actions); + return error; +} +static inline struct passwd *ashell_ssh_getpwuid(uid_t uid) { + static __thread struct passwd user; + struct passwd *original = getpwuid(uid); + if (original) user = *original; + else { + // Sandboxed Simulator processes may have no accessible passwd entry. + if (uid != getuid()) return NULL; + memset(&user, 0, sizeof(user)); + user.pw_name = getenv("USER"); + if (!user.pw_name || !*user.pw_name) user.pw_name = "mobile"; + user.pw_passwd = "*"; + user.pw_uid = uid; + user.pw_gid = getgid(); + user.pw_gecos = "a-Shell"; + user.pw_shell = "dash"; + } + const char *home = getenv("SSH_HOME"); + if (!home || !*home) home = getenv("HOME"); + if (home && *home) user.pw_dir = (char *)home; + return &user; +} +#define getpwuid ashell_ssh_getpwuid +// A spawn adapter cannot duplicate an arbitrary instruction pointer. +static inline pid_t ashell_ssh_fork(void) { errno = ENOTSUP; return -1; } +#define fork ashell_ssh_fork +static inline int ashell_ssh_daemon(int nochdir, int noclose) { + (void)nochdir; (void)noclose; errno = ENOTSUP; return -1; +} +#define daemon ashell_ssh_daemon +#endif diff --git a/ssh-stock/build.sh b/ssh-stock/build.sh new file mode 100644 index 00000000..fa4bb640 --- /dev/null +++ b/ssh-stock/build.sh @@ -0,0 +1,64 @@ +#!/bin/bash +set -euo pipefail +# Build stock OpenSSH client frameworks; the application owns signing and installation. +if [ "$#" -ne 4 ]; then + echo "Usage: $0 " >&2 + exit 64 +fi +sdk=$1; inputs=$2; output=$3; frameworks=$4 +case "$sdk" in + iphoneos) target=arm64-apple-ios16.0 ;; + iphonesimulator) target=arm64-apple-ios16.0-simulator ;; + *) echo "Unsupported SDK: $sdk" >&2; exit 64 ;; +esac +recipe=$(cd "$(dirname "$0")" && pwd) +runtime=$(cd "$recipe/.." && pwd) +inputs=$(cd "$inputs" && pwd) +frameworks=$(cd "$frameworks" && pwd) +mkdir -p "$output" +output=$(cd "$output" && pwd) +(cd "$inputs" && shasum -a 256 -c "$recipe/sources.sha256") +for path in ios_system.framework/ios_system openssl.framework/openssl openssl.framework/Headers/opensslv.h; do + test -f "$frameworks/$path" || { echo "Missing verified framework input: $path" >&2; exit 1; } +done +shasum -a 256 "$frameworks/ios_system.framework/ios_system" "$frameworks/openssl.framework/openssl" > "$output/framework-inputs.sha256" +{ + printf '%s\n' "$sdk" "$target" + xcodebuild -version + shasum -a 256 "$recipe/build.sh" "$recipe/embedded.patch" "$recipe/ashell-ssh.h" "$recipe/sources.sha256" + cat "$output/framework-inputs.sha256" +} > "$output/recipe-next.txt" +if [ -f "$output/recipe.txt" ] && ! cmp -s "$output/recipe.txt" "$output/recipe-next.txt"; then + echo "Build inputs changed; use a fresh output directory" >&2 + exit 1 +fi +mv "$output/recipe-next.txt" "$output/recipe.txt" +if [ ! -d "$output/source" ]; then + source_stage=$(mktemp -d "$output/source.XXXXXX") + trap 'rm -rf "$source_stage"' EXIT + tar -xzf "$inputs/openssh-10.6p1.tar.gz" -C "$source_stage" --strip-components=1 + patch -d "$source_stage" -p1 < "$recipe/embedded.patch" + mv "$source_stage" "$output/source" + trap - EXIT +fi +mkdir -p "$output/crypto/include" "$output/crypto/lib" "$output/build" +ln -sfn "$frameworks/openssl.framework/Headers" "$output/crypto/include/openssl" +ln -sfn "$frameworks/openssl.framework/openssl" "$output/crypto/lib/libcrypto.dylib" +cd "$output/build" +if [ ! -f Makefile ]; then + CC="xcrun --sdk $sdk clang -target $target" "$output/source/configure" --host=aarch64-apple-darwin --with-ssl-dir="$output/crypto" --without-pam --without-libedit --disable-strip --without-sandbox +fi +nice -n 15 make -j2 CFLAGS="-Oz -fPIC -Wno-deprecated-declarations -DASHELL_OPENSSH -I$recipe -I$runtime" LDFLAGS="-dynamiclib -L. -Lopenbsd-compat -F$frameworks -framework ios_system -L$output/crypto/lib" ssh ssh-keygen scp sftp +for pair in 'ssh SSHStock' 'ssh-keygen SSHKeygenStock' 'scp SCPStock' 'sftp SFTPStock'; do + read -r command name <<< "$pair" + framework="$output/sdk/$name.framework" + mkdir -p "$framework" + cp "$command" "$framework/$name" + xcrun install_name_tool -id "@rpath/$name.framework/$name" "$framework/$name" + cat > "$framework/Info.plist" < + +CFBundleExecutable$nameCFBundleIdentifierorg.a-shell.$nameCFBundleName$nameCFBundlePackageTypeFMWKCFBundleVersion1CFBundleShortVersionString10.6p1MinimumOSVersion16.0 +PLIST + shasum -a 256 "$framework/$name" +done diff --git a/ssh-stock/embedded.patch b/ssh-stock/embedded.patch new file mode 100644 index 00000000..d5e032ca --- /dev/null +++ b/ssh-stock/embedded.patch @@ -0,0 +1,208 @@ +--- a/includes.h ++++ b/includes.h +@@ -181,4 +181,8 @@ + + #include "entropy.h" + ++#ifdef ASHELL_OPENSSH ++#include "ashell-ssh.h" ++#endif ++ + #endif /* INCLUDES_H */ +--- a/log.c ++++ b/log.c +@@ -56,7 +56,7 @@ + + static LogLevel log_level = SYSLOG_LEVEL_INFO; + static int log_on_stderr = 1; +-static int log_stderr_fd = STDERR_FILENO; ++static int log_stderr_fd = 2; + static int log_facility = LOG_AUTH; + static const char *argv0; + static log_handler_fn *log_handler; +@@ -198,6 +198,10 @@ + struct syslog_data sdata = SYSLOG_DATA_INIT; + #endif + ++ ++#ifdef ASHELL_OPENSSH ++ log_stderr_fd = STDERR_FILENO; ++#endif + argv0 = av0; + + if (log_change_level(level) != 0) { +--- a/ssh.c ++++ b/ssh.c +@@ -42,6 +42,10 @@ + + #include "includes.h" + ++#ifdef ASHELL_OPENSSH ++#define main ashell_ssh_main ++#endif ++ + #include + #include + #include +@@ -104,7 +108,7 @@ + extern char *__progname; + + /* Saves a copy of argv for setproctitle emulation */ +-#ifndef HAVE_SETPROCTITLE ++#if !defined(HAVE_SETPROCTITLE) && !defined(ASHELL_OPENSSH) + static char **saved_av; + #endif + +@@ -643,11 +647,13 @@ + * Discard other fds that are hanging around. These can cause problem + * with backgrounded ssh processes started by ControlPersist. + */ ++#ifndef ASHELL_OPENSSH + closefrom(STDERR_FILENO + 1); ++#endif + + __progname = ssh_get_progname(av[0]); + +-#ifndef HAVE_SETPROCTITLE ++#if !defined(HAVE_SETPROCTITLE) && !defined(ASHELL_OPENSSH) + /* Prepare for later setproctitle emulation */ + /* Save argv so it isn't clobbered by setproctitle() emulation */ + saved_av = xcalloc(ac + 1, sizeof(*saved_av)); +--- a/ssh-keygen.c ++++ b/ssh-keygen.c +@@ -13,6 +13,10 @@ + */ + + #include "includes.h" ++ ++#ifdef ASHELL_OPENSSH ++#define main ashell_ssh_keygen_main ++#endif + + #include + #include +--- a/sftp.c ++++ b/sftp.c +@@ -16,6 +16,10 @@ + */ + + #include "includes.h" ++ ++#ifdef ASHELL_OPENSSH ++#define main ashell_sftp_main ++#endif + + #include + #include +@@ -2431,6 +2435,25 @@ + static void + connect_to_server(char *path, char **args, int *in, int *out) + { ++#ifdef ASHELL_OPENSSH ++ ++ int pair[2], error, output; ++ pid_t child; ++ if (socketpair(AF_UNIX, SOCK_STREAM, 0, pair) == -1) fatal("socketpair: %s", strerror(errno)); ++ output = dup(pair[0]); ++ if (output == -1) { ++ error = errno; ++ close(pair[0]); close(pair[1]); ++ fatal("dup: %s", strerror(error)); ++ } ++ error = ashell_ssh_spawn(path, args, pair[1], pair[1], &child); ++ close(pair[1]); ++ if (error) { close(pair[0]); close(output); fatal("spawn: %s", strerror(error)); } ++ sshpid = child; ++ *in = pair[0]; *out = output; ++ ++#else ++ + int c_in, c_out; + #ifdef USE_PIPES + int pin[2], pout[2]; +@@ -2488,6 +2511,8 @@ + ssh_signal(SIGCHLD, sigchld_handler); + close(c_in); + close(c_out); ++ ++#endif + } + + static void +--- a/scp.c ++++ b/scp.c +@@ -73,6 +73,10 @@ + + #include "includes.h" + ++#ifdef ASHELL_OPENSSH ++#define main ashell_scp_main ++#endif ++ + #include + #include + #include +@@ -260,6 +264,38 @@ + do_cmd(char *program, char *host, char *remuser, int port, int subsystem, + char *cmd, int *fdin, int *fdout, pid_t *pid) + { ++#ifdef ASHELL_OPENSSH ++ ++ int pair[2], output, error; ++ arglist child = {0}; ++ for (u_int i = 0; i < args.num; i++) ++ addargs(&child, "%s", args.list[i]); ++ if (port == -1) port = sshport; ++ replacearg(&child, 0, "%s", program); ++ if (port != -1) addargs(&child, "-p%d", port); ++ if (remuser != NULL) { addargs(&child, "-l"); addargs(&child, "%s", remuser); } ++ if (subsystem) addargs(&child, "-s"); ++ addargs(&child, "--"); addargs(&child, "%s", host); addargs(&child, "%s", cmd); ++ if (socketpair(AF_UNIX, SOCK_STREAM, 0, pair) == -1) { ++ freeargs(&child); ++ return -1; ++ } ++ output = dup(pair[1]); ++ if (output == -1) { ++ error = errno; ++ close(pair[0]); close(pair[1]); freeargs(&child); ++ errno = error; ++ return -1; ++ } ++ error = ashell_ssh_spawn(program, child.list, pair[0], pair[0], pid); ++ close(pair[0]); ++ freeargs(&child); ++ if (error) { close(pair[1]); close(output); errno = error; return -1; } ++ *fdin = pair[1]; *fdout = output; ++ return 0; ++ ++#else ++ + #ifdef USE_PIPES + int pin[2], pout[2]; + #else +@@ -349,6 +385,8 @@ + ssh_signal(SIGHUP, killchild); + return 0; + } ++ ++#endif + } + + /* +@@ -1275,6 +1313,15 @@ + (void) close(remin); + (void) close(remout); + remin = remout = -1; ++#ifdef ASHELL_OPENSSH ++ /* Reap this transport before admitting the next isolated worker. */ ++ int status; ++ if (waitpid(do_cmd_pid, &status, 0) == -1) ++ ++errs; ++ else if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) ++ ++errs; ++ do_cmd_pid = -1; ++#endif + continue; + } + /* SCP */ diff --git a/ssh-stock/home-migration.sh b/ssh-stock/home-migration.sh new file mode 100644 index 00000000..15712327 --- /dev/null +++ b/ssh-stock/home-migration.sh @@ -0,0 +1,18 @@ +#!/bin/sh +# Verify default config, identity and known_hosts lookup without explicit path options. +set -eu +if [ "$#" -ne 6 ]; then + echo "Usage: $0 CASE_DIR HOST PORT USER THROWAWAY_IDENTITY KNOWN_HOSTS" >&2 + exit 64 +fi +case_dir=$1; host=$2; port=$3; user=$4; identity=$5; known_hosts=$6 +mkdir "$case_dir" +mkdir "$case_dir/.ssh" +chmod 700 "$case_dir/.ssh" +cp "$identity" "$case_dir/.ssh/id_ed25519" +chmod 600 "$case_dir/.ssh/id_ed25519" +cp "$known_hosts" "$case_dir/.ssh/known_hosts" +printf 'Host ashell-home-fixture\n HostName %s\n Port %s\n User %s\n StrictHostKeyChecking yes\n IdentitiesOnly yes\n BatchMode yes\n ConnectTimeout 5\n' "$host" "$port" "$user" > "$case_dir/.ssh/config" +SSH_HOME="$case_dir" ssh ashell-home-fixture 'echo ASHELL_SSH_HOME_LOGIN_OK' > "$case_dir/login.txt" 2>&1 +grep '^ASHELL_SSH_HOME_LOGIN_OK$' "$case_dir/login.txt" +echo ASHELL_SSH_HOME_MIGRATION_PASS diff --git a/ssh-stock/phone-client.sh b/ssh-stock/phone-client.sh new file mode 100755 index 00000000..39fe88e1 --- /dev/null +++ b/ssh-stock/phone-client.sh @@ -0,0 +1,46 @@ +#!/bin/sh +# Use a throwaway identity already authorized on the disposable test host. +main() { + if [ "$#" -ne 6 ]; then + echo "Usage: $0 CASE_DIRECTORY HOST PORT USER IDENTITY KNOWN_HOSTS" >&2 + return 64 + fi + case_dir=$1; host=$2; port=$3; user=$4; identity=$5; known_hosts=$6 + case "$case_dir" in /*) ;; *) echo 'CASE_DIRECTORY must be absolute' >&2; return 64 ;; esac + case "$case_dir" in *[!a-zA-Z0-9_./-]*) echo 'Use a case path without spaces or shell metacharacters' >&2; return 64 ;; esac + for config_path in "$identity" "$known_hosts"; do + case "$config_path" in + ''|*[!a-zA-Z0-9_./~-]*) echo 'Use identity and known_hosts paths without spaces or config metacharacters' >&2; return 64 ;; + esac + done + mkdir "$case_dir" || return 1 + ssh -V > "$case_dir/version.txt" 2>&1 || return 1 + grep 'OpenSSH_10.6p1' "$case_dir/version.txt" || return 1 + ssh-keygen -q -t ed25519 -N '' -f "$case_dir/generated-key" || return 1 + ssh-keygen -y -f "$case_dir/generated-key" > "$case_dir/generated-public-key" || return 1 + grep '^ssh-ed25519 ' "$case_dir/generated-public-key" > /dev/null || return 1 + printf 'Host ashell-ssh-fixture\n HostName %s\n Port %s\n User %s\n IdentityFile %s\n UserKnownHostsFile %s\n StrictHostKeyChecking yes\n IdentitiesOnly yes\n BatchMode yes\n ConnectTimeout 5\n ConnectionAttempts 1\n' "$host" "$port" "$user" "$identity" "$known_hosts" > "$case_dir/config" + remote_dir=/tmp/ashell-ssh-$(basename "$case_dir") + ssh -F "$case_dir/config" ashell-ssh-fixture "mkdir '$remote_dir' && echo ASHELL_SSH_LOGIN_OK" > "$case_dir/login.txt" 2>&1 || return 1 + grep '^ASHELL_SSH_LOGIN_OK$' "$case_dir/login.txt" || return 1 + printf 'ASHELL_SSH round trip\n' > "$case_dir/input" + scp -F "$case_dir/config" "$case_dir/input" "ashell-ssh-fixture:$remote_dir/scp" > "$case_dir/scp.txt" 2>&1 || return 1 + scp -F "$case_dir/config" "ashell-ssh-fixture:$remote_dir/scp" "$case_dir/scp-back" >> "$case_dir/scp.txt" 2>&1 || return 1 + diff -q "$case_dir/input" "$case_dir/scp-back" || return 1 + printf 'put %s %s\nget %s %s\n' "$case_dir/input" "$remote_dir/sftp" "$remote_dir/sftp" "$case_dir/sftp-back" > "$case_dir/sftp.batch" + sftp -F "$case_dir/config" -b "$case_dir/sftp.batch" ashell-ssh-fixture > "$case_dir/sftp.txt" 2>&1 || return 1 + diff -q "$case_dir/input" "$case_dir/sftp-back" || return 1 + mkdir "$case_dir/multi" || return 1 + scp -F "$case_dir/config" "ashell-ssh-fixture:$remote_dir/scp" "ashell-ssh-fixture:$remote_dir/sftp" "$case_dir/multi/" > "$case_dir/scp-multi.txt" 2>&1 || return 1 + diff -q "$case_dir/input" "$case_dir/multi/scp" || return 1 + diff -q "$case_dir/input" "$case_dir/multi/sftp" || return 1 + mkdir "$case_dir/multi-missing" || return 1 + scp -F "$case_dir/config" "ashell-ssh-fixture:$remote_dir/missing" "ashell-ssh-fixture:$remote_dir/scp" "$case_dir/multi-missing/" > "$case_dir/scp-missing.txt" 2>&1 + scp_status=$? + [ "$scp_status" -ne 0 ] || return 1 + diff -q "$case_dir/input" "$case_dir/multi-missing/scp" || return 1 + ssh -F "$case_dir/config" ashell-ssh-fixture 'echo ASHELL_SSH_RECOVERED' > "$case_dir/recovered.txt" 2>&1 || return 1 + grep '^ASHELL_SSH_RECOVERED$' "$case_dir/recovered.txt" || return 1 + echo ASHELL_SSH_CLIENT_PASS +} +main "$@" diff --git a/ssh-stock/sources.sha256 b/ssh-stock/sources.sha256 new file mode 100644 index 00000000..4b7b0a7b --- /dev/null +++ b/ssh-stock/sources.sha256 @@ -0,0 +1 @@ +a9dc9565dffe8640f64d863cd29a32bc4a3dbdec0566a7fc44c5d6ee767d5f39 openssh-10.6p1.tar.gz diff --git a/tests/README.md b/tests/README.md index e290343c..9dd80bd4 100644 --- a/tests/README.md +++ b/tests/README.md @@ -214,6 +214,82 @@ xcrun simctl spawn "$SIMULATOR_UDID" "$PWD/.build/child-status/child-status" Expected: exit 0 and `child A expected=7 observed=7; child B expected=23 observed=23: PASS`, `missing command expected=127 observed=127` and `empty command expected=0 observed=0; NULL command expected=0 observed=0`. With shared session status, both children report 23 and the test exits 1. A ten-second alarm bounds a stalled wait. This fixture covers a normal return, `ios_exit` and commands that do not start; it does not qualify cancellation, thread-creation failure or PID reuse. +# Worker registry selection + +`worker-registry-selection.sh` checks the public shell route in a disposable worker-enabled host. Put a `git` script that prints `DISPATCH_WRAPPER_SELECTED` first in the host's PATH, then run the test with the absolute zero-byte native Git marker as its argument. The bare command must select the wrapper; the absolute marker must print `git version ...`. The wrapper belongs only in a disposable test container. Set its HOME and working directory to that container before launching the test. Expected: exit 0 and `WORKER_REGISTRY_SELECTION_PASS`. Without preserving the resolved registry selection across worker dispatch, the absolute marker selects the wrapper again and the test exits 1. + +# Spawned worker resource usage + +`spawn-rusage.m` exercises the public `ios_posix_spawn`, `ios_wait4` and `ios_killpid` APIs against real isolated workers. Its CPU-bound awk child must return a successful wait status, available usage and positive user time; its killed sleep child must return exit 137 with unavailable usage. The snapshot is worker `RUSAGE_SELF`, including process startup, rather than command-only CPU time. + +Build this fixture as a command framework with `-Dmain=spawn_rusage_main`, linking Foundation and the matching ios_system framework. In a disposable worker-enabled host, register `spawn-rusage` as `["SpawnRusageChecks.framework/SpawnRusageChecks", "spawn_rusage_main", "", "file"]` and provide its empty app/bin marker. Invoke the absolute marker with the test container HOME as its only argument, after setting HOME and cwd to that container. The host and every worker need the matching resource-usage broker protocol; a plain standalone Simulator process has no worker broker. No production test entry point is required. + +The arm64 Simulator host with five workers on Xcode 27.1 exited 0 with `CPU child user time: 0.517716` and `spawn-rusage: CPU measured, killed child unavailable PASS`. The baseline fails compilation because it has no public `ios_wait4`. This does not qualify physical-device behavior. + +# Detached spawned worker context + +`spawn-context.m` uses `ios_posix_spawn`, `ios_wait4` and `ios_killpid` in a disposable worker-enabled host. It changes the parent cwd while a child sleeps, then verifies completion preserves the parent cwd. A second child receives SIGTERM; wait must return exit 143 while the parent command status stays unchanged. + +Build/register it as a command framework with `-Dmain=spawn_context_main`, Foundation and the matching ios_system framework. Register `spawn-context` as `["SpawnContextChecks.framework/SpawnContextChecks", "spawn_context_main", "", "file"]`, provide its empty app/bin marker, and pass the disposable host HOME as its only argument. The baseline fails the cwd check; the fixed implementation must pass both checks. + +# Spawned PID ownership + +`spawn-pid-reuse.m` waits for a real worker, recycles its PID through the public allocator, registers an ordinary thread and sends SIGINT through `ios_killpid`. Expected: the signal handler runs; the ordinary thread is not cancelled. This fails when worker ownership is not reset on PID allocation. The ordinary thread explicitly unblocks SIGINT because the host may pass a blocked mask. + +`spawn-registration.m` uses an external `pthread_create` scheduling interposer (`spawn-thread-start-hook.c`) to complete an older public PID immediately before a strict child thread starts. Expected: the returned child is registered, bounded WNOHANG polling returns that child and status 0. It also checks that the older PID completed, so an inactive interposer cannot produce a pass. A second run with argument `fail` makes that OS call return EAGAIN and verifies the reserved child slot is released. These guard registration and error cleanup against changes to the global current PID. The hook changes the OS scheduling boundary; ios_system is not instrumented. + +Build/register these as PidReuseChecks.framework/pid_reuse_main and SpawnRegistrationChecks.framework/spawn_registration_main, with command markers pid-reuse and spawn-registration. Each takes the disposable host HOME. Build the hook as a Simulator dylib linked to the matching ios_system framework and insert it only for the registration test using SIMCTL_CHILD_DYLD_INSERT_LIBRARIES when launching the disposable host. Keep the interposer out of production builds. The cwd check in spawn-context also covers the cwd-free PID release. + +`worker-nested-environment.m` registers a parent command (nested_environment_main) and payload (nested_environment_payload_main) in a disposable host; register the payload in workers too. The parent invokes ios_system without ios_fork, with the payload admitted by ASHELL_WORKER_COMMANDS and a custom environment value. Expected: the payload runs in another process, sees the value and exits 0. This guards environment lookup for an unforked command whose parameter PID is -1. No production entry point or internal mock is required. + +`worker-pipeline-signal.m` checks a real worker producer piped into an ordinary command thread under one reserved PID. The external `pipeline-thread-start-hook.c` delays the first OS thread until both stages have captured that reservation, then lets the consumer register first. After the producer exits, SIGINT must reach the consumer's handler and let it return normally. The fixture asserts the producer used another process. A stale worker flag cancels the consumer instead. Register `pipeline-signal`, `pipeline-signal-producer` and `pipeline-signal-consumer` from PipelineSignalChecks.framework, exporting `pipeline_signal_main` (compile with `-Dmain=pipeline_signal_main`), `pipeline_signal_producer` and `pipeline_signal_consumer`. Register the producer in the worker bundles too. The hook controls OS scheduling only and is excluded from production builds. + +For both scheduling hooks, compile against the matching Simulator framework and launch only the disposable host with the hook inserted: + +```sh +xcrun --sdk iphonesimulator clang -target arm64-apple-ios26.0-simulator -dynamiclib -fblocks -F "$FRAMEWORK_DIR" -framework ios_system tests/spawn-thread-start-hook.c -o "$FIXTURE_APP/Frameworks/SpawnThreadHook.dylib" +codesign --force --sign - "$FIXTURE_APP/Frameworks/SpawnThreadHook.dylib" +codesign --force --sign - --preserve-metadata=entitlements "$FIXTURE_APP" +SIMCTL_CHILD_DYLD_INSERT_LIBRARIES="$INSTALLED_APP/Frameworks/SpawnThreadHook.dylib" xcrun simctl launch "$SIMULATOR_UDID" "$FIXTURE_BUNDLE_ID" +``` + +Use the host's command entry point to run `spawn-registration ` and `spawn-registration fail`. For the pipeline check compile `pipeline-thread-start-hook.c` the same way into its own `PipelineThreadHook.dylib`, insert that dylib instead of `SpawnThreadHook.dylib`, and run `pipeline-signal`. Resolve `INSTALLED_APP` with `simctl get_app_container` after installation; the source build directory is not the installed path. A passing registration run reports `completed=1 registered=1` and status 0. The injected failure reports EAGAIN with `child=-1 released=1`. A passing pipeline reports `signal=1 returned=1 isolated=1 ordered=1`. The ordering assertion prevents a pass when the OS hook does not match. + +# Command stream ownership + +`command-streams.m` runs real command implementations through `ios_system` with caller-owned stdin, stdout and stderr registered through the public ownership API. Its stdin is a pipe containing `one two\nthree\n`. It asserts the command status, exact stdout and all three streams remaining usable for caller cleanup. It uses no command stubs. Build `text` and `files` with the same pinned source revision as the runtime, copy those frameworks plus `ios_system.framework` into the test executable's `Frameworks` directory, and copy `Resources/commandDictionary.plist` next to the executable. Compile using the command above with `tests/command-streams.m`; include both `-I .` and `-I ios_system`. + +Run each command in a fresh process on the owned Simulator. The expected argument must contain the final newline: + +```sh +expected=' 14 +' +xcrun simctl spawn "$SIMULATOR_UDID" "$CHECK/command-streams" 'wc -c' "$expected" +``` + +Also run `wc -l` expecting ` 2\n`, `wc -w` expecting ` 3\n`, `wc -m` expecting ` 14\n`, and `wc` expecting ` 2 3 14\n`. Run `grep three`, `egrep three` and `fgrep three` expecting `three\n`; `cksum` expects `112545809 14\n`, and `sum` expects `63079 1\n`. Those checksum literals were independently obtained from the macOS stock commands, not calculated by the implementation under test. + +For the `gzip` output-open error, create a disposable input whose basename has 253 ASCII characters. Its `.gz` output exceeds the filesystem's 255-byte basename limit. Pass `"gzip /absolute/path/to/input"`, an empty expected output argument, and expected status `1` as the optional third argument. The command must fail while retaining all three caller streams and leaving the input intact. Remove the disposable input afterwards. Before the fixes, `wc`, `grep`, `cksum`/`sum` and the `gzip` error case leave only two caller descriptors open; after the fixes all cases exit 0 and print `PASS`. The worker-level pipeline regression in `worker-pipeline.sh` independently checks completion and data transfer for gzip/wc workers. + +# Direct command interruption + +`direct-cancellation.m` registers a native command through the public command catalog, starts it with `ios_system`, interrupts its virtual PID with `ios_interruptpid` and waits for completion. The embedding host ignores process SIGINT; that disposition must not prevent targeted command cancellation. The interrupted command returns 130 and a following completed command returns its own status 7. Interrupting the completed PID returns `ESRCH`. A separate command installs a SIGINT handler and receives the signal through unchanged `ios_killpid(pid, SIGINT)`; its handler runs and it returns its chosen status 23. This covers direct threads; worker-proxy cancellation needs its separate consumer. The a-Shell application companion's `tests/host-cancellation.m` covers host cancellation. + +```sh +mkdir -p .build/direct-cancellation/Frameworks .build/direct-cancellation/bin +cp -R FRAMEWORK_BUILD/ios_system.framework .build/direct-cancellation/Frameworks/ +plutil -create xml1 .build/direct-cancellation/commandDictionary.plist +plutil -insert direct-cancel -json '["SELF","direct_cancel_main","","none"]' .build/direct-cancellation/commandDictionary.plist +touch .build/direct-cancellation/bin/direct-cancel +chmod 755 .build/direct-cancellation/bin/direct-cancel +xcrun --sdk iphonesimulator clang -target arm64-apple-ios16.0-simulator -fobjc-arc -Wall -Wextra -Werror -I ios_system -F .build/direct-cancellation/Frameworks -framework ios_system -framework Foundation -Wl,-rpath,@executable_path/Frameworks -I . -Wl,-export_dynamic tests/direct-cancellation.m -o .build/direct-cancellation/check +codesign --force --sign - .build/direct-cancellation/Frameworks/ios_system.framework +codesign --force --sign - .build/direct-cancellation/check +xcrun simctl spawn "$SIMULATOR_UDID" "$PWD/.build/direct-cancellation/check" "$PWD/.build/direct-cancellation/bin" +``` + +The process has an eight-second bound. Success exits 0 and prints `direct interrupt result=0 status=130 following=7 PASS`. Delivering an ignored process signal alone would let the first command complete normally with status 7. The separate handler case requires `handler delivery result=0 handled=1 status=23 PASS`. + # Redirected alias `alias-redirection.m` defines an alias for a registered command with two arguments, then runs the alias with stdout and stderr redirected to files. The command must receive both arguments and return 7, and each file must hold exactly the line written to it. The expanded command is always a new allocation, and whether the stale bound is exceeded depends on where malloc places it. The alias pads its arguments with 2048 spaces: without the padding the test passed before the fix, with it the abort reproduced in three runs out of three. diff --git a/tests/background-stdin.sh b/tests/background-stdin.sh new file mode 100755 index 00000000..a3561090 --- /dev/null +++ b/tests/background-stdin.sh @@ -0,0 +1,34 @@ +#!/bin/sh +# Run through a-Shell's worker-enabled Dash. +set -eu +work=${TMPDIR:-/tmp}/background-stdin-$$ +mkdir "$work" +trap 'rm -rf "$work"' EXIT HUP INT TERM +printf 'background input must survive\n' >"$work/input" +setenv ASHELL_WORKER_COMMANDS gzip +gzip -c <"$work/input" >"$work/foreground.gz" +gzip -dc <"$work/foreground.gz" >"$work/foreground" +wc -c <"$work/foreground" >"$work/count" +read bytes <"$work/count" +printf 'Foreground bytes=%s\n' "$bytes" +[ "$bytes" -eq 30 ] +IFS= read -r value <"$work/foreground" +[ "$value" = 'background input must survive' ] +printf '%s\n' 'FOREGROUND_STDIN_PASS' +gzip -c <"$work/input" >"$work/background.gz" & +wait +gzip -dc <"$work/background.gz" >"$work/background" +wc -c <"$work/background" >"$work/count" +read bytes <"$work/count" +printf 'Background bytes=%s\n' "$bytes" +[ "$bytes" -eq 30 ] +IFS= read -r value <"$work/background" +[ "$value" = 'background input must survive' ] +printf '%s\n' 'BACKGROUND_STDIN_PASS' +gzip -c >"$work/inherited.gz" & +wait +gzip -dc <"$work/inherited.gz" >"$work/inherited" +wc -c <"$work/inherited" >"$work/count" +read bytes <"$work/count" +[ "$bytes" -eq 0 ] +printf '%s\n' 'INHERITED_BACKGROUND_STDIN_PASS' diff --git a/tests/command-streams.m b/tests/command-streams.m new file mode 100644 index 00000000..184a861a --- /dev/null +++ b/tests/command-streams.m @@ -0,0 +1,47 @@ +// Real commands must leave the embedding caller's borrowed streams open. +#import +#import "ios_system.h" +#include +#include +#include + +static void timedOut(int signal) { _Exit(124); } + +int main(int argc, char **argv) { + @autoreleasepool { + if (argc != 3 && argc != 4) return 64; + signal(SIGALRM, timedOut); + alarm(10); + initializeEnvironment(); + ios_switchSession("command-streams-probe"); + int pipefd[2]; + if (pipe(pipefd)) return 2; + const char inputBytes[] = "one two\nthree\n"; + if (write(pipefd[1], inputBytes, sizeof(inputBytes) - 1) != sizeof(inputBytes) - 1) return 2; + close(pipefd[1]); + FILE *input = fdopen(pipefd[0], "r"), *output = tmpfile(), *error = tmpfile(); + if (!input || !output || !error) return 2; + int descriptors[] = {fileno(input), fileno(output), fileno(error)}; + void *registration = NULL; + if (ashell_register_owned_streams(input, output, error, ®istration)) return 2; + ios_setStreams(input, output, error); + joinMainThread = true; + int status = ios_system(argv[1]); + ashell_wait_owned_streams(registration); + int openStreams = 0; + for (int i = 0; i < 3; i++) openStreams += fcntl(descriptors[i], F_GETFD) >= 0; + dprintf(STDERR_FILENO, "%s: status=%d caller streams open=%d/3\n", argv[1], status, openStreams); + if (openStreams != 3) return 1; + fflush(output); + rewind(output); + char actual[256] = {0}; + size_t count = fread(actual, 1, sizeof(actual) - 1, output); + BOOL passed = (argc == 4 ? status == atoi(argv[3]) : status == 0) && count == strlen(argv[2]) && !strcmp(actual, argv[2]); + ashell_unregister_owned_streams(®istration); + ios_setStreams(stdin, stdout, stderr); + if (fclose(input) || fclose(output) || fclose(error)) return 1; + alarm(0); + dprintf(STDERR_FILENO, "%s: output=[%s] %s\n", argv[1], actual, passed ? "PASS" : "FAIL"); + return passed ? 0 : 1; + } +} diff --git a/tests/direct-cancellation.m b/tests/direct-cancellation.m new file mode 100644 index 00000000..a0da24bb --- /dev/null +++ b/tests/direct-cancellation.m @@ -0,0 +1,72 @@ +#import +#import "ios_system.h" +#include +#include +#include +#include +#include +static _Atomic int entered; +static volatile sig_atomic_t handled; +static void command_interrupt(int number) { + (void)number; + handled = 1; +} +int direct_cancel_main(int argc, char **argv) { + if (argc > 1 && !strcmp(argv[1], "--handler")) { + signal(SIGINT, command_interrupt); + atomic_store(&entered, 1); + for (int n = 0; n < 300 && !handled; n++) usleep(10000); + return handled ? 23 : 7; + } + if (argc > 1) return 7; + atomic_store(&entered, 1); + sleep(3); + return 7; +} +int main(int argc, char **argv) { + @autoreleasepool { + if (argc != 2) return 2; + alarm(8); + initializeEnvironment(); + ios_switchSession("direct-cancel-public-consumer"); + ios_unsetenv("ASHELL_WORKER_COMMANDS"); + ios_unsetenv("ASHELL_HOST_COMMANDS"); + ios_setenv("PATH", argv[1], 1); + FILE *input = tmpfile(), *output = tmpfile(), *error = tmpfile(); + if (!input || !output || !error) return 2; + ios_setStreams(input, output, error); + joinMainThread = false; + replaceCommand(@"direct-cancel", @"direct_cancel_main", false); + signal(SIGINT, SIG_IGN); + pid_t pid = ios_fork(); + ios_system("direct-cancel"); + for (int n = 0; n < 200 && !atomic_load(&entered); n++) usleep(10000); + if (!atomic_load(&entered)) return 2; + int result = ios_interruptpid(pid); + ios_waitpid(pid); + int status = ios_getCommandStatus(); + int interrupted = !result && status == 130; + pid = ios_fork(); + ios_system("direct-cancel --complete"); + ios_waitpid(pid); + int following = ios_getCommandStatus(); + int absent = ios_interruptpid(pid); + if (absent != ESRCH) return 1; + atomic_store(&entered, 0); + pid = ios_fork(); + ios_system("direct-cancel --handler"); + for (int n = 0; n < 200 && !atomic_load(&entered); n++) usleep(10000); + if (!atomic_load(&entered)) return 2; + int handlerResult = ios_killpid(pid, SIGINT); + ios_waitpid(pid); + int handlerStatus = ios_getCommandStatus(); + int handlerPassed = !handlerResult && handled && handlerStatus == 23; + dprintf(1, "handler delivery result=%d handled=%d status=%d %s\n", handlerResult, handled, handlerStatus, handlerPassed ? "PASS" : "FAIL"); + dprintf(1, "direct interrupt result=%d status=%d following=%d %s\n", result, status, following, interrupted && following == 7 ? "PASS" : "FAIL"); + ios_setStreams(stdin, stdout, stderr); + fclose(input); + fclose(output); + fclose(error); + return interrupted && following == 7 && handlerPassed ? 0 : 1; + } +} diff --git a/tests/pipeline-thread-start-hook.c b/tests/pipeline-thread-start-hook.c new file mode 100644 index 00000000..faace54a --- /dev/null +++ b/tests/pipeline-thread-start-hook.c @@ -0,0 +1,38 @@ +#include +#include +#include +#include +#include +#include +extern int ios_currentPid(void); +extern pthread_t ios_getThreadId(int); +static _Atomic int stage = -1; +static _Atomic int ordered; +static dispatch_semaphore_t start_first; +static void *(*first_start)(void *); +static void *first_argument; +void arm_pipeline_thread_order(void) { start_first = dispatch_semaphore_create(0); atomic_store(&ordered, 0); atomic_store(&stage, 0); } +int pipeline_thread_ordered(void) { return atomic_load(&ordered); } +static void *delayed_start(void *unused) { + dispatch_semaphore_wait(start_first, DISPATCH_TIME_FOREVER); + return first_start(first_argument); +} +static int scheduled_create(pthread_t *thread, const pthread_attr_t *attributes, void *(*start)(void *), void *argument) { + Dl_info image; + if (atomic_load(&stage) >= 0 && dladdr((void *)start, &image) && strstr(image.dli_fname, "/ios_system.framework/ios_system")) { + int current = atomic_fetch_add(&stage, 1); + if (current == 0) { + first_start = start; first_argument = argument; + return pthread_create(thread, attributes, delayed_start, NULL); + } + if (current == 1) { + int pid = ios_currentPid(); + dispatch_semaphore_signal(start_first); + for (int n = 0; n < 1000 && ios_getThreadId(pid) == (pthread_t)-1; n++) usleep(1000); + atomic_store(&ordered, ios_getThreadId(pid) != (pthread_t)-1); + atomic_store(&stage, -1); + } + } + return pthread_create(thread, attributes, start, argument); +} +__attribute__((used, section("__DATA,__interpose"))) static struct { const void *replacement; const void *original; } hook = {(const void *)scheduled_create, (const void *)pthread_create}; diff --git a/tests/spawn-context.m b/tests/spawn-context.m new file mode 100644 index 00000000..f3c637b9 --- /dev/null +++ b/tests/spawn-context.m @@ -0,0 +1,49 @@ +// Register in a disposable worker-enabled host; argv[1] is the host HOME. +#import +#import "ios_system.h" +#include "ios_spawn.h" +#include +#include +#include + +int main(int argc, char **argv) { + if (argc != 2) return 64; + alarm(60); + char original[PATH_MAX], observed[PATH_MAX], expected[PATH_MAX]; + if (!getcwd(original, sizeof(original))) return 1; + NSString *base = [@(argv[1]) stringByAppendingPathComponent:[@"Documents/spawn-context-" stringByAppendingString:NSUUID.UUID.UUIDString]]; + NSURL *first = [NSURL fileURLWithPath:[base stringByAppendingPathComponent:@"first"]]; + NSURL *second = [NSURL fileURLWithPath:[base stringByAppendingPathComponent:@"second"]]; + if (![NSFileManager.defaultManager createDirectoryAtURL:first withIntermediateDirectories:YES attributes:nil error:nil] || + ![NSFileManager.defaultManager createDirectoryAtURL:second withIntermediateDirectories:YES attributes:nil error:nil]) return 1; + char *home; + if (asprintf(&home, "HOME=%s", argv[1]) < 0) return 1; + char *environment[] = { home, NULL }; + char *command[] = { "sleep", "1", NULL }; + pid_t pid = -1; + int status = -1; + ios_setDirectoryURL(first); + int error = ios_posix_spawn(&pid, command[0], NULL, NULL, command, environment); + ios_setDirectoryURL(second); + if (!getcwd(expected, sizeof(expected))) return 1; + BOOL passed = !error && ios_wait4(pid, &status, 0, NULL, NULL) == pid && WIFEXITED(status) && WEXITSTATUS(status) == 0 && + getcwd(observed, sizeof(observed)) && !strcmp(observed, expected); + fprintf(thread_stdout, "spawn completion preserves parent cwd: %s\n", passed ? "PASS" : "FAIL"); + if (passed) { + int parentStatus = ios_getCommandStatus(); + char *longCommand[] = { "sleep", "30", NULL }; + error = ios_posix_spawn(&pid, longCommand[0], NULL, NULL, longCommand, environment); + int killed = error ? error : ios_killpid(pid, SIGTERM); + pid_t waited = error ? -1 : ios_wait4(pid, &status, 0, NULL, NULL); + int observedStatus = ios_getCommandStatus(); + passed = !error && !killed && waited == pid && WIFEXITED(status) && + WEXITSTATUS(status) == 128 + SIGTERM && observedStatus == parentStatus; + if (!passed) fprintf(thread_stdout, "spawn=%d kill=%d wait=%d status=%d parent=%d observed=%d\n", error, killed, waited, status, parentStatus, observedStatus); + fprintf(thread_stdout, "spawn cancellation preserves parent status: %s\n", passed ? "PASS" : "FAIL"); + } + ios_setDirectoryURL([NSURL fileURLWithPath:@(original)]); + [NSFileManager.defaultManager removeItemAtPath:base error:nil]; + free(home); + alarm(0); + return passed ? 0 : 1; +} diff --git a/tests/spawn-pid-reuse.m b/tests/spawn-pid-reuse.m new file mode 100644 index 00000000..d43d4607 --- /dev/null +++ b/tests/spawn-pid-reuse.m @@ -0,0 +1,60 @@ +// Register in a disposable worker-enabled host; pass its HOME as argv[1]. +#import +#import "ios_system.h" +#include +#include +#include +#include +#include + +static volatile sig_atomic_t interrupted; +static dispatch_semaphore_t ready; +static void receivedInterrupt(int number) { interrupted = 1; } +static void *ordinaryThread(void *unused) { + sigset_t signals; + sigemptyset(&signals); + sigaddset(&signals, SIGINT); + pthread_sigmask(SIG_UNBLOCK, &signals, NULL); + ios_storeThreadId(pthread_self()); + dispatch_semaphore_signal(ready); + for (int n = 0; n < 100 && !interrupted; n++) usleep(10000); + return NULL; +} +int main(int argc, char **argv) { + if (argc != 2) return 64; + interrupted = 0; + alarm(30); + char *home; + if (asprintf(&home, "HOME=%s", argv[1]) < 0) return 1; + char *environment[] = { home, NULL }, *command[] = { "sleep", "0", NULL }; + pid_t child = -1; + int status; + int error = ios_posix_spawn(&child, command[0], NULL, NULL, command, environment); + free(home); + if (error || ios_wait4(child, &status, 0, NULL, NULL) != child || status) return 1; + BOOL passed = NO; + for (int n = 0; n < 256; n++) { + pid_t candidate = ios_fork(); + if (candidate != child) { ios_storeThreadId(NULL); continue; } + struct sigaction action = {0}, saved = {0}; + action.sa_handler = receivedInterrupt; + sigemptyset(&action.sa_mask); + if (sigaction(SIGINT, &action, &saved)) return 1; + ready = dispatch_semaphore_create(0); + pthread_t thread; + if (pthread_create(&thread, NULL, ordinaryThread, NULL)) return 1; + dispatch_semaphore_wait(ready, DISPATCH_TIME_FOREVER); + BOOL registered = ios_getThreadId(candidate) == thread; + int killed = ios_killpid(candidate, SIGINT); + void *result = NULL; + pthread_join(thread, &result); + passed = registered && killed == 0 && interrupted && result != PTHREAD_CANCELED; + fprintf(thread_stdout, "reused=%d child=%d kill=%d signal=%d cancelled=%d registered=%d\n", candidate, child, killed, interrupted, result == PTHREAD_CANCELED, registered); + ios_releaseThreadId(candidate); + sigaction(SIGINT, &saved, NULL); + break; + } + fprintf(thread_stdout, "reused worker PID delivers ordinary-thread SIGINT: %s\n", passed ? "PASS" : "FAIL"); + alarm(0); + return passed ? 0 : 1; +} diff --git a/tests/spawn-registration.m b/tests/spawn-registration.m new file mode 100644 index 00000000..d231bf3f --- /dev/null +++ b/tests/spawn-registration.m @@ -0,0 +1,50 @@ +// Register in a disposable host launched with the external OS scheduling interposer. +#import +#import "ios_system.h" +#include +#include +#include +#include +#include +#include + +int main(int argc, char **argv) { + if (argc != 2 && argc != 3) return 64; + BOOL fail = argc == 3 && strcmp(argv[2], "fail") == 0; + alarm(15); + void (*completeBeforeStart)(int) = dlsym(RTLD_DEFAULT, "complete_pid_before_spawn_thread"); + if (!completeBeforeStart) return 2; + pid_t older = ios_fork(); + ios_storeThreadId(pthread_self()); + if (fail) { + void (*failBeforeStart)(int) = dlsym(RTLD_DEFAULT, "fail_spawn_thread_after_completion"); + if (!failBeforeStart) return 2; + failBeforeStart(older); + } else completeBeforeStart(older); + char *home; + if (asprintf(&home, "HOME=%s", argv[1]) < 0) return 1; + char *environment[] = { home, NULL }, *command[] = { "sleep", "0", NULL }; + pid_t child = -1; + int status = -1; + int error = ios_posix_spawn(&child, command[0], NULL, NULL, command, environment); + free(home); + BOOL completed = ios_getThreadId(older) == 0; + if (fail) { + int (*failedPid)(void) = dlsym(RTLD_DEFAULT, "failed_spawn_pid"); + BOOL released = failedPid && ios_getThreadId(failedPid()) == 0; + BOOL passed = completed && error == EAGAIN && child == -1 && released; + fprintf(thread_stdout, "spawn thread failure after older PID completion: %s (completed=%d error=%d child=%d released=%d)\n", passed ? "PASS" : "FAIL", completed, error, child, released); + alarm(0); + return passed ? 0 : 1; + } + BOOL registered = !error && ios_getThreadId(child) != (pthread_t)-1; + pid_t waited = 0; + if (!error) for (int n = 0; n < 300 && waited == 0; n++) { + waited = ios_wait4(child, &status, WNOHANG, NULL, NULL); + if (!waited) usleep(10000); + } + BOOL passed = completed && registered && waited == child && WIFEXITED(status) && WEXITSTATUS(status) == 0; + fprintf(thread_stdout, "spawn registration after older PID completion: %s (completed=%d registered=%d waited=%d child=%d status=%d)\n", passed ? "PASS" : "FAIL", completed, registered, waited, child, status); + alarm(0); + return passed ? 0 : 1; +} diff --git a/tests/spawn-rusage.m b/tests/spawn-rusage.m new file mode 100644 index 00000000..c7a52030 --- /dev/null +++ b/tests/spawn-rusage.m @@ -0,0 +1,36 @@ +// Run as a registered command in a disposable worker-enabled host, with its HOME as argv[1]. +#import +#import "ios_system.h" +#include "ios_spawn.h" +#include +#include +#include +#include + +int main(int argc, char **argv) { + if (argc != 2) return 64; + char *home; + if (asprintf(&home, "HOME=%s", argv[1]) < 0) return 1; + char *env[] = { home, NULL }; + char *cpu[] = { "awk", "BEGIN { for (i=0;i<5000000;i++) s+=sqrt(i); exit(s>0?0:1) }", NULL }; + pid_t pid; + int status, available; + struct rusage usage; + alarm(30); + int error = ios_posix_spawn(&pid, cpu[0], NULL, NULL, cpu, env); + if (error) { fprintf(stderr, "CPU spawn failed: %d\n", error); return 1; } + if (ios_wait4(pid, &status, 0, &usage, &available) != pid || + !WIFEXITED(status) || WEXITSTATUS(status) || !available || + (usage.ru_utime.tv_sec == 0 && usage.ru_utime.tv_usec == 0)) return 1; + fprintf(stderr, "CPU child user time: %ld.%06d\n", usage.ru_utime.tv_sec, usage.ru_utime.tv_usec); + char *sleep[] = { "sleep", "30", NULL }; + error = ios_posix_spawn(&pid, sleep[0], NULL, NULL, sleep, env); + if (error || ios_killpid(pid, SIGKILL)) return 1; + available = 1; + if (ios_wait4(pid, &status, 0, &usage, &available) != pid || available || + !WIFEXITED(status) || WEXITSTATUS(status) != 137) return 1; + free(home); + alarm(0); + fprintf(stderr, "spawn-rusage: CPU measured, killed child unavailable PASS\n"); + return 0; +} diff --git a/tests/spawn-thread-start-hook.c b/tests/spawn-thread-start-hook.c new file mode 100644 index 00000000..2d79bb36 --- /dev/null +++ b/tests/spawn-thread-start-hook.c @@ -0,0 +1,30 @@ +// External OS scheduling boundary: complete an older public PID before one child thread starts. +#include +#include +#include +#include +#include +extern void ios_releaseThreadId(int pid); +extern int ios_currentPid(void); +static _Atomic int completion_pid = -1; +static _Atomic int fail_creation; +static int failed_pid = -1; +void complete_pid_before_spawn_thread(int pid) { atomic_store(&completion_pid, pid); } +void fail_spawn_thread_after_completion(int pid) { atomic_store(&completion_pid, pid); atomic_store(&fail_creation, 1); } +int failed_spawn_pid(void) { return failed_pid; } +static int scheduled_create(pthread_t *thread, const pthread_attr_t *attributes, void *(*start)(void *), void *argument) { + Dl_info image; + if (dladdr((void *)start, &image) && strstr(image.dli_fname, "/ios_system.framework/ios_system")) { + int pid = atomic_exchange(&completion_pid, -1); + if (pid >= 0) { + failed_pid = ios_currentPid(); + ios_releaseThreadId(pid); + if (atomic_exchange(&fail_creation, 0)) return EAGAIN; + } + } + return pthread_create(thread, attributes, start, argument); +} +__attribute__((used, section("__DATA,__interpose"))) static struct { + const void *replacement; + const void *original; +} hook = {(const void *)scheduled_create, (const void *)pthread_create}; diff --git a/tests/worker-nested-environment.m b/tests/worker-nested-environment.m new file mode 100644 index 00000000..c15e1ed6 --- /dev/null +++ b/tests/worker-nested-environment.m @@ -0,0 +1,46 @@ +// Register as a command in a disposable worker-enabled host. +#import +#import "ios_system.h" +#include + +int nested_environment_payload_main(int argc, char **argv) { + const char *value = ios_getenv("NESTED_ENV_SENTINEL"); + bool passed = argc == 2 && getpid() != atoi(argv[1]) && value && !strcmp(value, "nested-command-value"); + fprintf(thread_stdout, "nested environment payload: %s\n", passed ? "PASS" : "FAIL"); + return passed ? 0 : 1; +} + +int main(void) { + alarm(30); + FILE *output = tmpfile(); + if (!output) return 1; + FILE *savedInput = thread_stdin, *savedOutput = thread_stdout, *savedError = thread_stderr; + bool savedJoin = joinMainThread; + ios_setenv("ASHELL_WORKER_COMMANDS", "nested-environment-payload", 1); + ios_setenv("NESTED_ENV_SENTINEL", "nested-command-value", 1); + ios_setStreams(savedInput, output, savedError); + thread_stdout = NULL; + joinMainThread = true; + char command[128]; + snprintf(command, sizeof(command), "nested-environment-payload %d", getpid()); + int status = ios_system(command); // Deliberately do not reserve a child PID. + fflush(output); + rewind(output); + char line[4096]; + bool seen = false; + while (fgets(line, sizeof(line), output)) { + if (!strcmp(line, "nested environment payload: PASS\n")) seen = true; + } + ios_setStreams(savedInput, savedOutput, savedError); + thread_stdin = savedInput; + thread_stdout = savedOutput; + thread_stderr = savedError; + joinMainThread = savedJoin; + fclose(output); + ios_unsetenv("ASHELL_WORKER_COMMANDS"); + ios_unsetenv("NESTED_ENV_SENTINEL"); + alarm(0); + bool passed = status == 0 && seen; + fprintf(savedOutput, "unforked nested worker environment: %s (status=%d payload=%d)\n", passed ? "PASS" : "FAIL", status, seen); + return passed ? 0 : 1; +} diff --git a/tests/worker-pipeline-signal.m b/tests/worker-pipeline-signal.m new file mode 100644 index 00000000..bb3c79c3 --- /dev/null +++ b/tests/worker-pipeline-signal.m @@ -0,0 +1,43 @@ +#import +#import "ios_system.h" +#include +#include +#include +#include +#include +#include +static dispatch_semaphore_t drained, finished; +static volatile sig_atomic_t interrupted; +static _Atomic int returned, isolated; +int pipeline_signal_producer(int argc, char **argv) { fprintf(thread_stdout, "%d\n", getpid()); return 0; } +static void received(int sig) { interrupted = 1; } +static void completed(void *unused) { dispatch_semaphore_signal(finished); } +int pipeline_signal_consumer(int argc, char **argv) { + struct sigaction action = {0}; action.sa_handler = received; sigemptyset(&action.sa_mask); sigaction(SIGINT, &action, NULL); + sigset_t signals; sigemptyset(&signals); sigaddset(&signals, SIGINT); pthread_sigmask(SIG_UNBLOCK, &signals, NULL); + pthread_cleanup_push(completed, NULL); + char buffer[32] = {0}; ssize_t used = 0, count; + while (used < sizeof(buffer) - 1 && (count = read(fileno(thread_stdin), buffer + used, sizeof(buffer) - 1 - used)) > 0) used += count; + atomic_store(&isolated, used > 0 && atoi(buffer) != getpid()); + dispatch_semaphore_signal(drained); + for (int n = 0; n < 500 && !interrupted; n++) usleep(10000); + atomic_store(&returned, 1); + pthread_cleanup_pop(1); + return interrupted ? 0 : 1; +} +int main(void) { + alarm(30); interrupted = 0; atomic_store(&returned, 0); atomic_store(&isolated, 0); + drained = dispatch_semaphore_create(0); finished = dispatch_semaphore_create(0); + void (*arm)(void) = dlsym(RTLD_DEFAULT, "arm_pipeline_thread_order"); + int (*ordered)(void) = dlsym(RTLD_DEFAULT, "pipeline_thread_ordered"); + if (!arm || !ordered) return 2; + ios_setenv("ASHELL_WORKER_COMMANDS", "pipeline-signal-producer", 1); + pid_t pid = ios_fork(); arm(); + int status = ios_system("pipeline-signal-producer | pipeline-signal-consumer"); + BOOL ready = dispatch_semaphore_wait(drained, dispatch_time(DISPATCH_TIME_NOW, 10 * NSEC_PER_SEC)) == 0; + int killed = ready ? ios_killpid(pid, SIGINT) : -1; + BOOL done = dispatch_semaphore_wait(finished, dispatch_time(DISPATCH_TIME_NOW, 10 * NSEC_PER_SEC)) == 0; + BOOL passed = ordered() && atomic_load(&isolated) && ready && done && killed == 0 && interrupted && atomic_load(&returned); + fprintf(thread_stdout, "shared pipeline PID SIGINT: %s (pid=%d status=%d ready=%d done=%d kill=%d signal=%d returned=%d isolated=%d ordered=%d)\n", passed ? "PASS" : "FAIL", pid, status, ready, done, killed, interrupted, atomic_load(&returned), atomic_load(&isolated), ordered()); + ios_unsetenv("ASHELL_WORKER_COMMANDS"); alarm(0); return passed ? 0 : 1; +} diff --git a/tests/worker-pipeline.sh b/tests/worker-pipeline.sh new file mode 100644 index 00000000..698682c4 --- /dev/null +++ b/tests/worker-pipeline.sh @@ -0,0 +1,21 @@ +#!/bin/sh +# Run through a-Shell's worker-enabled Dash. A worker that answers while it is still reading must not stall. +set -eu +work=${TMPDIR:-/tmp}/worker-pipeline-$$ +mkdir "$work" +trap 'rm -rf "$work"' EXIT HUP INT TERM +head -c 2000000 /dev/urandom >"$work/input" +setenv ASHELL_WORKER_COMMANDS "" +gzip -c <"$work/input" | wc -c >"$work/expected" +read expected <"$work/expected" +setenv ASHELL_WORKER_COMMANDS "gzip wc" +gzip -c <"$work/input" | wc -c >"$work/count" +read bytes <"$work/count" +printf 'Worker gzip, redirected input, bytes=%s expected=%s\n' "$bytes" "$expected" +[ "$bytes" -eq "$expected" ] +printf '%s\n' 'WORKER_REDIRECT_PIPE_PASS' +cat "$work/input" | gzip -c | wc -c >"$work/count" +read bytes <"$work/count" +printf 'Worker gzip behind a pipe, bytes=%s expected=%s\n' "$bytes" "$expected" +[ "$bytes" -eq "$expected" ] +printf '%s\n' 'WORKER_PIPE_INPUT_PASS' diff --git a/tests/worker-registry-selection.sh b/tests/worker-registry-selection.sh new file mode 100644 index 00000000..7f3aa782 --- /dev/null +++ b/tests/worker-registry-selection.sh @@ -0,0 +1,11 @@ +#!/bin/dash +# Run in a disposable worker-enabled host with a PATH-first git wrapper that prints +# DISPATCH_WRAPPER_SELECTED, and pass the absolute zero-byte native git marker. +set -eu +native_git=${1:?Pass the absolute native git marker} +test "$(git --version)" = DISPATCH_WRAPPER_SELECTED +case "$("$native_git" --version)" in + 'git version '*) ;; + *) echo 'Explicit native Git selection was lost during worker dispatch' >&2; exit 1 ;; +esac +echo WORKER_REGISTRY_SELECTION_PASS diff --git a/text_cmds/grep/file.c b/text_cmds/grep/file.c index cfd8979d..2365be0c 100644 --- a/text_cmds/grep/file.c +++ b/text_cmds/grep/file.c @@ -263,7 +263,9 @@ grep_open(const char *path) if (path == NULL) { /* Processing stdin implies --line-buffered. */ lbflag = true; - f->fd = fileno(thread_stdin); + /* All close paths own this descriptor, not the caller stream. */ + if ((f->fd = dup(fileno(thread_stdin))) == -1) + goto error1; } else if ((f->fd = open(path, O_RDONLY)) == -1) goto error1; diff --git a/text_cmds/wc/wc.c b/text_cmds/wc/wc.c index 03755322..78d62891 100644 --- a/text_cmds/wc/wc.c +++ b/text_cmds/wc/wc.c @@ -162,7 +162,11 @@ cnt(const char *file) linect = wordct = charct = 0; if (file == NULL) { file = "stdin"; - fd = fileno(thread_stdin); + /* The embedding caller owns thread_stdin. */ + if ((fd = dup(fileno(thread_stdin))) < 0) { + warn("%s: dup", file); + return (1); + } } else { if ((fd = open(file, O_RDONLY, 0)) < 0) { warn("%s: open", file);