From 71c4e4c437e6c6daf3e3980a608f606a768f716c Mon Sep 17 00:00:00 2001 From: demrom <47463752+demrom@users.noreply.github.com> Date: Sat, 17 Oct 2026 22:50:09 +0300 Subject: [PATCH 01/16] Add isolated extension workers for native shell panes --- README.md | 10 + ShellWorkers/Host.swift | 98 +++ ShellWorkers/NativePane.h | 45 + ShellWorkers/NativePane.m | 799 +++++++++++++++++ ShellWorkers/PaneWire.h | 15 + ShellWorkers/RemotePane.m | 175 ++++ ShellWorkers/Worker.m | 178 ++++ ShellWorkers/Worker.swift | 28 + ShellWorkers/Worker.xcconfig | 22 + ShellWorkers/resources.sh | 14 + a-Shell-mini-Info.plist | 2 + a-Shell.xcodeproj/project.pbxproj | 1334 +++++++++++++++++++++++++++++ tests/worker-pool.m | 226 +++++ tests/worker-pool.md | 19 + 14 files changed, 2965 insertions(+) create mode 100644 ShellWorkers/Host.swift create mode 100644 ShellWorkers/NativePane.h create mode 100644 ShellWorkers/NativePane.m create mode 100644 ShellWorkers/PaneWire.h create mode 100644 ShellWorkers/RemotePane.m create mode 100644 ShellWorkers/Worker.m create mode 100644 ShellWorkers/Worker.swift create mode 100644 ShellWorkers/Worker.xcconfig create mode 100644 ShellWorkers/resources.sh create mode 100644 tests/worker-pool.m create mode 100644 tests/worker-pool.md diff --git a/README.md b/README.md index 156cb785..ce15a868 100644 --- a/README.md +++ b/README.md @@ -89,3 +89,13 @@ TeX files are not installed by default. Type any TeX command and the system will ## VoiceOver If you enable VoiceOver in Settings, a-Shell will work with VoiceOver: reading commands as you type them, reading the result, letting you read the screen with your finger... + +## Independent shell workers + +On iOS 26 and later, the mini target includes ten dedicated Generic Non-UI extensions, `ShellWorkerA` through `ShellWorkerJ`. Each worker owns one native command and its ios_system state. The original Intents and IntentsUI targets remain separate. Consumers call `ashell_pane_spawn` from a background thread, read the returned full-duplex descriptor, poll completion and release the opaque handle. The API and stream ownership rules are in `ShellWorkers/NativePane.h`. + +The host retains each `AppExtensionProcess` and XPC connection until release or interruption. A full pool returns `ENOSPC`; earlier iOS versions return `ENOTSUP`. Cancel and release terminate the dedicated worker through XPC before freeing its reservation; releasing an ExtensionFoundation assertion alone permits process reuse. Commands that ignore EOF therefore cannot retain a released slot. A lost worker reports status 137 while the host and other workers remain available. Directory access uses security-scoped bookmarks and is checked with actual file operations in the worker. + +Workers share the host's command libraries. Build with ios_system changes #179–#184 and dash_iOS#1–#2, including the matched libedit dependency described there. The build derives each worker's catalogue and executable markers from `Resources_mini/commandDictionary.plist`; it generates the ExtensionFoundation binding from `PRODUCT_BUNDLE_PREFIX`. Keep worker versions in `ShellWorkers/Worker.xcconfig` aligned with the containing app. The mini bundle suffixes are `.ShellWorkerA` through `.ShellWorkerJ`; each requires its own App ID and profile for a device build. + +`tests/worker-pool.m` is a real app-command consumer for Simulator qualification. Device memory limits, background execution without other host background modes, and App Store acceptance require separate validation. Retaining the process handle provides no promise of unlimited background execution. diff --git a/ShellWorkers/Host.swift b/ShellWorkers/Host.swift new file mode 100644 index 00000000..9028e57e --- /dev/null +++ b/ShellWorkers/Host.swift @@ -0,0 +1,98 @@ +import ExtensionFoundation +import Foundation + +@available(iOS 26.0, *) +extension AppExtensionPoint { + @Definition + static var shellWorker: AppExtensionPoint { Name("ShellWorker") } +} + +// Process lifetime and discovery belong to MainActor. Blocking C ABI calls wait +// only on the calling runner thread, never on the UI actor. +@available(iOS 26.0, *) +@MainActor +final class ShellWorkerPool { + static let shared = ShellWorkerPool() + private enum Slot { + case launching(UUID) + case connected(UUID, AppExtensionProcess, NSXPCConnection) + + var generation: UUID { + switch self { + case .launching(let generation), .connected(let generation, _, _): generation + } + } + } + private var slots: [String: Slot] = [:] + + func connect() async throws -> NSXPCConnection { + guard Bundle.main.object(forInfoDictionaryKey: "ShellWorkerCount") != nil else { + throw NSError(domain: NSPOSIXErrorDomain, code: Int(ENOTSUP)) + } + let monitor = try await AppExtensionPoint.Monitor(appExtensionPoint: .shellWorker) + for _ in 0..<100 { + let configured = Bundle.main.object(forInfoDictionaryKey: "ShellWorkerCount") + let capacity = (configured as? NSNumber)?.intValue ?? Int(configured as? String ?? "") ?? 0 + if capacity > 0 && slots.count >= capacity { + throw NSError(domain: NSPOSIXErrorDomain, code: Int(ENOSPC), userInfo: [NSLocalizedDescriptionKey: "All shell workers are in use. Close a pane before opening another."]) + } + if let identity = monitor.identities.sorted(by: { $0.bundleIdentifier < $1.bundleIdentifier }).first(where: { slots[$0.bundleIdentifier] == nil }) { + let key = identity.bundleIdentifier + let generation = UUID() + slots[key] = .launching(generation) + do { + let process = try await AppExtensionProcess(configuration: .init(appExtensionIdentity: identity, onInterruption: { + Task { @MainActor in + guard self.slots[key]?.generation == generation else { return } + self.slots.removeValue(forKey: key) + } + })) + guard slots[key]?.generation == generation else { + process.invalidate() + throw CancellationError() + } + let connection = try process.makeXPCConnection() + slots[key] = .connected(generation, process, connection) + return connection + } catch { + if slots[key]?.generation == generation { slots.removeValue(forKey: key) } + throw error + } + } + try await Task.sleep(for: .milliseconds(100)) + } + throw NSError(domain: NSPOSIXErrorDomain, code: Int(ETIMEDOUT), userInfo: [NSLocalizedDescriptionKey: "shell worker discovery timed out"]) + } + + func disconnect(_ connection: NSXPCConnection) { + for (key, slot) in slots { + guard case .connected(_, let process, let stored) = slot, stored === connection else { continue } + // Explicit invalidation does not guarantee an interruption callback. + // Release the reservation in this MainActor transaction; generation + // checks keep a late callback from removing a replacement process. + connection.invalidate() + process.invalidate() + slots.removeValue(forKey: key) + return + } + } +} + +@_cdecl("ashell_extension_disconnect") +func shellExtensionDisconnect(_ connection: NSXPCConnection) { + if #available(iOS 26.0, *) { + Task { @MainActor in ShellWorkerPool.shared.disconnect(connection) } + } +} + +@_cdecl("ashell_extension_connect") +func shellExtensionConnect(_ callback: @escaping @convention(block) (NSXPCConnection?, NSError?) -> Void) { + guard #available(iOS 26.0, *) else { + callback(nil, NSError(domain: NSPOSIXErrorDomain, code: Int(ENOTSUP))) + return + } + Task { @MainActor in + do { callback(try await ShellWorkerPool.shared.connect(), nil) } + catch { callback(nil, error as NSError) } + } +} diff --git a/ShellWorkers/NativePane.h b/ShellWorkers/NativePane.h new file mode 100644 index 00000000..0249cf84 --- /dev/null +++ b/ShellWorkers/NativePane.h @@ -0,0 +1,45 @@ +#ifndef ASHELL_NATIVE_PANE_H +#define ASHELL_NATIVE_PANE_H + +#ifdef __cplusplus +extern "C" { +#endif + +// command/cwd must be nonempty UTF-8; cwd must be an absolute accessible directory. +// envp is NULL or a NULL-terminated list of UTF-8 key=value overrides, copied before return. +// Dimensions are 1..9999. Returns 0 or positive errno; failure sets outputs to -1/NULL. +// Success transfers the full-duplex fd to the caller; stderr shares stdout. +// Host handles use independent extension processes and socket streams. Each worker +// accepts one native pane. Stock ios_system globals stay private to that process. +// The caller may set O_NONBLOCK on its returned descriptor. +int ashell_pane_spawn(const char *command, const char *cwd, unsigned cols, + unsigned rows, const char *const *envp, int *fd_out, void **job_out); + +// Returns 0 running, 1 exited (writes final command status), or -errno. +// Repeated polling after exit preserves status. Drain the returned fd to EOF independently. +// A cleanup/restore error returns -errno after completion instead of hiding the failure. +int ashell_pane_poll(void *job, int *status_out); + +// These operations return 0 or POSITIVE errno, unlike poll. +// Resize updates the worker command dimensions through ios_setWindowSize. +int ashell_pane_resize(void *job, unsigned cols, unsigned rows); +// Half-close socket stdin while keeping output open. Repeated calls are harmless. +int ashell_pane_close_stdin(void *job); +// Host cancellation terminates the entire worker process. The worker-local +// adapter returns ENOTSUP while running; interrupt targets only its foreground command. +int ashell_pane_cancel(void *job); +// Interrupt a cooked foreground command; ENOTSUP means forward ETX to the TUI/editor. +int ashell_pane_interrupt(void *job); + +// Release the host connection and worker process, then drop the caller reference. +// The worker-local adapter retains live runners and their streams through cleanup. +// Caller must invalidate its pointer and release exactly once. No concurrent release +// with another call: serialize calls on each handle. +// Close the returned fd separately. +// The handle is opaque and is NOT an OS pid: never pass it to kill/waitpid/process APIs. +void ashell_pane_release(void *job); + +#ifdef __cplusplus +} +#endif +#endif diff --git a/ShellWorkers/NativePane.m b/ShellWorkers/NativePane.m new file mode 100644 index 00000000..29db5b84 --- /dev/null +++ b/ShellWorkers/NativePane.m @@ -0,0 +1,799 @@ +#import +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#import "ios_system/ios_system.h" +#include "NativePane.h" + +// Each extension process owns one active native pane. +static _Atomic int pane_in_flight; +// Stock explicit-PID setter avoids the libc setenv fallback for the parent PID. +extern int ios_setenv_pid(const char *, const char *, int, int); + +typedef struct { + pthread_mutex_t mutex; + pthread_cond_t ready; + unsigned refs; + int initialized, startup_error, done, status, completion_error; + int peer_control, input_closed, is_pty; + unsigned char eof_key; + FILE *streams[3]; + void *registration; + char key[64]; + char *command, *cwd, *previous_cwd; + char **env; + unsigned cols, rows; + struct pane_input_relay *input_relay; +} pane_job; + +// One pane owns this process. Dash's optional evaluation hook borrows a fresh +// stdin pipe, while the relay keeps the editor pipe and socket alive. +static _Atomic(pane_job *) foreground_pane; +// Dash publishes the synthetic child PID before dispatch and restores its +// parent's PID after waiting. lastThreadId instead names the first descendant, +// which can be a shell blocked in ios_waitpid rather than the running command. +static _Atomic int foreground_command_pid; + +__attribute__((used)) int ashell_pane_exchange_foreground_pid(int pid) { + return atomic_exchange(&foreground_command_pid, pid); +} + +typedef struct { + unsigned char bytes[262144]; + size_t length; +} input_queue; + +typedef struct command_input { + FILE *reader; + pane_job *job; + int writer; + void *registration; + struct pane_input_relay *relay; +} command_input; + +typedef struct pane_input_relay { + pthread_mutex_t mutex; + pthread_cond_t changed; + pthread_t thread; + int socket, editor_reader, editor_writer, wake[2], editor_events; + int running, stop, error, input_eof, command_eof; + int request, request_done, request_error; + command_input *command, *requested_command; + input_queue editor_queue, command_queue; + int (*interactive)(void); +} pane_input_relay; + +enum { INPUT_BEGIN = 1, INPUT_END, INPUT_RETIRE }; + +static int append_input(input_queue *queue, const void *bytes, size_t length) { + if (length > sizeof(queue->bytes) - queue->length) return EOVERFLOW; + memcpy(queue->bytes + queue->length, bytes, length); + queue->length += length; + return 0; +} + +static int drain_input(int fd, input_queue *queue) { + unsigned char bytes[4096]; + struct pollfd available = {.fd = fd, .events = POLLIN}; + for (;;) { + int count = poll(&available, 1, 0); + if (count < 0) { if (errno == EINTR) continue; return errno; } + if (!count) return 0; + ssize_t length = read(fd, bytes, sizeof(bytes)); + if (length < 0) { if (errno == EINTR) continue; return errno; } + if (!length) return 0; + int error = append_input(queue, bytes, (size_t)length); + if (error) return error; + } +} + +static void close_input_writer(int *writer) { + if (*writer >= 0) close(*writer); + *writer = -1; +} + +static int flush_input(int fd, input_queue *queue) { + ssize_t count = write(fd, queue->bytes, queue->length); + if (count < 0) return errno == EINTR || errno == EAGAIN ? 0 : errno; + if (count) { + queue->length -= (size_t)count; + memmove(queue->bytes, queue->bytes + count, queue->length); + } + return 0; +} + +static int route_input(pane_input_relay *relay, const unsigned char *bytes, size_t length) { + for (size_t i = 0; i < length; i++) { + unsigned char byte = bytes[i]; + int foreground = relay->command && !relay->command_eof; + int raw = foreground && relay->interactive && relay->interactive(); + if (foreground && !raw && byte == 4) { + relay->command_eof = 1; + continue; + } + if (foreground && !raw && byte == '\r') byte = '\n'; + int error = append_input(foreground ? &relay->command_queue : + &relay->editor_queue, &byte, 1); + if (error) return error; + } + return 0; +} + +static int change_input(pane_input_relay *relay) { + command_input *command = relay->requested_command; + if (relay->request == INPUT_BEGIN) { + if (relay->command) return EBUSY; + // Bytes already forwarded to libedit belong to the next foreground + // reader once its command line has been consumed. Preserve their order. + input_queue *typeahead = calloc(1, sizeof(*typeahead)); + if (!typeahead) return ENOMEM; + int error = drain_input(relay->editor_reader, typeahead); + if (!error) error = append_input(typeahead, + relay->editor_queue.bytes, relay->editor_queue.length); + if (!error) { + relay->editor_queue.length = 0; + relay->command = command; + relay->command_eof = 0; + error = route_input(relay, typeahead->bytes, typeahead->length); + } + free(typeahead); + if (error) return error; + } else if (relay->request == INPUT_END) { + if (relay->command != command) return EINVAL; + close_input_writer(&command->writer); + relay->command_eof = 1; + } else if (relay->request == INPUT_RETIRE) { + if (relay->command != command) return EINVAL; + input_queue *remaining = calloc(1, sizeof(*remaining)); + if (!remaining) return ENOMEM; + // No borrower remains at this point. Unconsumed typeahead goes back to + // the editor before bytes typed after Ctrl-D; it must not be discarded. + int error = drain_input(fileno(command->reader), remaining); + if (!error) error = append_input(remaining, + relay->command_queue.bytes, relay->command_queue.length); + if (!error) error = append_input(remaining, + relay->editor_queue.bytes, relay->editor_queue.length); + if (!error) relay->editor_queue = *remaining; + free(remaining); + if (error) return error; + relay->command_queue.length = 0; + relay->command = NULL; + } + return 0; +} + +static void *relay_input(void *cookie) { + pane_input_relay *relay = cookie; + pthread_mutex_lock(&relay->mutex); + while (!relay->stop && !relay->error) { + if (relay->request) { + relay->request_error = change_input(relay); + relay->request = 0; + relay->request_done = 1; + pthread_cond_broadcast(&relay->changed); + if (relay->request_error) { relay->error = relay->request_error; break; } + } + if (relay->command && (relay->command_eof || relay->input_eof) && + !relay->command_queue.length) + close_input_writer(&relay->command->writer); + if (relay->input_eof && !relay->editor_queue.length) + close_input_writer(&relay->editor_writer); + // Backpressure bounds queued input. Each pipe also has its kernel + // buffer; transition queues have room to drain that existing typeahead. + int can_read = !relay->input_eof && relay->editor_queue.length < 61440 && + relay->command_queue.length < 61440; + struct pollfd fds[4] = { + {.fd = can_read ? relay->socket : -1, .events = POLLIN}, + {.fd = relay->wake[0], .events = POLLIN}, + {.fd = !relay->command && relay->editor_queue.length ? relay->editor_writer : -1, .events = POLLOUT}, + {.fd = relay->command && relay->command_queue.length ? relay->command->writer : -1, + .events = POLLOUT} + }; + pthread_mutex_unlock(&relay->mutex); + int result = poll(fds, 4, -1); + int poll_error = errno; + pthread_mutex_lock(&relay->mutex); + if (result < 0) { + if (poll_error == EINTR) continue; + relay->error = poll_error; + break; + } + if (fds[1].revents) { + unsigned char wake; + (void)read(relay->wake[0], &wake, 1); + continue; // Apply the requested boundary before consuming more input. + } + if (fds[2].revents && relay->editor_queue.length) + relay->error = flush_input(relay->editor_writer, &relay->editor_queue); + if (!relay->error && fds[3].revents && relay->command_queue.length) + relay->error = flush_input(relay->command->writer, &relay->command_queue); + if (!relay->error && fds[0].revents) { + unsigned char bytes[4096]; + ssize_t length = read(relay->socket, bytes, sizeof(bytes)); + if (length < 0) { + if (errno != EINTR && errno != EAGAIN) relay->error = errno; + } else if (!length) { + relay->input_eof = 1; + } else { + relay->error = route_input(relay, bytes, (size_t)length); + } + } + } + close_input_writer(&relay->editor_writer); + if (relay->command) close_input_writer(&relay->command->writer); + relay->running = 0; + pthread_cond_broadcast(&relay->changed); + pthread_mutex_unlock(&relay->mutex); + return NULL; +} + +static int request_input(pane_input_relay *relay, int request, command_input *command) { + pthread_mutex_lock(&relay->mutex); + int error = relay->running ? 0 : relay->error ? relay->error : EPIPE; + if (!error) { + relay->request = request; + relay->requested_command = command; + relay->request_done = 0; + unsigned char wake = 1; + ssize_t count; + do { count = write(relay->wake[1], &wake, 1); } while (count < 0 && errno == EINTR); + if (count != 1) error = errno ? errno : EIO; + while (!error && !relay->request_done && relay->running) + pthread_cond_wait(&relay->changed, &relay->mutex); + if (!error) error = relay->request_done ? relay->request_error : relay->error ? relay->error : EPIPE; + } + pthread_mutex_unlock(&relay->mutex); + return error; +} + +static int stop_input_relay(pane_job *job) { + pane_input_relay *relay = job->input_relay; + if (!relay) return 0; + pthread_mutex_lock(&relay->mutex); + relay->stop = 1; + unsigned char wake = 1; + (void)write(relay->wake[1], &wake, 1); + pthread_mutex_unlock(&relay->mutex); + pthread_join(relay->thread, NULL); + int error = relay->error; + // pthread_cancel can bypass Dash's shell-exception handler. The outer + // runner has already waited for the root tree; retire any abandoned lease + // only after every registered stdin borrower has completed cleanup. + if (relay->command) { + command_input *command = relay->command; + ashell_wait_owned_streams(command->registration); + ios_setStreams(job->streams[0], job->streams[1], job->streams[2]); + ios_settty(job->streams[0]); + ashell_unregister_owned_streams(&command->registration); + if (fclose(command->reader) && !error) error = errno; + free(command); + relay->command = NULL; + } + close(relay->socket); + close(relay->editor_reader); + close(relay->wake[0]); + close(relay->wake[1]); + close(relay->editor_events); + pthread_cond_destroy(&relay->changed); + pthread_mutex_destroy(&relay->mutex); + free(relay); + job->input_relay = NULL; + return error; +} + +static int prepare_input_relay(pane_job *job) { + int input[2], wake[2]; + if (pipe(input)) return errno; + if (pipe(wake)) { int error = errno; close(input[0]); close(input[1]); return error; } + FILE *reader = fdopen(input[0], "r"); + if (!reader) { int error = errno; close(input[0]); close(input[1]); close(wake[0]); close(wake[1]); return error; } + pane_input_relay *relay = calloc(1, sizeof(*relay)); + if (!relay) { fclose(reader); close(input[1]); close(wake[0]); close(wake[1]); return ENOMEM; } + relay->socket = dup(fileno(job->streams[0])); + relay->editor_reader = dup(input[0]); + relay->editor_writer = input[1]; + relay->wake[0] = wake[0]; relay->wake[1] = wake[1]; + relay->editor_events = kqueue(); + relay->interactive = dlsym(RTLD_DEFAULT, "storeInteractive"); + int error = relay->socket < 0 || relay->editor_reader < 0 || relay->editor_events < 0 ? errno : 0; + if (!error && setvbuf(reader, NULL, _IONBF, 0)) error = EIO; + int descriptors[] = {relay->socket, input[0], input[1], relay->editor_reader, wake[0], wake[1], relay->editor_events}; + for (unsigned i = 0; !error && i < sizeof(descriptors) / sizeof(descriptors[0]); i++) + if (fcntl(descriptors[i], F_SETFD, FD_CLOEXEC)) error = errno; + if (!error && (fcntl(input[1], F_SETFL, O_NONBLOCK) || + fcntl(input[1], F_SETNOSIGPIPE, 1))) error = errno; + if (!error) { + struct kevent events[2]; + EV_SET(&events[0], input[0], EVFILT_READ, EV_ADD, 0, 0, NULL); + EV_SET(&events[1], 1, EVFILT_USER, EV_ADD | EV_CLEAR, 0, 0, NULL); + if (kevent(relay->editor_events, events, 2, NULL, 0, NULL)) error = errno; + } + int mutex_ready = 0, condition_ready = 0; + if (!error) { error = pthread_mutex_init(&relay->mutex, NULL); mutex_ready = !error; } + if (!error) { error = pthread_cond_init(&relay->changed, NULL); condition_ready = !error; } + if (!error) { + relay->running = 1; + error = pthread_create(&relay->thread, NULL, relay_input, relay); + } + if (error) { + if (condition_ready) pthread_cond_destroy(&relay->changed); + if (mutex_ready) pthread_mutex_destroy(&relay->mutex); + if (relay->socket >= 0) close(relay->socket); + if (relay->editor_reader >= 0) close(relay->editor_reader); + if (relay->editor_events >= 0) close(relay->editor_events); + fclose(reader); close(input[1]); close(wake[0]); close(wake[1]); free(relay); + return error; + } + fclose(job->streams[0]); + job->streams[0] = reader; + job->input_relay = relay; + return 0; +} + +// Wait on the editor's real input and resize event without changing process +// signal handlers or inserting synthetic keys into the command stream. +// Return 0 for an unrelated reader, 1 for input, 2 for resize, or -errno. +__attribute__((used)) int ashell_pane_wait_editor_input(FILE *input) { + pane_job *job = atomic_load(&foreground_pane); + if (!job || !job->input_relay || input != job->streams[0]) return 0; + struct kevent event; + int count; + do { + count = kevent(job->input_relay->editor_events, NULL, 0, &event, 1, NULL); + } while (count < 0 && errno == EINTR); + if (count < 0) return -errno; + if (event.flags & EV_ERROR) return -(int)event.data; + return event.filter == EVFILT_USER ? 2 : 1; +} + +__attribute__((used)) int ashell_pane_begin_foreground_input(FILE **input, void **token) { + if (!input || !token || *token) return EINVAL; + pane_job *job = atomic_load(&foreground_pane); + if (!job || !job->input_relay || *input != job->streams[0]) return 0; + // Neither a condition wait nor cancellation during allocation may strand + // the relay mutex or an unregistered pipe. The relay owns the lease before + // cancellation is restored, so outer cleanup can recover it. + int cancellation; + int error = pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, &cancellation); + if (error) return error; + int descriptors[2]; + if (pipe(descriptors)) { + error = errno; + pthread_setcancelstate(cancellation, NULL); + return error; + } + command_input *command = calloc(1, sizeof(*command)); + if (!command) { + close(descriptors[0]); close(descriptors[1]); + pthread_setcancelstate(cancellation, NULL); + return ENOMEM; + } + command->writer = descriptors[1]; + command->reader = fdopen(descriptors[0], "r"); + command->job = job; + command->relay = job->input_relay; + error = command->reader ? 0 : errno; + if (!error && (fcntl(descriptors[0], F_SETFD, FD_CLOEXEC) || + fcntl(descriptors[1], F_SETFD, FD_CLOEXEC) || + fcntl(descriptors[1], F_SETFL, O_NONBLOCK) || + fcntl(descriptors[1], F_SETNOSIGPIPE, 1))) error = errno; + if (!error && setvbuf(command->reader, NULL, _IONBF, 0)) error = EIO; + if (!error) error = ashell_register_command_input(command->reader, &command->registration); + if (!error) error = request_input(command->relay, INPUT_BEGIN, command); + if (error) { + pthread_mutex_lock(&command->relay->mutex); + if (!command->relay->running && command->relay->command == command) + command->relay->command = NULL; + pthread_mutex_unlock(&command->relay->mutex); + ashell_unregister_owned_streams(&command->registration); + if (command->reader) fclose(command->reader); else close(descriptors[0]); + close(command->writer); free(command); + pthread_setcancelstate(cancellation, NULL); + return error; + } + *input = command->reader; + *token = command; + // Stock tty detection compares command streams against the session streams. + // Keep baseline outputs so explicit redirects remain non-terminal streams. + ios_setStreams(command->reader, job->streams[1], job->streams[2]); + // Stock ios_opentty borrowers must receive this command's input as well. + ios_settty(command->reader); + pthread_setcancelstate(cancellation, NULL); + return 0; +} + +__attribute__((used)) int ashell_pane_end_foreground_input(void *token) { + command_input *command = token; + if (!command) return EINVAL; + int cancellation; + int error = pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, &cancellation); + if (error) return error; + error = request_input(command->relay, INPUT_END, command); + // Closing this command's writer unblocks cooked readers without touching + // Dash. Borrow counts include asynchronous children through their cleanup. + ashell_wait_owned_streams(command->registration); + pane_job *job = command->job; + ios_setStreams(job->streams[0], job->streams[1], job->streams[2]); + ios_settty(job->streams[0]); + int retired = request_input(command->relay, INPUT_RETIRE, command); + if (!error) error = retired; + pthread_mutex_lock(&command->relay->mutex); + if (!command->relay->running && command->relay->command == command) + command->relay->command = NULL; + pthread_mutex_unlock(&command->relay->mutex); + ashell_unregister_owned_streams(&command->registration); + if (fclose(command->reader) && !error) error = errno; + free(command); + pthread_setcancelstate(cancellation, NULL); + return error; +} + +static void drop_reference(pane_job *job) { + pthread_mutex_lock(&job->mutex); + int destroy = --job->refs == 0; + pthread_mutex_unlock(&job->mutex); + if (!destroy) return; + if (job->peer_control >= 0) close(job->peer_control); + if (job->env) { + for (size_t i = 0; job->env[i]; i++) free(job->env[i]); + free(job->env); + } + free(job->command); + free(job->cwd); + free(job->previous_cwd); + pthread_cond_destroy(&job->ready); + pthread_mutex_destroy(&job->mutex); + free(job); +} + +static int close_streams(pane_job *job) { + int error = stop_input_relay(job); + ashell_unregister_owned_streams(&job->registration); + for (int i = 0; i < 3; i++) { + if (job->streams[i] && fclose(job->streams[i]) && !error) error = errno; + job->streams[i] = NULL; + } + return error; +} + +static int socket_options(int fd) { + int one = 1; + if (setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &one, sizeof(one))) return errno; + if (fcntl(fd, F_SETFD, FD_CLOEXEC)) return errno; + return 0; +} + +static void *run_pane(void *cookie) { + pane_job *job = cookie; + @autoreleasepool { + NSLog(@"Shell pane lifecycle: start key=%s cols=%u rows=%u", job->key, job->cols, job->rows); + // This runner is new: no parent session or TLS streams are switched or overwritten. + ios_switchSession(job->key); + ios_setContext(job->key); + ios_setStreams(job->streams[0], job->streams[1], job->streams[2]); + ios_settty(job->streams[0]); + thread_stdin = job->streams[0]; + thread_stdout = job->streams[1]; + thread_stderr = job->streams[2]; + thread_context = job->key; + ios_setDirectoryURL([NSURL fileURLWithPath:@(job->cwd) isDirectory:YES]); + // The stock setter has no error return; confirm its actual process directory. + char observed[PATH_MAX]; + int error = 0; + struct stat wanted, actual; + if (!getcwd(observed, sizeof(observed))) error = errno; + else if (stat(job->cwd, &wanted) || stat(observed, &actual)) error = errno; + else if (wanted.st_dev != actual.st_dev || wanted.st_ino != actual.st_ino) error = EACCES; + if (!error) { + ios_setWindowSize((int)job->cols, (int)job->rows, job->key); + pid_t pid = ios_fork(); + // ios_fork allocates a private environment and holds stock pid_mtx until + // command registration. Do not abort between allocation and ios_system. + for (size_t i = 0; job->env && job->env[i]; i++) { + char *equals = strchr(job->env[i], '='); + *equals = 0; + if (ios_setenv_pid(job->env[i], equals + 1, 1, pid)) error = EINVAL; + *equals = '='; + } + pthread_mutex_lock(&job->mutex); + job->initialized = 1; + pthread_cond_signal(&job->ready); + pthread_mutex_unlock(&job->mutex); + NSLog(@"Shell pane lifecycle: ready key=%s pid=%d pty=%d", job->key, pid, job->is_pty); + atomic_store(&foreground_pane, job); + ios_system(job->command); + // ios_system may return before detached children finish when the app sets + // joinMainThread=false. Stock wait understands allocated-but-not-started IDs. + ios_waitpid(pid); + ashell_wait_owned_streams(job->registration); + atomic_store(&foreground_pane, NULL); + job->status = ios_getCommandStatus(); + ios_releaseThreadId(pid); + } + pthread_mutex_lock(&job->mutex); + int stream_error = close_streams(job); + if (!error) error = stream_error; + // Restore the extension process cwd before publishing completion. + ios_setDirectoryURL([NSURL fileURLWithPath:@(job->previous_cwd) isDirectory:YES]); + struct stat before, restored; + if (!getcwd(observed, sizeof(observed))) { + if (!error) error = errno; + } else if (stat(job->previous_cwd, &before) || stat(observed, &restored)) { + if (!error) error = errno; + } else if (before.st_dev != restored.st_dev || before.st_ino != restored.st_ino) { + if (!error) error = EIO; + } + // Stock ios_closeSession removes its dictionary entry, but does not free stock + // session allocations. Do not invent a private layout/free across the framework ABI. + ios_closeSession(job->key); + job->startup_error = job->initialized ? 0 : error; + job->completion_error = error; + job->initialized = 1; + job->done = 1; + NSLog(@"Shell pane lifecycle: exit key=%s status=%d error=%d", job->key, job->status, error); + // Close the retained peer before announcing exit so output EOF is observable. + if (job->peer_control >= 0) close(job->peer_control); + job->peer_control = -1; + atomic_store(&pane_in_flight, 0); + pthread_cond_signal(&job->ready); + pthread_mutex_unlock(&job->mutex); + } + drop_reference(job); + return NULL; +} + +int ashell_pane_spawn(const char *command, const char *cwd, unsigned cols, + unsigned rows, const char *const *envp, int *fd_out, void **job_out) { + if (fd_out) *fd_out = -1; + if (job_out) *job_out = NULL; + if (!fd_out || !job_out || !command || !*command || !cwd || cwd[0] != '/' || + !cols || cols > 9999 || !rows || rows > 9999) return EINVAL; + @autoreleasepool { + if (![@(command) length] || ![@(cwd) length]) return EILSEQ; + if (strlen(cwd) >= PATH_MAX) return ENAMETOOLONG; + struct stat directory; + if (stat(cwd, &directory)) return errno; + if (!S_ISDIR(directory.st_mode)) return ENOTDIR; + if (access(cwd, R_OK | X_OK)) return errno; + size_t env_count = 0; + if (envp) for (; envp[env_count]; env_count++) { + const char *equals = strchr(envp[env_count], '='); + if (!equals || equals == envp[env_count]) return EINVAL; + if (![@(envp[env_count]) length]) return EILSEQ; + } + int expected = 0; + if (!atomic_compare_exchange_strong(&pane_in_flight, &expected, 1)) return EBUSY; + int error = ENOMEM, peer = -1; + pane_job *job = calloc(1, sizeof(*job)); + if (!job) { atomic_store(&pane_in_flight, 0); return ENOMEM; } + job->peer_control = -1; + error = pthread_mutex_init(&job->mutex, NULL); + if (error) { free(job); atomic_store(&pane_in_flight, 0); return error; } + error = pthread_cond_init(&job->ready, NULL); + if (error) { + pthread_mutex_destroy(&job->mutex); + free(job); + atomic_store(&pane_in_flight, 0); + return error; + } + job->refs = 1; + job->cols = cols; + job->rows = rows; + snprintf(job->key, sizeof(job->key), "shell-pane-%s", NSUUID.UUID.UUIDString.UTF8String); + job->command = strdup(command); + job->cwd = strdup(cwd); + job->previous_cwd = getcwd(NULL, 0); + job->env = calloc(env_count + 1, sizeof(char *)); + error = ENOMEM; + if (!job->command || !job->cwd || !job->env) goto failed; + if (!job->previous_cwd) { error = errno; goto failed; } + for (size_t i = 0; i < env_count; i++) { + job->env[i] = strdup(envp[i]); + if (!job->env[i]) goto failed; + } + int fds[2]; + int wants_pty = 0; + for (size_t i = 0; i < env_count; i++) + if (!strcmp(envp[i], "ASHELL_IOS_PTY=1")) wants_pty = 1; + if (wants_pty) { + struct winsize size = {.ws_col = (unsigned short)cols, .ws_row = (unsigned short)rows}; + if (openpty(&fds[0], &fds[1], NULL, NULL, &size)) { + error = errno; + NSLog(@"Shell pane: openpty failed errno=%d (%s)", error, strerror(error)); + if (error != EPERM) goto failed; + // Sandboxed devices may deny PTYs. Keep the existing socket transport + // explicit and let Dash print its non-TTY prompt; never claim PTY semantics. + wants_pty = 0; + for (size_t i = 0; i < env_count; i++) + if (!strcmp(job->env[i], "ASHELL_IOS_PTY=1")) + memcpy(job->env[i], "ASHELL_IOS_PTY=0", sizeof("ASHELL_IOS_PTY=0")); + NSLog(@"Shell pane: using socket transport; kernel PTY signals and ioctls unavailable"); + } else job->is_pty = 1; + } + if (!wants_pty && socketpair(AF_UNIX, SOCK_STREAM, 0, fds)) { error = errno; goto failed; } + peer = fds[0]; + int child = fds[1]; + if (job->is_pty) { + struct termios settings; + if (tcgetattr(child, &settings)) { error = errno; close(child); goto failed; } + if (settings.c_cc[VEOF] == _POSIX_VDISABLE) { error = ENOTSUP; close(child); goto failed; } + job->eof_key = settings.c_cc[VEOF]; + // Commands share an application process: never enable kernel process-group signals. + settings.c_lflag &= ~ISIG; + settings.c_iflag |= ICRNL; + settings.c_oflag |= OPOST | ONLCR; + error = tcsetattr(child, TCSANOW, &settings) ? errno : 0; + } else { + error = socket_options(peer); + if (!error) error = socket_options(child); + } + if (error) { close(child); goto failed; } + job->peer_control = dup(peer); + if (job->peer_control < 0) { error = errno; close(child); goto failed; } + error = job->is_pty ? (fcntl(job->peer_control, F_SETFD, FD_CLOEXEC) ? errno : 0) : socket_options(job->peer_control); + if (!error && job->is_pty && fcntl(peer, F_SETFD, FD_CLOEXEC)) error = errno; + if (error) { close(child); goto failed; } + for (int i = 0; i < 3; i++) { + int fd = i == 0 ? child : dup(child); + if (fd < 0) { error = errno; goto failed; } + job->streams[i] = fdopen(fd, i == 0 ? "r" : "w"); + if (!job->streams[i]) { error = errno; close(fd); goto failed; } + if (fcntl(fd, F_SETFD, FD_CLOEXEC)) { error = errno; goto failed; } + /* Match terminal stdout: libedit flushes each completed redraw. */ + int buffering = !job->is_pty && i == 1 ? _IOLBF : _IONBF; + if (setvbuf(job->streams[i], NULL, buffering, 0)) { error = EIO; goto failed; } + } + if (!job->is_pty) error = prepare_input_relay(job); + if (!error) error = ashell_register_owned_streams(job->streams[0], job->streams[1], job->streams[2], &job->registration); + if (error) goto failed; + pthread_attr_t attributes; + error = pthread_attr_init(&attributes); + if (error) goto failed; + error = pthread_attr_setdetachstate(&attributes, PTHREAD_CREATE_DETACHED); + pthread_t thread; + job->refs = 2; + if (!error) error = pthread_create(&thread, &attributes, run_pane, job); + pthread_attr_destroy(&attributes); + if (error) { job->refs = 1; goto failed; } + pthread_mutex_lock(&job->mutex); + while (!job->initialized) pthread_cond_wait(&job->ready, &job->mutex); + error = job->startup_error; + pthread_mutex_unlock(&job->mutex); + if (error) { close(peer); drop_reference(job); return error; } + *fd_out = peer; + *job_out = job; + return 0; +failed: + close_streams(job); + if (peer >= 0) close(peer); + drop_reference(job); + atomic_store(&pane_in_flight, 0); + return error; + } +} + +int ashell_pane_poll(void *handle, int *status_out) { + if (!handle || !status_out) return -EINVAL; + pane_job *job = handle; + pthread_mutex_lock(&job->mutex); + int result = job->completion_error ? -job->completion_error : job->done; + if (job->done) *status_out = job->status; + pthread_mutex_unlock(&job->mutex); + return result; +} + +int ashell_pane_resize(void *handle, unsigned cols, unsigned rows) { + if (!handle || !cols || cols > 9999 || !rows || rows > 9999) return EINVAL; + pane_job *job = handle; + pthread_mutex_lock(&job->mutex); + int error = job->done ? ESRCH : 0; + NSLog(@"Shell pane lifecycle: resize key=%s cols=%u rows=%u error=%d", job->key, cols, rows, error); + if (!error && job->is_pty) { + struct winsize size = {.ws_col = (unsigned short)cols, .ws_row = (unsigned short)rows}; + if (ioctl(job->peer_control, TIOCSWINSZ, &size)) error = errno; + } + if (!error) { + job->cols = cols; + job->rows = rows; + ios_setWindowSize((int)cols, (int)rows, job->key); + if (job->input_relay) { + struct kevent event; + EV_SET(&event, 1, EVFILT_USER, 0, NOTE_TRIGGER, 0, NULL); + if (kevent(job->input_relay->editor_events, &event, 1, NULL, 0, NULL)) error = errno; + } + } + pthread_mutex_unlock(&job->mutex); + return error; +} + +int ashell_pane_close_stdin(void *handle) { + if (!handle) return EINVAL; + pane_job *job = handle; + pthread_mutex_lock(&job->mutex); + int error = 0; + if (!job->done && !job->input_closed) { + if (job->is_pty) { + // PTYs have no half-close. Request EOF through the terminal's configured key; + // canonical readers and libedit handle it, arbitrary raw readers may not. + // libedit temporarily disables VEOF in raw mode; retain the initial key + // that its line editor binds, rather than injecting _POSIX_VDISABLE. + unsigned char eof[2] = {job->eof_key, job->eof_key}; + size_t sent = 0; + while (sent < sizeof(eof)) { + ssize_t n = write(job->peer_control, eof + sent, sizeof(eof) - sent); + if (n < 0 && errno == EINTR) continue; + if (n <= 0) { error = n < 0 ? errno : EIO; break; } + sent += (size_t)n; + } + } else if (shutdown(job->peer_control, SHUT_WR)) error = errno; + if (!error) job->input_closed = 1; + } + pthread_mutex_unlock(&job->mutex); + return error; +} + +int ashell_pane_interrupt(void *handle) { + if (!handle) return EINVAL; + pane_job *job = handle; + pthread_mutex_lock(&job->mutex); + int error = job->done ? ESRCH : 0; + pthread_t foreground = 0; + struct termios settings; + if (!error && job->is_pty) { + if (tcgetattr(job->peer_control, &settings)) error = errno; + // Raw readers (libedit, Codex, Helix) own their Ctrl-C binding. + else if (!(settings.c_lflag & ICANON)) error = ENOTSUP; + } + if (!error) { + ios_switchSession(job->key); + const char *name = ios_progname(); + if (!name || !strcmp(name, "dash") || !strcmp(name, "sh")) error = ENOTSUP; + else { + int pid = atomic_load(&foreground_command_pid); + foreground = pid > 0 ? ios_getThreadId(pid) : NULL; + if (!foreground || foreground == (pthread_t)-1) error = ESRCH; + } + } + pthread_mutex_unlock(&job->mutex); + if (error) return error; + // Never cancel a shell waiter: cancellation of pthread_cond_wait can leave + // the stock completion mutex locked. Raw TUIs already use the ETX path. + return pthread_equal(foreground, pthread_self()) ? ESRCH : pthread_cancel(foreground); +} + +int ashell_pane_cancel(void *handle) { + if (!handle) return EINVAL; + pane_job *job = handle; + pthread_mutex_lock(&job->mutex); + int error = job->done ? ESRCH : ENOTSUP; + pthread_mutex_unlock(&job->mutex); + return error; +} + +void ashell_pane_release(void *handle) { + if (!handle) return; + ashell_pane_close_stdin(handle); + pane_job *job = handle; + pthread_mutex_lock(&job->mutex); + NSLog(@"Shell pane lifecycle: release key=%s done=%d", job->key, job->done); + // Do not keep a hidden reader alive after the consumer closes its own fd: + // abandoned output must reach EPIPE, rather than filling an unread socket forever. + if (job->peer_control >= 0) close(job->peer_control); + job->peer_control = -1; + pthread_mutex_unlock(&job->mutex); + drop_reference(handle); +} diff --git a/ShellWorkers/PaneWire.h b/ShellWorkers/PaneWire.h new file mode 100644 index 00000000..a1b80a9c --- /dev/null +++ b/ShellWorkers/PaneWire.h @@ -0,0 +1,15 @@ +#import + +// One connection owns one real process and one existing native pane handle. +@protocol AShellPaneWire +- (void)spawn:(NSString *)command cwd:(NSData *)cwd home:(NSData *)home documents:(NSData *)documents probe:(NSString *)probe env:(NSArray *)env cols:(unsigned)cols rows:(unsigned)rows reply:(void (^)(int, NSFileHandle *, int))reply; +- (void)poll:(void (^)(int, int))reply; +- (void)resize:(unsigned)cols rows:(unsigned)rows reply:(void (^)(int))reply; +- (void)closeInput:(void (^)(int))reply; +- (void)interrupt:(void (^)(int))reply; +- (void)releasePane; +// The worker exits instead of replying; synchronous callers observe XPC loss. +- (void)terminate:(void (^)(void))reply; +@end + +NSObject *ashell_worker_service(void); diff --git a/ShellWorkers/RemotePane.m b/ShellWorkers/RemotePane.m new file mode 100644 index 00000000..09929eb1 --- /dev/null +++ b/ShellWorkers/RemotePane.m @@ -0,0 +1,175 @@ +#import "PaneWire.h" +#include "NativePane.h" +#include +#include +#include +#include +#include + +extern void ashell_extension_connect(void (^)(NSXPCConnection *, NSError *)); +extern void ashell_extension_disconnect(NSXPCConnection *); + +static void log_host_library(const char *phase) { + int saved = errno; + NSString *path = [NSHomeDirectory() stringByAppendingPathComponent:@"Library"]; + DIR *directory = opendir(path.fileSystemRepresentation); + int error = directory ? 0 : errno; + if (directory && closedir(directory)) error = errno; + NSLog(@"Shell host Library capability: phase=%s path=%@ read_error=%d", phase, path, error); + errno = saved; +} + +@interface RemotePane : NSObject +@property(nonatomic, strong) NSXPCConnection *connection; +@property(nonatomic, strong) id proxy; +@property(atomic) BOOL interrupted; +@property(nonatomic, strong) NSMutableArray *directories; +@property(nonatomic, copy) NSString *probeDirectory; +@end +@implementation RemotePane +- (void)dealloc { + // Only remove our empty disposable directory, never a project tree. + if (_probeDirectory) rmdir(_probeDirectory.fileSystemRepresentation); + log_host_library("before_scope_release"); + for (NSURL *url in _directories) { + [url stopAccessingSecurityScopedResource]; + NSLog(@"Shell host directory scope: phase=release url=%@", url.path); + } + log_host_library("after_scope_release"); +} +@end + +int ashell_pane_spawn(const char *command, const char *cwd, unsigned cols, unsigned rows, const char *const *envp, int *fd_out, void **job_out) { + if (fd_out) *fd_out = -1; + if (job_out) *job_out = NULL; + if (!command || !cwd || !fd_out || !job_out || !cols || !rows) return EINVAL; + if (NSThread.isMainThread) return EDEADLK; + @autoreleasepool { + NSString *home = NSHomeDirectory(); + for (size_t i = 0; envp && envp[i]; i++) + if (!strncmp(envp[i], "HOME=", 5)) home = @(envp[i] + 5); + RemotePane *pane = [RemotePane new]; + pane.directories = [NSMutableArray array]; + NSURL *homeURL = [NSURL fileURLWithPath:home isDirectory:YES]; + NSURL *documentsURL = [homeURL URLByAppendingPathComponent:@"Documents" isDirectory:YES]; + NSURL *cwdURL = [NSURL fileURLWithPath:@(cwd) isDirectory:YES]; + log_host_library("before_scope_acquire"); + NSString *container = NSHomeDirectory().stringByResolvingSymlinksInPath; + NSString *containerPrefix = [container stringByAppendingString:@"/"]; + for (NSURL *url in @[homeURL, documentsURL, cwdURL]) { + NSString *path = url.path.stringByResolvingSymlinksInPath; + // The host's own container needs no dynamic grant. Only external + // bookmarked folders belong to this pane's scope lifetime. + BOOL owned = [path isEqualToString:container] || [path hasPrefix:containerPrefix]; + BOOL acquired = !owned && [url startAccessingSecurityScopedResource]; + if (acquired) [pane.directories addObject:url]; + NSLog(@"Shell host directory scope: phase=acquire url=%@ own_container=%d acquired=%d", path, owned, acquired); + } + log_host_library("after_scope_acquire"); + NSError *bookmarkFailure = nil; + // Default bookmarks carry Foundation's implicit ephemeral sandbox scope. + // A pathname alone grants no access to the containing app's private files. + NSData *cwdBookmark = [cwdURL + bookmarkDataWithOptions:0 includingResourceValuesForKeys:nil relativeToURL:nil error:&bookmarkFailure]; + NSData *homeBookmark = cwdBookmark ? [homeURL + bookmarkDataWithOptions:0 includingResourceValuesForKeys:nil relativeToURL:nil error:&bookmarkFailure] : nil; + NSData *documentsBookmark = homeBookmark ? [documentsURL + bookmarkDataWithOptions:0 includingResourceValuesForKeys:nil relativeToURL:nil error:&bookmarkFailure] : nil; + if (!cwdBookmark || !homeBookmark || !documentsBookmark) { + NSLog(@"Shell pane directory bookmark failed: %@", bookmarkFailure); + return [bookmarkFailure.domain isEqualToString:NSPOSIXErrorDomain] ? (int)bookmarkFailure.code : EIO; + } + // A real create/read/unlink in this host-owned disposable directory proves + // the worker capability; bookmark resolution or access(W_OK) alone does not. + NSString *template = [documentsURL.path stringByAppendingPathComponent:@".ashell-worker-access.XXXXXX"]; + char *probe = strdup(template.fileSystemRepresentation); + if (!probe) return ENOMEM; + if (!mkdtemp(probe)) { int code = errno; free(probe); return code; } + pane.probeDirectory = [[NSFileManager defaultManager] stringWithFileSystemRepresentation:probe length:strlen(probe)]; + free(probe); + NSTimeInterval started = NSProcessInfo.processInfo.systemUptime; + dispatch_semaphore_t ready = dispatch_semaphore_create(0); + __block int error = 0; + ashell_extension_connect(^(NSXPCConnection *connection, NSError *failure) { + pane.connection = connection; + // ExtensionFoundation and Cocoa error numbers are not POSIX errno. + // In particular, framework code 1 must not masquerade as EPERM. + error = failure ? ([failure.domain isEqualToString:NSPOSIXErrorDomain] ? (int)failure.code : EIO) : 0; + if (failure) NSLog(@"Shell extension launch failed: %@", failure); + dispatch_semaphore_signal(ready); + }); + dispatch_semaphore_wait(ready, DISPATCH_TIME_FOREVER); + if (error) return error; + __weak RemotePane *weak = pane; + pane.connection.interruptionHandler = ^{ weak.interrupted = YES; }; + pane.connection.invalidationHandler = ^{ weak.interrupted = YES; }; + pane.connection.remoteObjectInterface = [NSXPCInterface interfaceWithProtocol:@protocol(AShellPaneWire)]; + [pane.connection resume]; + pane.proxy = [pane.connection synchronousRemoteObjectProxyWithErrorHandler:^(NSError *failure) { error = EPIPE; }]; + NSMutableArray *env = [NSMutableArray array]; + for (size_t i = 0; envp && envp[i]; i++) [env addObject:@(envp[i])]; + __block int fd = -1; + [pane.proxy spawn:@(command) cwd:cwdBookmark home:homeBookmark documents:documentsBookmark probe:pane.probeDirectory env:env cols:cols rows:rows reply:^(int code, NSFileHandle *handle, int pid) { + error = code; + if (!code && !handle) error = EPROTO; + if (!error) { + fd = dup(handle.fileDescriptor); + if (fd < 0) error = errno; + else if (fcntl(fd, F_SETFD, FD_CLOEXEC)) { error = errno; close(fd); fd = -1; } + } + NSLog(@"Shell extension spawn: real_pid=%d error=%d startup_ms=%.3f", pid, error, (NSProcessInfo.processInfo.systemUptime - started) * 1000); + }]; + if (rmdir(pane.probeDirectory.fileSystemRepresentation) == 0) pane.probeDirectory = nil; + if (!error && fd < 0) error = EPROTO; + if (error) { ashell_extension_disconnect(pane.connection); return error; } + *fd_out = fd; + *job_out = (__bridge_retained void *)pane; + return 0; + } +} +int ashell_pane_poll(void *job, int *status_out) { + RemotePane *pane = (__bridge RemotePane *)job; + if (!pane || !status_out) return -EINVAL; + // 137 is this backend's lost-worker status, not a recovered waitpid status. + if (pane.interrupted) { *status_out = 137; return 1; } + __block int result = -EPIPE; + [pane.proxy poll:^(int value, int status) { result = value; *status_out = status; }]; + if (result == -EPIPE) { pane.interrupted = YES; *status_out = 137; return 1; } + return result; +} +int ashell_pane_resize(void *job, unsigned cols, unsigned rows) { + RemotePane *pane = (__bridge RemotePane *)job; + if (!pane || pane.interrupted) return ESRCH; + __block int result = EPIPE; + [pane.proxy resize:cols rows:rows reply:^(int error) { result = error; }]; + return result; +} +int ashell_pane_close_stdin(void *job) { + RemotePane *pane = (__bridge RemotePane *)job; + if (!pane || pane.interrupted) return ESRCH; + __block int result = EPIPE; + [pane.proxy closeInput:^(int error) { result = error; }]; + return result; +} +int ashell_pane_interrupt(void *job) { + RemotePane *pane = (__bridge RemotePane *)job; + if (!pane || pane.interrupted) return ESRCH; + __block int result = EPIPE; + [pane.proxy interrupt:^(int error) { result = error; }]; + return result; +} +int ashell_pane_cancel(void *job) { + RemotePane *pane = (__bridge RemotePane *)job; + if (!pane) return EINVAL; + pane.interrupted = YES; + [pane.proxy terminate:^{}]; + ashell_extension_disconnect(pane.connection); + return 0; +} +void ashell_pane_release(void *job) { + if (!job) return; + RemotePane *pane = (__bridge_transfer RemotePane *)job; + [pane.proxy releasePane]; + [pane.proxy terminate:^{}]; + ashell_extension_disconnect(pane.connection); +} diff --git a/ShellWorkers/Worker.m b/ShellWorkers/Worker.m new file mode 100644 index 00000000..e215311c --- /dev/null +++ b/ShellWorkers/Worker.m @@ -0,0 +1,178 @@ +#import "PaneWire.h" +#import "ios_system/ios_system.h" +#include "NativePane.h" +#include +#include +#include +#include +#include +#include +#include + +// ios_system discovers these stock host callbacks with dlsym. Each extension +// owns one shell; its socket already carries input directly to the command. +static atomic_bool interactive_input; +__attribute__((used)) void startInteractive(void) { atomic_store(&interactive_input, true); } +__attribute__((used)) void stopInteractive(void) { atomic_store(&interactive_input, false); } +__attribute__((used)) int storeInteractive(void) { return atomic_load(&interactive_input); } + +// The stock host clear command calls its UIKit delegate. Workers clear through +// the terminal stream, just like an external terminal command. +__attribute__((used)) int clear(int argc, char **argv) { + if (!thread_stdout) return 1; + return fputs("\033[H\033[2J", thread_stdout) == EOF || fflush(thread_stdout) == EOF ? 1 : 0; +} + +static int check_directory(NSURL *url) { + DIR *directory = opendir(url.fileSystemRepresentation); + if (!directory) return errno; + return closedir(directory) == 0 ? 0 : errno; +} + +static int check_documents_write(NSURL *documents, NSString *probe) { + NSString *parent = probe.stringByDeletingLastPathComponent.stringByResolvingSymlinksInPath; + if (![parent isEqualToString:documents.path.stringByResolvingSymlinksInPath] || + ![probe.lastPathComponent hasPrefix:@".ashell-worker-access."]) return EINVAL; + int directory = open(probe.fileSystemRepresentation, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (directory < 0) return errno; + int fd = openat(directory, "capability", O_RDWR | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, 0600); + if (fd < 0) { int code = errno; close(directory); return code; } + const char expected = 'H'; + char observed = 0; + int code = 0; + if (write(fd, &expected, 1) != 1) code = errno ? errno : EIO; + else if (lseek(fd, 0, SEEK_SET) < 0) code = errno; + else if (read(fd, &observed, 1) != 1) code = errno ? errno : EIO; + else if (observed != expected) code = EIO; + if (close(fd) && !code) code = errno; + if (unlinkat(directory, "capability", 0) && !code) code = errno; + if (close(directory) && !code) code = errno; + return code; +} + +@interface PaneService : NSObject +@end + +@implementation PaneService { + void *_pane; + NSMutableArray *_directories; +} +- (void)spawn:(NSString *)command cwd:(NSData *)cwd home:(NSData *)home documents:(NSData *)documents probe:(NSString *)probe env:(NSArray *)env cols:(unsigned)cols rows:(unsigned)rows reply:(void (^)(int, NSFileHandle *, int))reply { + if (_pane) { reply(EBUSY, nil, getpid()); return; } + if (!cwd || !home || !documents || !probe) { reply(EINVAL, nil, getpid()); return; } + // Resolve the host's ephemeral grants explicitly and retain them for this + // pane, including HOME for shared command configuration and authentication. + _directories = [NSMutableArray array]; + NSURL *directory = nil; + NSURL *documentsURL = nil; + NSMutableArray *resolved = [NSMutableArray array]; + unsigned index = 0; + NSLog(@"Shell worker sandbox: real_pid=%d own_home=%@", getpid(), NSHomeDirectory()); + for (NSData *bookmark in @[home, documents, cwd]) { + NSError *failure = nil; + BOOL stale = NO; + NSURL *url = [NSURL URLByResolvingBookmarkData:bookmark + options:NSURLBookmarkResolutionWithoutUI | NSURLBookmarkResolutionWithoutImplicitStartAccessing + relativeToURL:nil bookmarkDataIsStale:&stale error:&failure]; + if (!url) { + NSLog(@"Shell worker directory bookmark failed: %@", failure); + [self releasePane]; + reply([failure.domain isEqualToString:NSPOSIXErrorDomain] ? (int)failure.code : EIO, nil, getpid()); + return; + } + BOOL scoped = [url startAccessingSecurityScopedResource]; + if (scoped) [_directories addObject:url]; + NSLog(@"Shell worker directory: index=%u url=%@ stale=%d scoped=%d", index, url.path, stale, scoped); + [resolved addObject:url]; + if (index == 1) documentsURL = url; + index++; + directory = url; + } + int readError = 0; + for (NSURL *url in resolved) { + int code = check_directory(url); + NSLog(@"Shell worker directory capability: url=%@ read_error=%d", url.path, code); + if (code && !readError) readError = code; + } + if (readError) { [self releasePane]; reply(readError, nil, getpid()); return; } + int capabilityError = check_documents_write(documentsURL, probe); + NSLog(@"Shell worker Documents capability: real_pid=%d read_write_error=%d", getpid(), capabilityError); + if (capabilityError) { [self releasePane]; reply(capabilityError, nil, getpid()); return; } + // ios_system globals are private to this extension. The adapter keeps its + // original dedicated pthread and stream ownership/cleanup barrier. + NSString *temporaryDirectory = NSTemporaryDirectory(); + if (!temporaryDirectory.length) { [self releasePane]; reply(EIO, nil, getpid()); return; } + // The host's directory bookmark does not grant its tmp directory. Keep the + // extension's stock Foundation/libc temp domain in both environment views. + NSMutableArray *environment = [NSMutableArray arrayWithCapacity:env.count + 1]; + for (NSString *value in env) + if (![value hasPrefix:@"TMPDIR="]) [environment addObject:value]; + [environment addObject:[@"TMPDIR=" stringByAppendingString:temporaryDirectory]]; + env = environment; + initializeEnvironment(); + replaceCommand(@"clear", @"clear", true); + // This process owns exactly one shell. Native Rust clients enumerate libc's + // environment as well as ios_system's forked command environment. + for (NSString *value in env) { + NSRange separator = [value rangeOfString:@"="]; + if (separator.location == NSNotFound || separator.location == 0) { [self releasePane]; reply(EINVAL, nil, getpid()); return; } + if (setenv([value substringToIndex:separator.location].UTF8String, + [value substringFromIndex:separator.location + 1].UTF8String, 1)) { int error = errno; [self releasePane]; reply(error, nil, getpid()); return; } + } + // Match stock SceneDelegate before libedit decodes Unicode or measures it. + if (setenv("LC_CTYPE", "UTF-8", 1)) { int error = errno; [self releasePane]; reply(error, nil, getpid()); return; } + const char *locale = setlocale(LC_CTYPE, "UTF-8"); + if (!locale) { [self releasePane]; reply(EINVAL, nil, getpid()); return; } + NSLog(@"Shell worker locale: real_pid=%d ctype=%s", getpid(), locale); + // The physical device has no kernel tty. Use the same socket-backed route + // on Simulator so its acceptance cannot pass through a desktop-only PTY. + if (setenv("ASHELL_IOS_PTY", "0", 1)) { int error = errno; [self releasePane]; reply(error, nil, getpid()); return; } + const char **values = calloc(env.count + 4, sizeof(char *)); + if (!values) { [self releasePane]; reply(ENOMEM, nil, getpid()); return; } + for (NSUInteger i = 0; i < env.count; i++) + values[i] = [env[i] hasPrefix:@"ASHELL_IOS_PTY="] ? "ASHELL_IOS_PTY=0" : + [env[i] hasPrefix:@"LC_CTYPE="] ? "LC_CTYPE=UTF-8" : env[i].UTF8String; + NSString *identity = [NSString stringWithFormat:@"ASHELL_WORKER_PID=%d", getpid()]; + values[env.count] = identity.UTF8String; + values[env.count + 1] = "LC_CTYPE=UTF-8"; + values[env.count + 2] = "ASHELL_IOS_SOCKET_INPUT=1"; + int fd = -1; + int error = ashell_pane_spawn(command.UTF8String, directory.path.UTF8String, cols, rows, values, &fd, &_pane); + free(values); + if (error) [self releasePane]; + NSFileHandle *handle = error ? nil : [[NSFileHandle alloc] initWithFileDescriptor:fd closeOnDealloc:YES]; + reply(error, handle, getpid()); +} +- (void)poll:(void (^)(int, int))reply { + int status = 0; + int result = _pane ? ashell_pane_poll(_pane, &status) : -ESRCH; + reply(result, status); +} +- (void)resize:(unsigned)cols rows:(unsigned)rows reply:(void (^)(int))reply { + reply(_pane ? ashell_pane_resize(_pane, cols, rows) : ESRCH); +} +- (void)closeInput:(void (^)(int))reply { reply(_pane ? ashell_pane_close_stdin(_pane) : ESRCH); } +- (void)interrupt:(void (^)(int))reply { + // Stream-backed TUIs have no kernel termios state. ENOTSUP sends the ETX + // byte through the existing host input path instead of cancelling a thread. + if (atomic_load(&interactive_input)) { reply(ENOTSUP); return; } + reply(_pane ? ashell_pane_interrupt(_pane) : ESRCH); +} +- (void)releasePane { + if (_pane) ashell_pane_release(_pane); + _pane = NULL; + for (NSURL *directory in _directories) [directory stopAccessingSecurityScopedResource]; + _directories = nil; +} +- (void)terminate:(void (^)(void))reply { + // AppExtensionProcess.invalidate releases the host's assertion but permits + // process reuse. End this dedicated worker even if its command ignores EOF. + // ios_system interposes exit/_exit; SIGKILL reaches the OS process directly. + // Do not acknowledge before death: the host's synchronous XPC call returns + // through its error handler only after the old worker can no longer run. + kill(getpid(), SIGKILL); +} +- (void)dealloc { [self releasePane]; } +@end + +NSObject *ashell_worker_service(void) { return [PaneService new]; } diff --git a/ShellWorkers/Worker.swift b/ShellWorkers/Worker.swift new file mode 100644 index 00000000..7b40748d --- /dev/null +++ b/ShellWorkers/Worker.swift @@ -0,0 +1,28 @@ +import ExtensionFoundation +import Foundation + +// Every extension identity compiles this same source and native pane adapter. +@main +final class PaneExtension: AppExtension { + required init() {} + + @AppExtensionPoint.Bind + var extensionPoint: AppExtensionPoint { + AppExtensionPoint.Identifier(host: "@ASHELL_WORKER_HOST_IDENTIFIER@", name: "ShellWorker") + } + + var configuration: some AppExtensionConfiguration { PaneConfiguration() } +} + +struct PaneConfiguration: AppExtensionConfiguration { + func accept(connection: NSXPCConnection) -> Bool { + connection.exportedInterface = NSXPCInterface(with: AShellPaneWire.self) + guard let service = ashell_worker_service() else { return false } + connection.exportedObject = service + // ios_system interposes exit/_exit as pthread_exit, which is invalid on + // this dispatch callback. Process lifetime belongs to ExtensionFoundation. + connection.invalidationHandler = { service.releasePane() } + connection.resume() + return true + } +} diff --git a/ShellWorkers/Worker.xcconfig b/ShellWorkers/Worker.xcconfig new file mode 100644 index 00000000..fda0cec8 --- /dev/null +++ b/ShellWorkers/Worker.xcconfig @@ -0,0 +1,22 @@ +// Dedicated Generic Non-UI worker targets share the stock host's command libraries. +SDKROOT = iphoneos +SUPPORTED_PLATFORMS = iphoneos iphonesimulator +IPHONEOS_DEPLOYMENT_TARGET = 26.0 +SWIFT_VERSION = 6.0 +SWIFT_STRICT_CONCURRENCY = complete +SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated +SWIFT_APPROACHABLE_CONCURRENCY = YES +CLANG_ENABLE_OBJC_ARC = YES +APPLICATION_EXTENSION_API_ONLY = YES +GENERATE_INFOPLIST_FILE = YES +EX_ENABLE_EXTENSION_POINT_GENERATION = YES +ASHELL_WORKER_HOST_IDENTIFIER = $(PRODUCT_BUNDLE_PREFIX:default=AsheKube.app).a-Shell-mini +SWIFT_OBJC_BRIDGING_HEADER = ShellWorkers/PaneWire.h +HEADER_SEARCH_PATHS = $(inherited) $(SRCROOT)/ShellWorkers +FRAMEWORK_SEARCH_PATHS = $(inherited) $(BUILT_PRODUCTS_DIR) +OTHER_LDFLAGS = $(inherited) -framework ios_system -framework Foundation -framework UIKit +LD_RUNPATH_SEARCH_PATHS = @executable_path/../../Frameworks @executable_path/Frameworks +SKIP_INSTALL = YES +CURRENT_PROJECT_VERSION = 589 +MARKETING_VERSION = 2.2.3 +CODE_SIGN_STYLE = Automatic diff --git a/ShellWorkers/resources.sh b/ShellWorkers/resources.sh new file mode 100644 index 00000000..6ddaa3b7 --- /dev/null +++ b/ShellWorkers/resources.sh @@ -0,0 +1,14 @@ +#!/bin/bash +# Reuse the host catalogue; workers locate shared libraries through their rpaths. +set -euo pipefail +source_dictionary="$SRCROOT/Resources_mini/commandDictionary.plist" +resources="$TARGET_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH" +mkdir -p "$resources/bin" "$DERIVED_FILE_DIR" +# ExtensionFoundation requires a literal StaticString for its host binding. +/usr/bin/sed "s|@ASHELL_WORKER_HOST_IDENTIFIER@|$ASHELL_WORKER_HOST_IDENTIFIER|g" "$SRCROOT/ShellWorkers/Worker.swift" >"$DERIVED_FILE_DIR/Worker.swift" +/usr/bin/sed -E 's|([^<]*\.framework/[^<]*)|@rpath/\1|' "$source_dictionary" >"$resources/commandDictionary.plist" +# Dash discovers a command through PATH before asking ios_system to dispatch it. +/usr/bin/sed -n 's|.*\([^<]*\).*|\1|p' "$source_dictionary" | while IFS= read -r command; do + touch "$resources/bin/$command" + chmod 755 "$resources/bin/$command" +done diff --git a/a-Shell-mini-Info.plist b/a-Shell-mini-Info.plist index 664ab9df..b90289e6 100644 --- a/a-Shell-mini-Info.plist +++ b/a-Shell-mini-Info.plist @@ -212,5 +212,7 @@ AppGroupIdentifier group.$(APP_GROUP_PREFIX:default=AsheKube).a-Shell + ShellWorkerCount + 10 diff --git a/a-Shell.xcodeproj/project.pbxproj b/a-Shell.xcodeproj/project.pbxproj index de410812..8055ff92 100644 --- a/a-Shell.xcodeproj/project.pbxproj +++ b/a-Shell.xcodeproj/project.pbxproj @@ -982,6 +982,264 @@ 6BD9536F2BFFCC6400C2508F /* ExecuteCommandIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BD9536D2BFFCC6400C2508F /* ExecuteCommandIntentHandler.swift */; }; 6BD953702BFFCC6400C2508F /* ExecuteCommandIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BD9536D2BFFCC6400C2508F /* ExecuteCommandIntentHandler.swift */; }; 89483CE425CC48B2008EF012 /* ios_system.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 228E13F225C95F2D0065D825 /* ios_system.xcframework */; }; + 7A025EB47CCEBDAA7E49CB3E = { + isa = "PBXBuildFile"; + fileRef = "D847238C57D48CADF264887A"; + }; + C608987E0D35D7D8C92CF091 = { + isa = "PBXBuildFile"; + fileRef = "11189940A2AF26A6542BDEAF"; + }; + A1C0F473B5B02A405758E45F = { + isa = "PBXBuildFile"; + fileRef = "070AE9F6D58955EE5B3B2FB9"; + }; + 01254C24560A350B59662101 = { + isa = "PBXBuildFile"; + fileRef = "BA7967007CF5D5AF7BEE03CE"; + }; + 948BA9E69DBEE6BFD829414C = { + isa = "PBXBuildFile"; + fileRef = "CBB6E958A8AF3B33B29AA4A8"; + }; + D2F30C91685F9DC284616312 = { + isa = "PBXBuildFile"; + fileRef = "3BA328FE78F02ED540EFB7DA"; + settings = { + ATTRIBUTES = ( + "RemoveHeadersOnCopy", + ); + }; + }; + 59144CB4855198131ED069B3 = { + isa = "PBXBuildFile"; + fileRef = "070AE9F6D58955EE5B3B2FB9"; + }; + 188B14A9B070A4E709C1A447 = { + isa = "PBXBuildFile"; + fileRef = "BA7967007CF5D5AF7BEE03CE"; + }; + C27C502276A1F0301FBC2CBA = { + isa = "PBXBuildFile"; + fileRef = "CBB6E958A8AF3B33B29AA4A8"; + }; + 7068DDDA75BCEA8A404C09AD = { + isa = "PBXBuildFile"; + fileRef = "295B641B253C35B85CBD0100"; + settings = { + ATTRIBUTES = ( + "RemoveHeadersOnCopy", + ); + }; + }; + 2A45EE5BE77CD575DD0ADEBE = { + isa = "PBXBuildFile"; + fileRef = "070AE9F6D58955EE5B3B2FB9"; + }; + DB9A503B8C8690B9298B35E3 = { + isa = "PBXBuildFile"; + fileRef = "BA7967007CF5D5AF7BEE03CE"; + }; + 8F4F306A0A771AF4667582BA = { + isa = "PBXBuildFile"; + fileRef = "CBB6E958A8AF3B33B29AA4A8"; + }; + FD33E045D3FF5D175364CB7C = { + isa = "PBXBuildFile"; + fileRef = "1D81B2E2F18AE7821D05A7AD"; + settings = { + ATTRIBUTES = ( + "RemoveHeadersOnCopy", + ); + }; + }; + F2D20BDE70DB7C92C03509DE = { + isa = "PBXBuildFile"; + fileRef = "070AE9F6D58955EE5B3B2FB9"; + }; + 70E66D31CD0F42D23FBC3EFC = { + isa = "PBXBuildFile"; + fileRef = "BA7967007CF5D5AF7BEE03CE"; + }; + A85A41CEA814190D8404E105 = { + isa = "PBXBuildFile"; + fileRef = "CBB6E958A8AF3B33B29AA4A8"; + }; + 27937DD7D9B079E79382E010 = { + isa = "PBXBuildFile"; + fileRef = "671E1D67938C7F8A1BF10D84"; + settings = { + ATTRIBUTES = ( + "RemoveHeadersOnCopy", + ); + }; + }; + F1632FC1E103CFB4010F7006 = { + isa = "PBXBuildFile"; + fileRef = "070AE9F6D58955EE5B3B2FB9"; + }; + 6C8E5746297C24B1080C03AF = { + isa = "PBXBuildFile"; + fileRef = "BA7967007CF5D5AF7BEE03CE"; + }; + 7407B0C9B5E07D5492D98053 = { + isa = "PBXBuildFile"; + fileRef = "CBB6E958A8AF3B33B29AA4A8"; + }; + CDECC5E5A6CB9CC531134296 = { + isa = "PBXBuildFile"; + fileRef = "366758A2DEC0386D139D2234"; + settings = { + ATTRIBUTES = ( + "RemoveHeadersOnCopy", + ); + }; + }; + 8299DBD0E93022AAEACE5F54 = { + isa = "PBXBuildFile"; + fileRef = "070AE9F6D58955EE5B3B2FB9"; + }; + B70CFF8BCD5A858CCB5D600B = { + isa = "PBXBuildFile"; + fileRef = "BA7967007CF5D5AF7BEE03CE"; + }; + 084FF2541A50122906582CAC = { + isa = "PBXBuildFile"; + fileRef = "CBB6E958A8AF3B33B29AA4A8"; + }; + BD336245D81BD967822E8805 = { + isa = "PBXBuildFile"; + fileRef = "A8EA5B4748F0CC00F65BA1A1"; + settings = { + ATTRIBUTES = ( + "RemoveHeadersOnCopy", + ); + }; + }; + E357BFCCA00A4DD6EF1D0235 = { + isa = "PBXBuildFile"; + fileRef = "070AE9F6D58955EE5B3B2FB9"; + }; + 60BA73F3EE45C345CD0C593D = { + isa = "PBXBuildFile"; + fileRef = "BA7967007CF5D5AF7BEE03CE"; + }; + 755E22C57BB293CDAD64CB41 = { + isa = "PBXBuildFile"; + fileRef = "CBB6E958A8AF3B33B29AA4A8"; + }; + 632684F10ED929FB4713B2AE = { + isa = "PBXBuildFile"; + fileRef = "A490F7519B2E1597438D0853"; + settings = { + ATTRIBUTES = ( + "RemoveHeadersOnCopy", + ); + }; + }; + 556FE5F2A7285723350E4CAC = { + isa = "PBXBuildFile"; + fileRef = "070AE9F6D58955EE5B3B2FB9"; + }; + 1C7B8B3BAEF7289AACBD231D = { + isa = "PBXBuildFile"; + fileRef = "BA7967007CF5D5AF7BEE03CE"; + }; + C3EEB3DED5B57200DA36F34E = { + isa = "PBXBuildFile"; + fileRef = "CBB6E958A8AF3B33B29AA4A8"; + }; + B20620C6A9ADEC432BF25194 = { + isa = "PBXBuildFile"; + fileRef = "5A7F8536C04A2BAC1834B3E7"; + settings = { + ATTRIBUTES = ( + "RemoveHeadersOnCopy", + ); + }; + }; + 359DDEBBC7AB6E4D48032723 = { + isa = "PBXBuildFile"; + fileRef = "070AE9F6D58955EE5B3B2FB9"; + }; + E2CFAA905BF589905A69D32F = { + isa = "PBXBuildFile"; + fileRef = "BA7967007CF5D5AF7BEE03CE"; + }; + 4096B185162EAE2C4738E1C1 = { + isa = "PBXBuildFile"; + fileRef = "CBB6E958A8AF3B33B29AA4A8"; + }; + 4E381ABD8DEE9DA436598879 = { + isa = "PBXBuildFile"; + fileRef = "1A10CFCE432C459F3DD1051D"; + settings = { + ATTRIBUTES = ( + "RemoveHeadersOnCopy", + ); + }; + }; + 5A652AAB3F21AD3EEE1DC9B1 = { + isa = "PBXBuildFile"; + fileRef = "070AE9F6D58955EE5B3B2FB9"; + }; + 72B403FFF4DA5BA1CEDD5717 = { + isa = "PBXBuildFile"; + fileRef = "BA7967007CF5D5AF7BEE03CE"; + }; + C14B2CCCCE38CFD39751A8F3 = { + isa = "PBXBuildFile"; + fileRef = "CBB6E958A8AF3B33B29AA4A8"; + }; + 96976D843556F3E024DE3B2F = { + isa = "PBXBuildFile"; + fileRef = "93314A863CABFBA3A5626312"; + settings = { + ATTRIBUTES = ( + "RemoveHeadersOnCopy", + ); + }; + }; + 1277856148B9DF5BCFB73A45 = { + isa = "PBXBuildFile"; + fileRef = "228E13F225C95F2D0065D825"; + }; + 0673335401F0E6294B1D6A65 = { + isa = "PBXBuildFile"; + fileRef = "228E13F225C95F2D0065D825"; + }; + 92590DF487A4D5B48CEE3C36 = { + isa = "PBXBuildFile"; + fileRef = "228E13F225C95F2D0065D825"; + }; + 539578FA38E5117D4FFA6F13 = { + isa = "PBXBuildFile"; + fileRef = "228E13F225C95F2D0065D825"; + }; + 210223C3B93B2D495C96B792 = { + isa = "PBXBuildFile"; + fileRef = "228E13F225C95F2D0065D825"; + }; + 4C9CA710D4734D323992A80C = { + isa = "PBXBuildFile"; + fileRef = "228E13F225C95F2D0065D825"; + }; + 3AABA6ADD41BDE6C2C6BEC6E = { + isa = "PBXBuildFile"; + fileRef = "228E13F225C95F2D0065D825"; + }; + 5588CB35CBA255F519ECBB71 = { + isa = "PBXBuildFile"; + fileRef = "228E13F225C95F2D0065D825"; + }; + 09D5DF2DB0BAEEB73DCFED25 = { + isa = "PBXBuildFile"; + fileRef = "228E13F225C95F2D0065D825"; + }; + 6B4BFFDBD16619E32E8DA990 = { + isa = "PBXBuildFile"; + fileRef = "228E13F225C95F2D0065D825"; + }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -1899,6 +2157,26 @@ name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; }; + 612A319DA4E81CC5CF4B62F3 = { + isa = "PBXCopyFilesBuildPhase"; + buildActionMask = 2147483647; + dstPath = "$(EXTENSIONS_FOLDER_PATH)"; + dstSubfolderSpec = 16; + files = ( + "D2F30C91685F9DC284616312", + "7068DDDA75BCEA8A404C09AD", + "FD33E045D3FF5D175364CB7C", + "27937DD7D9B079E79382E010", + "CDECC5E5A6CB9CC531134296", + "BD336245D81BD967822E8805", + "632684F10ED929FB4713B2AE", + "B20620C6A9ADEC432BF25194", + "4E381ABD8DEE9DA436598879", + "96976D843556F3E024DE3B2F", + ); + name = "Embed Shell Workers"; + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ @@ -2751,6 +3029,106 @@ 8916088E2713056900CED1C9 /* KBWebViewBase.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = KBWebViewBase.m; sourceTree = ""; }; 8916088F2713056A00CED1C9 /* KBWebViewBase.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = KBWebViewBase.h; sourceTree = ""; }; 898384A83061A39800A1E18D /* Build.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Build.xcconfig; sourceTree = ""; }; + D847238C57D48CADF264887A = { + isa = "PBXFileReference"; + path = "ShellWorkers/Host.swift"; + sourceTree = "SOURCE_ROOT"; + }; + 11189940A2AF26A6542BDEAF = { + isa = "PBXFileReference"; + path = "ShellWorkers/RemotePane.m"; + sourceTree = "SOURCE_ROOT"; + }; + 070AE9F6D58955EE5B3B2FB9 = { + isa = "PBXFileReference"; + path = "$(DERIVED_FILE_DIR)/Worker.swift"; + sourceTree = ""; + }; + BA7967007CF5D5AF7BEE03CE = { + isa = "PBXFileReference"; + path = "ShellWorkers/Worker.m"; + sourceTree = "SOURCE_ROOT"; + }; + CBB6E958A8AF3B33B29AA4A8 = { + isa = "PBXFileReference"; + path = "ShellWorkers/NativePane.m"; + sourceTree = "SOURCE_ROOT"; + }; + EBFE9D103C0EF60A16FFF4FE = { + isa = "PBXFileReference"; + path = "ShellWorkers/NativePane.h"; + sourceTree = "SOURCE_ROOT"; + }; + 829DEA185012AF6B325E7867 = { + isa = "PBXFileReference"; + path = "ShellWorkers/PaneWire.h"; + sourceTree = "SOURCE_ROOT"; + }; + 0893D186728BD261366E4BD1 = { + isa = "PBXFileReference"; + path = "ShellWorkers/Worker.xcconfig"; + sourceTree = "SOURCE_ROOT"; + }; + 3BA328FE78F02ED540EFB7DA = { + isa = "PBXFileReference"; + explicitFileType = "wrapper.app-extension"; + path = "ShellWorkerA.appex"; + sourceTree = "BUILT_PRODUCTS_DIR"; + }; + 295B641B253C35B85CBD0100 = { + isa = "PBXFileReference"; + explicitFileType = "wrapper.app-extension"; + path = "ShellWorkerB.appex"; + sourceTree = "BUILT_PRODUCTS_DIR"; + }; + 1D81B2E2F18AE7821D05A7AD = { + isa = "PBXFileReference"; + explicitFileType = "wrapper.app-extension"; + path = "ShellWorkerC.appex"; + sourceTree = "BUILT_PRODUCTS_DIR"; + }; + 671E1D67938C7F8A1BF10D84 = { + isa = "PBXFileReference"; + explicitFileType = "wrapper.app-extension"; + path = "ShellWorkerD.appex"; + sourceTree = "BUILT_PRODUCTS_DIR"; + }; + 366758A2DEC0386D139D2234 = { + isa = "PBXFileReference"; + explicitFileType = "wrapper.app-extension"; + path = "ShellWorkerE.appex"; + sourceTree = "BUILT_PRODUCTS_DIR"; + }; + A8EA5B4748F0CC00F65BA1A1 = { + isa = "PBXFileReference"; + explicitFileType = "wrapper.app-extension"; + path = "ShellWorkerF.appex"; + sourceTree = "BUILT_PRODUCTS_DIR"; + }; + A490F7519B2E1597438D0853 = { + isa = "PBXFileReference"; + explicitFileType = "wrapper.app-extension"; + path = "ShellWorkerG.appex"; + sourceTree = "BUILT_PRODUCTS_DIR"; + }; + 5A7F8536C04A2BAC1834B3E7 = { + isa = "PBXFileReference"; + explicitFileType = "wrapper.app-extension"; + path = "ShellWorkerH.appex"; + sourceTree = "BUILT_PRODUCTS_DIR"; + }; + 1A10CFCE432C459F3DD1051D = { + isa = "PBXFileReference"; + explicitFileType = "wrapper.app-extension"; + path = "ShellWorkerI.appex"; + sourceTree = "BUILT_PRODUCTS_DIR"; + }; + 93314A863CABFBA3A5626312 = { + isa = "PBXFileReference"; + explicitFileType = "wrapper.app-extension"; + path = "ShellWorkerJ.appex"; + sourceTree = "BUILT_PRODUCTS_DIR"; + }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -2816,6 +3194,86 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 14FF53A4F006E7098AEAAE7F = { + isa = "PBXFrameworksBuildPhase"; + buildActionMask = 2147483647; + files = ( + "1277856148B9DF5BCFB73A45", + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DEAB4E94790E3EA7168A48C3 = { + isa = "PBXFrameworksBuildPhase"; + buildActionMask = 2147483647; + files = ( + "0673335401F0E6294B1D6A65", + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 258FF1E0CF03820F66E545A5 = { + isa = "PBXFrameworksBuildPhase"; + buildActionMask = 2147483647; + files = ( + "92590DF487A4D5B48CEE3C36", + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 82F519E40E8A8147E4355208 = { + isa = "PBXFrameworksBuildPhase"; + buildActionMask = 2147483647; + files = ( + "539578FA38E5117D4FFA6F13", + ); + runOnlyForDeploymentPostprocessing = 0; + }; + B845E8A798AC3E84D1E78A6A = { + isa = "PBXFrameworksBuildPhase"; + buildActionMask = 2147483647; + files = ( + "210223C3B93B2D495C96B792", + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 546F48346B8E1E84B6C5218F = { + isa = "PBXFrameworksBuildPhase"; + buildActionMask = 2147483647; + files = ( + "4C9CA710D4734D323992A80C", + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3BAA79E324EBC4898A0D14A6 = { + isa = "PBXFrameworksBuildPhase"; + buildActionMask = 2147483647; + files = ( + "3AABA6ADD41BDE6C2C6BEC6E", + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 5A1CAE50D727AC931A1AC17A = { + isa = "PBXFrameworksBuildPhase"; + buildActionMask = 2147483647; + files = ( + "5588CB35CBA255F519ECBB71", + ); + runOnlyForDeploymentPostprocessing = 0; + }; + FCA3F3734E17EAA67DAFC01F = { + isa = "PBXFrameworksBuildPhase"; + buildActionMask = 2147483647; + files = ( + "09D5DF2DB0BAEEB73DCFED25", + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 0FCC490857271C3EA8FD0560 = { + isa = "PBXFrameworksBuildPhase"; + buildActionMask = 2147483647; + files = ( + "6B4BFFDBD16619E32E8DA990", + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -3950,6 +4408,7 @@ isa = PBXNativeTarget; buildConfigurationList = 229D25A3257931E4004A78AC /* Build configuration list for PBXNativeTarget "a-Shell-mini" */; buildPhases = ( + 612A319DA4E81CC5CF4B62F3, 229D2476257931E4004A78AC /* Sources */, 229D2486257931E4004A78AC /* Frameworks */, 229D2488257931E4004A78AC /* Resources */, @@ -3959,6 +4418,16 @@ buildRules = ( ); dependencies = ( + E119072ED69DB93517DB130D, + AD01960A41E12D8546D60344, + 31A01BF362BE8594C88A7461, + 42442DC659794242C4C4F078, + 0AAC31F15A0DCCDD78983016, + A635836C211C3AB5C85C00A7, + 26F687795555A5D88B96304D, + BB6767147D3C4FEA4C3C952B, + C07B3F85C9AAEB628033EA61, + 88F36AC8C5832103CAA7195E, 22178F0B257BBBA6008EB2B4 /* PBXTargetDependency */, 22178F0E257BBBA6008EB2B4 /* PBXTargetDependency */, ); @@ -4006,6 +4475,186 @@ productReference = 22AD8721246EBFFB00F415DE /* a-Shell-IntentsUI.appex */; productType = "com.apple.product-type.app-extension"; }; + B80CAEC73DAE28A7664A2FE7 = { + isa = "PBXNativeTarget"; + buildConfigurationList = "AF05FF73FF3462482114110C"; + buildPhases = ( + 14FF53A4F006E7098AEAAE7F, + B6DFC70EFFFF8DF6433E72CB, + "718D17729DFE061A0416ECA4", + "EC1A1AC5948AC9AA36F4B087", + ); + buildRules = ( + ); + dependencies = ( + ); + name = "ShellWorkerA"; + productName = "ShellWorkerA"; + productReference = "3BA328FE78F02ED540EFB7DA"; + productType = "com.apple.product-type.extensionkit-extension"; + }; + EE8FF11D18D32F6F9601FA0D = { + isa = "PBXNativeTarget"; + buildConfigurationList = "7B74F35F903EA73A5F1CC5CA"; + buildPhases = ( + DEAB4E94790E3EA7168A48C3, + B67A38D341342EF376079F91, + "95713C2B5DDEC29599BCB5F5", + "E9D1E579FDF61B49CE0FEC17", + ); + buildRules = ( + ); + dependencies = ( + ); + name = "ShellWorkerB"; + productName = "ShellWorkerB"; + productReference = "295B641B253C35B85CBD0100"; + productType = "com.apple.product-type.extensionkit-extension"; + }; + F1BC0042128131FA3BB63FE6 = { + isa = "PBXNativeTarget"; + buildConfigurationList = "4E8FD56F38A3D6CA40A44BBA"; + buildPhases = ( + 258FF1E0CF03820F66E545A5, + 2D03C0D5AAA2F2256E34D090, + "2DC585EAE450FD4D0A61AFE3", + "F87943363674934C30AE6197", + ); + buildRules = ( + ); + dependencies = ( + ); + name = "ShellWorkerC"; + productName = "ShellWorkerC"; + productReference = "1D81B2E2F18AE7821D05A7AD"; + productType = "com.apple.product-type.extensionkit-extension"; + }; + B1BF9CA28196AF7D11ED15C1 = { + isa = "PBXNativeTarget"; + buildConfigurationList = "C529F9BD8FC95FC69151E5D9"; + buildPhases = ( + 82F519E40E8A8147E4355208, + CD23A87555F8E9CB087A2CC3, + "6531237C17643573A10DC2A8", + "3A1724BD1DB4BAD26BEFB1A0", + ); + buildRules = ( + ); + dependencies = ( + ); + name = "ShellWorkerD"; + productName = "ShellWorkerD"; + productReference = "671E1D67938C7F8A1BF10D84"; + productType = "com.apple.product-type.extensionkit-extension"; + }; + CE532D2E94C62529D2EAC80C = { + isa = "PBXNativeTarget"; + buildConfigurationList = "357D1EE97A1181CB2C69AE7B"; + buildPhases = ( + B845E8A798AC3E84D1E78A6A, + 0A54213A9F754AD98D50483E, + "FB4B4076C7B39F3D49510853", + "34CBB91233009C8ABD5A85B7", + ); + buildRules = ( + ); + dependencies = ( + ); + name = "ShellWorkerE"; + productName = "ShellWorkerE"; + productReference = "366758A2DEC0386D139D2234"; + productType = "com.apple.product-type.extensionkit-extension"; + }; + E698830C007E12B930873C45 = { + isa = "PBXNativeTarget"; + buildConfigurationList = "63E3448BFC96684E006144BE"; + buildPhases = ( + 546F48346B8E1E84B6C5218F, + A8E67298AAEA82BF82D81A15, + "9E5D451B8CEF1EF743CC2B97", + "A3640D0AC75735AA97CA7E34", + ); + buildRules = ( + ); + dependencies = ( + ); + name = "ShellWorkerF"; + productName = "ShellWorkerF"; + productReference = "A8EA5B4748F0CC00F65BA1A1"; + productType = "com.apple.product-type.extensionkit-extension"; + }; + 9C778EEDE9AFE25B17E919CD = { + isa = "PBXNativeTarget"; + buildConfigurationList = "01298A57963C7D7FC8A5E943"; + buildPhases = ( + 3BAA79E324EBC4898A0D14A6, + 9E2EF90743ED233D863DDABE, + "8565DB4F80836EA220237CA0", + "3C98BBDD25082B6A0ADD867E", + ); + buildRules = ( + ); + dependencies = ( + ); + name = "ShellWorkerG"; + productName = "ShellWorkerG"; + productReference = "A490F7519B2E1597438D0853"; + productType = "com.apple.product-type.extensionkit-extension"; + }; + BDF38991DBFB316DAA737CDC = { + isa = "PBXNativeTarget"; + buildConfigurationList = "BFB21F5FBA41F45FB2CB4F2F"; + buildPhases = ( + 5A1CAE50D727AC931A1AC17A, + F47AB85C2E323C145A9C92C4, + "C9B665B86623DA45589D3DD5", + "BE93A368BA56F46061D5A30F", + ); + buildRules = ( + ); + dependencies = ( + ); + name = "ShellWorkerH"; + productName = "ShellWorkerH"; + productReference = "5A7F8536C04A2BAC1834B3E7"; + productType = "com.apple.product-type.extensionkit-extension"; + }; + 980C38795331054C0AB04808 = { + isa = "PBXNativeTarget"; + buildConfigurationList = "5444CAFE71E28CDFEBA55CCD"; + buildPhases = ( + FCA3F3734E17EAA67DAFC01F, + 4FD09DF0A4CEF6A5C5CBDEB5, + "05343EBACA43E356190F5510", + "0DB04B227C827E3026826B08", + ); + buildRules = ( + ); + dependencies = ( + ); + name = "ShellWorkerI"; + productName = "ShellWorkerI"; + productReference = "1A10CFCE432C459F3DD1051D"; + productType = "com.apple.product-type.extensionkit-extension"; + }; + A614E13CB5ADC97DAC1C52AC = { + isa = "PBXNativeTarget"; + buildConfigurationList = "9094C1580181846504A7959E"; + buildPhases = ( + 0FCC490857271C3EA8FD0560, + 5BE30F6A540EDF0BD98793A1, + "5E23CF762C8E87A5C4F07026", + "81A62DB36AA951C12F501AA6", + ); + buildRules = ( + ); + dependencies = ( + ); + name = "ShellWorkerJ"; + productName = "ShellWorkerJ"; + productReference = "93314A863CABFBA3A5626312"; + productType = "com.apple.product-type.extensionkit-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -4057,6 +4706,16 @@ projectDirPath = ""; projectRoot = ""; targets = ( + B80CAEC73DAE28A7664A2FE7, + EE8FF11D18D32F6F9601FA0D, + F1BC0042128131FA3BB63FE6, + B1BF9CA28196AF7D11ED15C1, + CE532D2E94C62529D2EAC80C, + E698830C007E12B930873C45, + 9C778EEDE9AFE25B17E919CD, + BDF38991DBFB316DAA737CDC, + 980C38795331054C0AB04808, + A614E13CB5ADC97DAC1C52AC, 22984EE922C93DBC00069497 /* a-Shell */, 22AD8717246EBFFA00F415DE /* a-Shell-Intents */, 22AD8720246EBFFB00F415DE /* a-Shell-IntentsUI */, @@ -4210,8 +4869,271 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + EC1A1AC5948AC9AA36F4B087 = { + isa = "PBXResourcesBuildPhase"; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E9D1E579FDF61B49CE0FEC17 = { + isa = "PBXResourcesBuildPhase"; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F87943363674934C30AE6197 = { + isa = "PBXResourcesBuildPhase"; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3A1724BD1DB4BAD26BEFB1A0 = { + isa = "PBXResourcesBuildPhase"; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 34CBB91233009C8ABD5A85B7 = { + isa = "PBXResourcesBuildPhase"; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A3640D0AC75735AA97CA7E34 = { + isa = "PBXResourcesBuildPhase"; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3C98BBDD25082B6A0ADD867E = { + isa = "PBXResourcesBuildPhase"; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + BE93A368BA56F46061D5A30F = { + isa = "PBXResourcesBuildPhase"; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 0DB04B227C827E3026826B08 = { + isa = "PBXResourcesBuildPhase"; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 81A62DB36AA951C12F501AA6 = { + isa = "PBXResourcesBuildPhase"; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ +/* Begin PBXShellScriptBuildPhase section */ + B6DFC70EFFFF8DF6433E72CB = { + isa = "PBXShellScriptBuildPhase"; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "$(SRCROOT)/Resources_mini/commandDictionary.plist", + "$(SRCROOT)/ShellWorkers/resources.sh", + "$(SRCROOT)/ShellWorkers/Worker.swift", + ); + name = "Prepare worker assets"; + outputPaths = ( + "$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/commandDictionary.plist", + "$(DERIVED_FILE_DIR)/Worker.swift", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = "/bin/bash"; + shellScript = "/bin/bash \"$SRCROOT/ShellWorkers/resources.sh\"\n"; + }; + B67A38D341342EF376079F91 = { + isa = "PBXShellScriptBuildPhase"; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "$(SRCROOT)/Resources_mini/commandDictionary.plist", + "$(SRCROOT)/ShellWorkers/resources.sh", + "$(SRCROOT)/ShellWorkers/Worker.swift", + ); + name = "Prepare worker assets"; + outputPaths = ( + "$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/commandDictionary.plist", + "$(DERIVED_FILE_DIR)/Worker.swift", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = "/bin/bash"; + shellScript = "/bin/bash \"$SRCROOT/ShellWorkers/resources.sh\"\n"; + }; + 2D03C0D5AAA2F2256E34D090 = { + isa = "PBXShellScriptBuildPhase"; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "$(SRCROOT)/Resources_mini/commandDictionary.plist", + "$(SRCROOT)/ShellWorkers/resources.sh", + "$(SRCROOT)/ShellWorkers/Worker.swift", + ); + name = "Prepare worker assets"; + outputPaths = ( + "$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/commandDictionary.plist", + "$(DERIVED_FILE_DIR)/Worker.swift", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = "/bin/bash"; + shellScript = "/bin/bash \"$SRCROOT/ShellWorkers/resources.sh\"\n"; + }; + CD23A87555F8E9CB087A2CC3 = { + isa = "PBXShellScriptBuildPhase"; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "$(SRCROOT)/Resources_mini/commandDictionary.plist", + "$(SRCROOT)/ShellWorkers/resources.sh", + "$(SRCROOT)/ShellWorkers/Worker.swift", + ); + name = "Prepare worker assets"; + outputPaths = ( + "$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/commandDictionary.plist", + "$(DERIVED_FILE_DIR)/Worker.swift", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = "/bin/bash"; + shellScript = "/bin/bash \"$SRCROOT/ShellWorkers/resources.sh\"\n"; + }; + 0A54213A9F754AD98D50483E = { + isa = "PBXShellScriptBuildPhase"; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "$(SRCROOT)/Resources_mini/commandDictionary.plist", + "$(SRCROOT)/ShellWorkers/resources.sh", + "$(SRCROOT)/ShellWorkers/Worker.swift", + ); + name = "Prepare worker assets"; + outputPaths = ( + "$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/commandDictionary.plist", + "$(DERIVED_FILE_DIR)/Worker.swift", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = "/bin/bash"; + shellScript = "/bin/bash \"$SRCROOT/ShellWorkers/resources.sh\"\n"; + }; + A8E67298AAEA82BF82D81A15 = { + isa = "PBXShellScriptBuildPhase"; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "$(SRCROOT)/Resources_mini/commandDictionary.plist", + "$(SRCROOT)/ShellWorkers/resources.sh", + "$(SRCROOT)/ShellWorkers/Worker.swift", + ); + name = "Prepare worker assets"; + outputPaths = ( + "$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/commandDictionary.plist", + "$(DERIVED_FILE_DIR)/Worker.swift", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = "/bin/bash"; + shellScript = "/bin/bash \"$SRCROOT/ShellWorkers/resources.sh\"\n"; + }; + 9E2EF90743ED233D863DDABE = { + isa = "PBXShellScriptBuildPhase"; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "$(SRCROOT)/Resources_mini/commandDictionary.plist", + "$(SRCROOT)/ShellWorkers/resources.sh", + "$(SRCROOT)/ShellWorkers/Worker.swift", + ); + name = "Prepare worker assets"; + outputPaths = ( + "$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/commandDictionary.plist", + "$(DERIVED_FILE_DIR)/Worker.swift", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = "/bin/bash"; + shellScript = "/bin/bash \"$SRCROOT/ShellWorkers/resources.sh\"\n"; + }; + F47AB85C2E323C145A9C92C4 = { + isa = "PBXShellScriptBuildPhase"; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "$(SRCROOT)/Resources_mini/commandDictionary.plist", + "$(SRCROOT)/ShellWorkers/resources.sh", + "$(SRCROOT)/ShellWorkers/Worker.swift", + ); + name = "Prepare worker assets"; + outputPaths = ( + "$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/commandDictionary.plist", + "$(DERIVED_FILE_DIR)/Worker.swift", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = "/bin/bash"; + shellScript = "/bin/bash \"$SRCROOT/ShellWorkers/resources.sh\"\n"; + }; + 4FD09DF0A4CEF6A5C5CBDEB5 = { + isa = "PBXShellScriptBuildPhase"; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "$(SRCROOT)/Resources_mini/commandDictionary.plist", + "$(SRCROOT)/ShellWorkers/resources.sh", + "$(SRCROOT)/ShellWorkers/Worker.swift", + ); + name = "Prepare worker assets"; + outputPaths = ( + "$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/commandDictionary.plist", + "$(DERIVED_FILE_DIR)/Worker.swift", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = "/bin/bash"; + shellScript = "/bin/bash \"$SRCROOT/ShellWorkers/resources.sh\"\n"; + }; + 5BE30F6A540EDF0BD98793A1 = { + isa = "PBXShellScriptBuildPhase"; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "$(SRCROOT)/Resources_mini/commandDictionary.plist", + "$(SRCROOT)/ShellWorkers/resources.sh", + "$(SRCROOT)/ShellWorkers/Worker.swift", + ); + name = "Prepare worker assets"; + outputPaths = ( + "$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/commandDictionary.plist", + "$(DERIVED_FILE_DIR)/Worker.swift", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = "/bin/bash"; + shellScript = "/bin/bash \"$SRCROOT/ShellWorkers/resources.sh\"\n"; + }; +/* End PBXShellScriptBuildPhase section */ + /* Begin PBXSourcesBuildPhase section */ 22178EF4257BBBA4008EB2B4 /* Sources */ = { isa = PBXSourcesBuildPhase; @@ -4289,6 +5211,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + C608987E0D35D7D8C92CF091, + 7A025EB47CCEBDAA7E49CB3E, 229D2477257931E4004A78AC /* ExtraCommands.swift in Sources */, 229D2478257931E4004A78AC /* AppDelegate.swift in Sources */, 223A1C1D2A5EDCA700B1B9AD /* Tips.swift in Sources */, @@ -4330,6 +5254,106 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 718D17729DFE061A0416ECA4 = { + isa = "PBXSourcesBuildPhase"; + buildActionMask = 2147483647; + files = ( + "A1C0F473B5B02A405758E45F", + "01254C24560A350B59662101", + "948BA9E69DBEE6BFD829414C", + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 95713C2B5DDEC29599BCB5F5 = { + isa = "PBXSourcesBuildPhase"; + buildActionMask = 2147483647; + files = ( + "59144CB4855198131ED069B3", + "188B14A9B070A4E709C1A447", + "C27C502276A1F0301FBC2CBA", + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 2DC585EAE450FD4D0A61AFE3 = { + isa = "PBXSourcesBuildPhase"; + buildActionMask = 2147483647; + files = ( + "2A45EE5BE77CD575DD0ADEBE", + "DB9A503B8C8690B9298B35E3", + "8F4F306A0A771AF4667582BA", + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 6531237C17643573A10DC2A8 = { + isa = "PBXSourcesBuildPhase"; + buildActionMask = 2147483647; + files = ( + "F2D20BDE70DB7C92C03509DE", + "70E66D31CD0F42D23FBC3EFC", + "A85A41CEA814190D8404E105", + ); + runOnlyForDeploymentPostprocessing = 0; + }; + FB4B4076C7B39F3D49510853 = { + isa = "PBXSourcesBuildPhase"; + buildActionMask = 2147483647; + files = ( + "F1632FC1E103CFB4010F7006", + "6C8E5746297C24B1080C03AF", + "7407B0C9B5E07D5492D98053", + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 9E5D451B8CEF1EF743CC2B97 = { + isa = "PBXSourcesBuildPhase"; + buildActionMask = 2147483647; + files = ( + "8299DBD0E93022AAEACE5F54", + "B70CFF8BCD5A858CCB5D600B", + "084FF2541A50122906582CAC", + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 8565DB4F80836EA220237CA0 = { + isa = "PBXSourcesBuildPhase"; + buildActionMask = 2147483647; + files = ( + "E357BFCCA00A4DD6EF1D0235", + "60BA73F3EE45C345CD0C593D", + "755E22C57BB293CDAD64CB41", + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C9B665B86623DA45589D3DD5 = { + isa = "PBXSourcesBuildPhase"; + buildActionMask = 2147483647; + files = ( + "556FE5F2A7285723350E4CAC", + "1C7B8B3BAEF7289AACBD231D", + "C3EEB3DED5B57200DA36F34E", + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 05343EBACA43E356190F5510 = { + isa = "PBXSourcesBuildPhase"; + buildActionMask = 2147483647; + files = ( + "359DDEBBC7AB6E4D48032723", + "E2CFAA905BF589905A69D32F", + "4096B185162EAE2C4738E1C1", + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 5E23CF762C8E87A5C4F07026 = { + isa = "PBXSourcesBuildPhase"; + buildActionMask = 2147483647; + files = ( + "5A652AAB3F21AD3EEE1DC9B1", + "72B403FFF4DA5BA1CEDD5717", + "C14B2CCCCE38CFD39751A8F3", + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -4365,6 +5389,46 @@ target = 22AD8717246EBFFA00F415DE /* a-Shell-Intents */; targetProxy = 22AD872E246EBFFD00F415DE /* PBXContainerItemProxy */; }; + 88F36AC8C5832103CAA7195E = { + isa = "PBXTargetDependency"; + target = "B80CAEC73DAE28A7664A2FE7"; + }; + C07B3F85C9AAEB628033EA61 = { + isa = "PBXTargetDependency"; + target = "EE8FF11D18D32F6F9601FA0D"; + }; + BB6767147D3C4FEA4C3C952B = { + isa = "PBXTargetDependency"; + target = "F1BC0042128131FA3BB63FE6"; + }; + 26F687795555A5D88B96304D = { + isa = "PBXTargetDependency"; + target = "B1BF9CA28196AF7D11ED15C1"; + }; + A635836C211C3AB5C85C00A7 = { + isa = "PBXTargetDependency"; + target = "CE532D2E94C62529D2EAC80C"; + }; + 0AAC31F15A0DCCDD78983016 = { + isa = "PBXTargetDependency"; + target = "E698830C007E12B930873C45"; + }; + 42442DC659794242C4C4F078 = { + isa = "PBXTargetDependency"; + target = "9C778EEDE9AFE25B17E919CD"; + }; + 31A01BF362BE8594C88A7461 = { + isa = "PBXTargetDependency"; + target = "BDF38991DBFB316DAA737CDC"; + }; + AD01960A41E12D8546D60344 = { + isa = "PBXTargetDependency"; + target = "980C38795331054C0AB04808"; + }; + E119072ED69DB93517DB130D = { + isa = "PBXTargetDependency"; + target = "A614E13CB5ADC97DAC1C52AC"; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -5033,6 +6097,186 @@ }; name = Release; }; + DC87439DFACFBC5E6720ADF1 = { + isa = "XCBuildConfiguration"; + baseConfigurationReference = "0893D186728BD261366E4BD1"; + buildSettings = { + PRODUCT_NAME = "ShellWorkerA"; + PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_PREFIX:default=AsheKube.app).a-Shell-mini.ShellWorkerA"; + }; + name = "Debug"; + }; + BA1F3E3B5D416DFE86D9EE20 = { + isa = "XCBuildConfiguration"; + baseConfigurationReference = "0893D186728BD261366E4BD1"; + buildSettings = { + PRODUCT_NAME = "ShellWorkerA"; + PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_PREFIX:default=AsheKube.app).a-Shell-mini.ShellWorkerA"; + }; + name = "Release"; + }; + 49EECCB6CADB39D8B8693B1A = { + isa = "XCBuildConfiguration"; + baseConfigurationReference = "0893D186728BD261366E4BD1"; + buildSettings = { + PRODUCT_NAME = "ShellWorkerB"; + PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_PREFIX:default=AsheKube.app).a-Shell-mini.ShellWorkerB"; + }; + name = "Debug"; + }; + 55B5C32D6ABB182F8415F3E8 = { + isa = "XCBuildConfiguration"; + baseConfigurationReference = "0893D186728BD261366E4BD1"; + buildSettings = { + PRODUCT_NAME = "ShellWorkerB"; + PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_PREFIX:default=AsheKube.app).a-Shell-mini.ShellWorkerB"; + }; + name = "Release"; + }; + 3D16C484D9FB67F9E4A967F1 = { + isa = "XCBuildConfiguration"; + baseConfigurationReference = "0893D186728BD261366E4BD1"; + buildSettings = { + PRODUCT_NAME = "ShellWorkerC"; + PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_PREFIX:default=AsheKube.app).a-Shell-mini.ShellWorkerC"; + }; + name = "Debug"; + }; + 389011DD426091393EAB6662 = { + isa = "XCBuildConfiguration"; + baseConfigurationReference = "0893D186728BD261366E4BD1"; + buildSettings = { + PRODUCT_NAME = "ShellWorkerC"; + PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_PREFIX:default=AsheKube.app).a-Shell-mini.ShellWorkerC"; + }; + name = "Release"; + }; + 88DB69305DE72BD245EEEF21 = { + isa = "XCBuildConfiguration"; + baseConfigurationReference = "0893D186728BD261366E4BD1"; + buildSettings = { + PRODUCT_NAME = "ShellWorkerD"; + PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_PREFIX:default=AsheKube.app).a-Shell-mini.ShellWorkerD"; + }; + name = "Debug"; + }; + EF65C555B0AF51766A1051B8 = { + isa = "XCBuildConfiguration"; + baseConfigurationReference = "0893D186728BD261366E4BD1"; + buildSettings = { + PRODUCT_NAME = "ShellWorkerD"; + PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_PREFIX:default=AsheKube.app).a-Shell-mini.ShellWorkerD"; + }; + name = "Release"; + }; + 201F025FCF49E73888B0B562 = { + isa = "XCBuildConfiguration"; + baseConfigurationReference = "0893D186728BD261366E4BD1"; + buildSettings = { + PRODUCT_NAME = "ShellWorkerE"; + PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_PREFIX:default=AsheKube.app).a-Shell-mini.ShellWorkerE"; + }; + name = "Debug"; + }; + 40CDA083E3E201CEEC579D2C = { + isa = "XCBuildConfiguration"; + baseConfigurationReference = "0893D186728BD261366E4BD1"; + buildSettings = { + PRODUCT_NAME = "ShellWorkerE"; + PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_PREFIX:default=AsheKube.app).a-Shell-mini.ShellWorkerE"; + }; + name = "Release"; + }; + 4B6D89CD4EFC7D905940B09E = { + isa = "XCBuildConfiguration"; + baseConfigurationReference = "0893D186728BD261366E4BD1"; + buildSettings = { + PRODUCT_NAME = "ShellWorkerF"; + PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_PREFIX:default=AsheKube.app).a-Shell-mini.ShellWorkerF"; + }; + name = "Debug"; + }; + D05AF64AC9567EE39EC70897 = { + isa = "XCBuildConfiguration"; + baseConfigurationReference = "0893D186728BD261366E4BD1"; + buildSettings = { + PRODUCT_NAME = "ShellWorkerF"; + PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_PREFIX:default=AsheKube.app).a-Shell-mini.ShellWorkerF"; + }; + name = "Release"; + }; + F9CACCCC7F5AA5543BB48468 = { + isa = "XCBuildConfiguration"; + baseConfigurationReference = "0893D186728BD261366E4BD1"; + buildSettings = { + PRODUCT_NAME = "ShellWorkerG"; + PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_PREFIX:default=AsheKube.app).a-Shell-mini.ShellWorkerG"; + }; + name = "Debug"; + }; + CB9596B242B3686F3D58FE8E = { + isa = "XCBuildConfiguration"; + baseConfigurationReference = "0893D186728BD261366E4BD1"; + buildSettings = { + PRODUCT_NAME = "ShellWorkerG"; + PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_PREFIX:default=AsheKube.app).a-Shell-mini.ShellWorkerG"; + }; + name = "Release"; + }; + 472F5990568AF73F0DDA5984 = { + isa = "XCBuildConfiguration"; + baseConfigurationReference = "0893D186728BD261366E4BD1"; + buildSettings = { + PRODUCT_NAME = "ShellWorkerH"; + PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_PREFIX:default=AsheKube.app).a-Shell-mini.ShellWorkerH"; + }; + name = "Debug"; + }; + AF505CCF3483810C2864282F = { + isa = "XCBuildConfiguration"; + baseConfigurationReference = "0893D186728BD261366E4BD1"; + buildSettings = { + PRODUCT_NAME = "ShellWorkerH"; + PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_PREFIX:default=AsheKube.app).a-Shell-mini.ShellWorkerH"; + }; + name = "Release"; + }; + 05813E7057E8747F469C45B5 = { + isa = "XCBuildConfiguration"; + baseConfigurationReference = "0893D186728BD261366E4BD1"; + buildSettings = { + PRODUCT_NAME = "ShellWorkerI"; + PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_PREFIX:default=AsheKube.app).a-Shell-mini.ShellWorkerI"; + }; + name = "Debug"; + }; + E408A607AE680275D025988F = { + isa = "XCBuildConfiguration"; + baseConfigurationReference = "0893D186728BD261366E4BD1"; + buildSettings = { + PRODUCT_NAME = "ShellWorkerI"; + PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_PREFIX:default=AsheKube.app).a-Shell-mini.ShellWorkerI"; + }; + name = "Release"; + }; + 5A3E39E37904499071CED2F3 = { + isa = "XCBuildConfiguration"; + baseConfigurationReference = "0893D186728BD261366E4BD1"; + buildSettings = { + PRODUCT_NAME = "ShellWorkerJ"; + PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_PREFIX:default=AsheKube.app).a-Shell-mini.ShellWorkerJ"; + }; + name = "Debug"; + }; + 1572AF9E6C2EFC03C506A102 = { + isa = "XCBuildConfiguration"; + baseConfigurationReference = "0893D186728BD261366E4BD1"; + buildSettings = { + PRODUCT_NAME = "ShellWorkerJ"; + PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_PREFIX:default=AsheKube.app).a-Shell-mini.ShellWorkerJ"; + }; + name = "Release"; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -5108,6 +6352,96 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + AF05FF73FF3462482114110C = { + isa = "XCConfigurationList"; + buildConfigurations = ( + "DC87439DFACFBC5E6720ADF1", + "BA1F3E3B5D416DFE86D9EE20", + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = "Release"; + }; + 7B74F35F903EA73A5F1CC5CA = { + isa = "XCConfigurationList"; + buildConfigurations = ( + "49EECCB6CADB39D8B8693B1A", + "55B5C32D6ABB182F8415F3E8", + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = "Release"; + }; + 4E8FD56F38A3D6CA40A44BBA = { + isa = "XCConfigurationList"; + buildConfigurations = ( + "3D16C484D9FB67F9E4A967F1", + "389011DD426091393EAB6662", + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = "Release"; + }; + C529F9BD8FC95FC69151E5D9 = { + isa = "XCConfigurationList"; + buildConfigurations = ( + "88DB69305DE72BD245EEEF21", + "EF65C555B0AF51766A1051B8", + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = "Release"; + }; + 357D1EE97A1181CB2C69AE7B = { + isa = "XCConfigurationList"; + buildConfigurations = ( + "201F025FCF49E73888B0B562", + "40CDA083E3E201CEEC579D2C", + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = "Release"; + }; + 63E3448BFC96684E006144BE = { + isa = "XCConfigurationList"; + buildConfigurations = ( + "4B6D89CD4EFC7D905940B09E", + "D05AF64AC9567EE39EC70897", + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = "Release"; + }; + 01298A57963C7D7FC8A5E943 = { + isa = "XCConfigurationList"; + buildConfigurations = ( + "F9CACCCC7F5AA5543BB48468", + "CB9596B242B3686F3D58FE8E", + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = "Release"; + }; + BFB21F5FBA41F45FB2CB4F2F = { + isa = "XCConfigurationList"; + buildConfigurations = ( + "472F5990568AF73F0DDA5984", + "AF505CCF3483810C2864282F", + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = "Release"; + }; + 5444CAFE71E28CDFEBA55CCD = { + isa = "XCConfigurationList"; + buildConfigurations = ( + "05813E7057E8747F469C45B5", + "E408A607AE680275D025988F", + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = "Release"; + }; + 9094C1580181846504A7959E = { + isa = "XCConfigurationList"; + buildConfigurations = ( + "5A3E39E37904499071CED2F3", + "1572AF9E6C2EFC03C506A102", + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = "Release"; + }; /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ diff --git a/tests/worker-pool.m b/tests/worker-pool.m new file mode 100644 index 00000000..64f32d16 --- /dev/null +++ b/tests/worker-pool.m @@ -0,0 +1,226 @@ +#import +#import +#include +#include +#include +#include +#include +#include + + +// Keep each command alive at its public stdin boundary while the other slots start. +static int capacity_check(void) { + int (*spawn)(const char *, const char *, unsigned, unsigned, const char *const *, int *, void **) = dlsym(RTLD_DEFAULT, "ashell_pane_spawn"); + int (*pollJob)(void *, int *) = dlsym(RTLD_DEFAULT, "ashell_pane_poll"); + void (*release)(void *) = dlsym(RTLD_DEFAULT, "ashell_pane_release"); + if (!spawn || !pollJob || !release) { fputs("worker pool unavailable: FAIL\n", thread_stdout); return 1; } + NSString *root = [NSHomeDirectory() stringByAppendingPathComponent:[@"Documents/worker-pool-" stringByAppendingString:NSUUID.UUID.UUIDString]]; + void *jobs[10] = {0}; + int descriptors[10]; + char output[10][2048] = {{0}}; + size_t used[10] = {0}; + int pids[10] = {0}; + BOOL passed = YES; + for (int i = 0; i < 10; i++) descriptors[i] = -1; + for (int i = 0; i < 10; i++) { + NSString *directory = [root stringByAppendingPathComponent:[NSString stringWithFormat:@"slot-%d", i]]; + if (![[NSFileManager defaultManager] createDirectoryAtPath:directory withIntermediateDirectories:YES attributes:nil error:nil]) { passed = NO; break; } + char value[32], command[256]; + snprintf(value, sizeof(value), "POOL_VALUE=slot-%d", i); + const char *environment[] = {value, NULL}; + snprintf(command, sizeof(command), "dash -c 'printf \"READY=%%s,%%s\\n\" \"$ASHELL_WORKER_PID\" \"$POOL_VALUE\"; pwd; read value; printf \"END=%%s\\n\" \"$value\"; exit %d'", i); + int error = spawn(command, directory.fileSystemRepresentation, 80, 24, environment, &descriptors[i], &jobs[i]); + if (error) { fprintf(thread_stdout, "slot=%d startup error=%d: FAIL\n", i, error); passed = NO; break; } + if (fcntl(descriptors[i], F_SETFL, O_NONBLOCK)) { passed = NO; break; } + } + NSTimeInterval deadline = NSProcessInfo.processInfo.systemUptime + 10; + BOOL allReady = NO; + while (passed && NSProcessInfo.processInfo.systemUptime < deadline) { + allReady = YES; + for (int i = 0; i < 10; i++) { + ssize_t count = read(descriptors[i], output[i] + used[i], sizeof(output[i]) - used[i] - 1); + if (count > 0) used[i] += count; + if (!strchr(output[i], '\n') || !strchr(strchr(output[i], '\n') + 1, '\n')) allReady = NO; + int status; + if (pollJob(jobs[i], &status) != 0) { passed = NO; break; } + } + if (allReady) break; + usleep(10000); + } + if (!allReady) passed = NO; + for (int i = 0; passed && i < 10; i++) { + char value[32]; + if (sscanf(output[i], "READY=%d,%31[^\n]", &pids[i], value) != 2 || pids[i] <= 0) { passed = NO; break; } + char expectedValue[32]; + snprintf(expectedValue, sizeof(expectedValue), "slot-%d", i); + NSString *expectedDirectory = [[root stringByAppendingPathComponent:[NSString stringWithFormat:@"slot-%d", i]] stringByAppendingString:@"\n"]; + if (strcmp(value, expectedValue) || strcmp(strchr(output[i], '\n') + 1, expectedDirectory.fileSystemRepresentation)) passed = NO; + for (int j = 0; j < i; j++) if (pids[i] == pids[j]) passed = NO; + fprintf(thread_stdout, "slot=%d real_pid=%d cwd/env=%s\n", i, pids[i], passed ? "PASS" : "FAIL"); + } + if (passed) { + int descriptor = -1; + void *job = NULL; + int error = spawn("dash -c 'exit 0'", root.fileSystemRepresentation, 80, 24, NULL, &descriptor, &job); + BOOL full = error == ENOSPC && descriptor == -1 && job == NULL; + fprintf(thread_stdout, "eleventh worker error=%d: %s\n", error, full ? "PASS" : "FAIL"); + if (job) release(job); + if (descriptor >= 0) close(descriptor); + passed = full; + } + // The same open sessions must still accept input after more than five seconds. + if (passed) { + sleep(6); + for (int i = 0; i < 10; i++) if (write(descriptors[i], "continue\n", 9) != 9) passed = NO; + for (int i = 0; i < 10; i++) { + BOOL exited = NO, eof = NO; + int status = -1; + deadline = NSProcessInfo.processInfo.systemUptime + 10; + while (NSProcessInfo.processInfo.systemUptime < deadline && (!exited || !eof)) { + ssize_t count = read(descriptors[i], output[i] + used[i], sizeof(output[i]) - used[i] - 1); + if (count > 0) used[i] += count; + else if (count == 0) eof = YES; + int result = pollJob(jobs[i], &status); + if (result == 1) exited = YES; + else if (result < 0) break; + if (!exited || !eof) usleep(10000); + } + BOOL completed = exited && eof && status == i && strstr(output[i], "END=continue\n"); + fprintf(thread_stdout, "slot=%d delayed input/status=%d: %s\n", i, status, completed ? "PASS" : "FAIL"); + if (!completed) passed = NO; + } + } + for (int i = 0; i < 10; i++) { + if (jobs[i]) release(jobs[i]); + if (descriptors[i] >= 0) close(descriptors[i]); + } + [[NSFileManager defaultManager] removeItemAtPath:root error:nil]; + fprintf(thread_stdout, "ten-worker capacity: %s\n", passed ? "PASS" : "FAIL"); + return passed ? 0 : 1; +} + + +static int lifecycle_check(BOOL busy, BOOL closeOnly) { + int (*spawn)(const char *, const char *, unsigned, unsigned, const char *const *, int *, void **) = dlsym(RTLD_DEFAULT, "ashell_pane_spawn"); + int (*pollJob)(void *, int *) = dlsym(RTLD_DEFAULT, "ashell_pane_poll"); + int (*cancel)(void *) = dlsym(RTLD_DEFAULT, "ashell_pane_cancel"); + void (*release)(void *) = dlsym(RTLD_DEFAULT, "ashell_pane_release"); + if (!spawn || !pollJob || !cancel || !release) { fputs("worker pool unavailable: FAIL\n", thread_stdout); return 1; } + NSString *directory = [NSHomeDirectory() stringByAppendingPathComponent:@"Documents"]; + void *jobs[3] = {0}; + int descriptors[3] = {-1, -1, -1}, pids[3] = {0}; + BOOL passed = YES; + for (int i = 0; i < 3; i++) { + const char *command = busy && i == 0 ? "dash -c 'printf \"READY=%s\\n\" \"$ASHELL_WORKER_PID\"; while :; do :; done'" : "dash -c 'printf \"READY=%s\\n\" \"$ASHELL_WORKER_PID\"; read value; printf \"DONE=%s\\n\" \"$value\"; exit 7'"; + int error = spawn(command, directory.fileSystemRepresentation, 80, 24, NULL, &descriptors[i], &jobs[i]); + if (error) { fprintf(thread_stdout, "lifecycle slot=%d startup=%d: FAIL\n", i, error); passed = NO; break; } + struct pollfd ready = {.fd = descriptors[i], .events = POLLIN}; + char output[128] = {0}; + size_t used = 0; + NSTimeInterval deadline = NSProcessInfo.processInfo.systemUptime + 5; + while (!strchr(output, '\n') && used < sizeof(output) - 1 && NSProcessInfo.processInfo.systemUptime < deadline) { + if (poll(&ready, 1, 100) > 0) { + ssize_t count = read(descriptors[i], output + used, sizeof(output) - used - 1); + if (count > 0) used += count; + else break; + } + } + if (sscanf(output, "READY=%d", &pids[i]) != 1 || pids[i] <= 0 || pids[i] == getpid()) { passed = NO; break; } + for (int j = 0; j < i; j++) if (pids[i] == pids[j]) passed = NO; + } + if (passed) { + int status = -1; + BOOL cancelled = YES; + if (!closeOnly) cancelled = cancel(jobs[0]) == 0 && pollJob(jobs[0], &status) == 1 && status == 137; + release(jobs[0]); jobs[0] = NULL; + fprintf(thread_stdout, "%s one worker: %s\n", closeOnly ? "close" : "cancel", cancelled ? "PASS" : "FAIL"); + passed = cancelled; + close(descriptors[0]); descriptors[0] = -1; + } + if (passed) { + // Inject OS process loss only after that process identified itself through + // its public output and was proved distinct from the consumer and peers. + BOOL lost = kill(pids[1], SIGKILL) == 0; + int status = -1, result = 0; + NSTimeInterval deadline = NSProcessInfo.processInfo.systemUptime + 5; + while (lost && NSProcessInfo.processInfo.systemUptime < deadline) { + result = pollJob(jobs[1], &status); + if (result) break; + usleep(10000); + } + lost = lost && result == 1 && status == 137; + fprintf(thread_stdout, "lost worker status=%d: %s\n", status, lost ? "PASS" : "FAIL"); + passed = lost; + release(jobs[1]); jobs[1] = NULL; + close(descriptors[1]); descriptors[1] = -1; + } + if (passed) { + int status = -1; + BOOL alive = pollJob(jobs[2], &status) == 0 && write(descriptors[2], "survivor\n", 9) == 9; + char output[128] = {0}; size_t used = 0; + BOOL exited = NO, eof = NO; + fcntl(descriptors[2], F_SETFL, O_NONBLOCK); + NSTimeInterval deadline = NSProcessInfo.processInfo.systemUptime + 5; + while (alive && NSProcessInfo.processInfo.systemUptime < deadline && (!exited || !eof)) { + ssize_t count = read(descriptors[2], output + used, sizeof(output) - used - 1); + if (count > 0) used += count; + else if (count == 0) eof = YES; + int result = pollJob(jobs[2], &status); + if (result == 1) exited = YES; + else if (result < 0) break; + if (!exited || !eof) usleep(10000); + } + alive = alive && exited && eof && status == 7 && !strcmp(output, "DONE=survivor\n"); + fprintf(thread_stdout, "peer input/output/status after loss: %s\n", alive ? "PASS" : "FAIL"); + passed = alive; + } + for (int i = 0; i < 3; i++) { if (jobs[i]) release(jobs[i]); if (descriptors[i] >= 0) close(descriptors[i]); } + // Re-enter the full public capacity check after asynchronous teardown. + if (passed) passed = capacity_check() == 0; + if (!passed && busy && pids[0] > 0 && pids[0] != getpid()) kill(pids[0], SIGKILL); + fprintf(thread_stdout, "worker lifecycle and full capacity reuse: %s\n", passed ? "PASS" : "FAIL"); + return passed ? 0 : 1; +} + +// Run as a real app command. The same consumer must fail on the stock app. +int worker_pool_check_main(int argc, char **argv) { + @autoreleasepool { + if (argc == 2 && !strcmp(argv[1], "capacity")) return capacity_check(); + if (argc == 2 && !strcmp(argv[1], "lifecycle")) return lifecycle_check(NO, NO); + if (argc == 2 && !strcmp(argv[1], "lifecycle-busy")) return lifecycle_check(YES, NO); + if (argc == 2 && !strcmp(argv[1], "lifecycle-close")) return lifecycle_check(YES, YES); + int (*spawn)(const char *, const char *, unsigned, unsigned, const char *const *, int *, void **) = dlsym(RTLD_DEFAULT, "ashell_pane_spawn"); + int (*pollJob)(void *, int *) = dlsym(RTLD_DEFAULT, "ashell_pane_poll"); + void (*release)(void *) = dlsym(RTLD_DEFAULT, "ashell_pane_release"); + if (!spawn || !pollJob || !release) { + fputs("worker pool unavailable: FAIL\n", thread_stdout); + return 1; + } + NSString *directory = [NSHomeDirectory() stringByAppendingPathComponent:@"Documents"]; + const char *environment[] = {"POOL_VALUE=isolated", NULL}; + int descriptor = -1; + void *job = NULL; + int error = spawn("dash -c 'printf \"WORKER=%s\\n\" \"$POOL_VALUE\"'", directory.fileSystemRepresentation, 80, 24, environment, &descriptor, &job); + if (error) { fprintf(thread_stdout, "worker startup error=%d: FAIL\n", error); return 1; } + char output[256] = {0}; + size_t used = 0; + BOOL completed = NO; + int status = -1; + NSTimeInterval deadline = NSProcessInfo.processInfo.systemUptime + 20; + while (NSProcessInfo.processInfo.systemUptime < deadline) { + struct pollfd ready = {.fd = descriptor, .events = POLLIN}; + if (poll(&ready, 1, 100) > 0 && used < sizeof(output) - 1) { + ssize_t count = read(descriptor, output + used, sizeof(output) - used - 1); + if (count > 0) used += count; + } + int result = pollJob(job, &status); + if (result < 0) break; + if (result == 1) { completed = YES; break; } + } + release(job); + close(descriptor); + BOOL passed = completed && status == 0 && !strcmp(output, "WORKER=isolated\n"); + fprintf(thread_stdout, "worker stdout=%zu status=%d: %s\n", used, status, passed ? "PASS" : "FAIL"); + return passed ? 0 : 1; + } +} diff --git a/tests/worker-pool.md b/tests/worker-pool.md new file mode 100644 index 00000000..a362535d --- /dev/null +++ b/tests/worker-pool.md @@ -0,0 +1,19 @@ +# Extension worker pool consumer + +`worker-pool.m` is an app command that calls the public C interface in `ShellWorkers/NativePane.h` through `dlsym`. It uses real extensions, XPC, socket streams and Dash. It does not mock the pool or add a production test entry point. + +In a disposable a-Shell mini build, add this source to the host target and register `replaceCommand("worker-pool-check", "worker_pool_check_main", true)` after `initializeEnvironment()` in `AppDelegate.swift`. Build the host with the matched ios_system#179–#184 and dash_iOS#1–#2 frameworks and libedit patch. Run these commands in the app terminal: + +```sh +worker-pool-check +worker-pool-check capacity +worker-pool-check lifecycle +worker-pool-check lifecycle-busy +worker-pool-check lifecycle-close +``` + +Each command returns zero on success and prints its assertions to the command output stream. The capacity check keeps ten commands open, requires ten distinct real process IDs and independent cwd/environment, rejects the eleventh request with `ENOSPC`, then sends input after six seconds and checks exact exit statuses and output EOF. The lifecycle checks cancel or close one worker, inject `SIGKILL` into another worker that identified itself through stdout, check the surviving worker's input/output/status, and repeat the full capacity check. The busy variants make the first command ignore EOF. Run them only in a disposable build: they deliberately terminate worker processes belonging to that build. + +For a baseline, register the same command in unmodified a-Shell mini. It must print `worker pool unavailable: FAIL` and return one. The final stock baseline and patched app were built with byte-identical consumer source (SHA-256 `6e9dd2d1bc22548c5932dbc173983a51bd423e0883bb9632bc63849532d35967`). The stock baseline returned the missing-API failure for `lifecycle-close`; the patched app completed the lifecycle and ten-slot checks. + +Verified on iOS 27 Simulator: the stock baseline failed at missing pool API; the patched app passed the single-command and ten-worker checks. A first busy-loop lifecycle run failed with `EBUSY` after invalidation; explicit worker termination made that same regression pass. Simulator does not qualify physical-device sandbox enforcement, memory limits, ten-worker device capacity, background lifetime or App Store acceptance. Keep those checks separate. From 38d15cef5a7d8c9292fac2f298e5624b60e42e4e Mon Sep 17 00:00:00 2001 From: demrom <47463752+demrom@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:37:11 +0300 Subject: [PATCH 02/16] Export worker pane callbacks in Release builds --- ShellWorkers/NativePane.h | 5 +++++ ShellWorkers/NativePane.m | 8 +++---- ShellWorkers/Worker.m | 8 +++---- tests/worker-pool.m | 46 +++++++++++++++++++++++++-------------- tests/worker-pool.md | 14 +++++++----- 5 files changed, 52 insertions(+), 29 deletions(-) diff --git a/ShellWorkers/NativePane.h b/ShellWorkers/NativePane.h index 0249cf84..34f4294e 100644 --- a/ShellWorkers/NativePane.h +++ b/ShellWorkers/NativePane.h @@ -5,6 +5,9 @@ extern "C" { #endif +// Keep the public C ABI discoverable in Release builds with hidden visibility. +#pragma GCC visibility push(default) + // command/cwd must be nonempty UTF-8; cwd must be an absolute accessible directory. // envp is NULL or a NULL-terminated list of UTF-8 key=value overrides, copied before return. // Dimensions are 1..9999. Returns 0 or positive errno; failure sets outputs to -1/NULL. @@ -39,6 +42,8 @@ int ashell_pane_interrupt(void *job); // The handle is opaque and is NOT an OS pid: never pass it to kill/waitpid/process APIs. void ashell_pane_release(void *job); +#pragma GCC visibility pop + #ifdef __cplusplus } #endif diff --git a/ShellWorkers/NativePane.m b/ShellWorkers/NativePane.m index 29db5b84..f48a8411 100644 --- a/ShellWorkers/NativePane.m +++ b/ShellWorkers/NativePane.m @@ -48,7 +48,7 @@ // which can be a shell blocked in ios_waitpid rather than the running command. static _Atomic int foreground_command_pid; -__attribute__((used)) int ashell_pane_exchange_foreground_pid(int pid) { +__attribute__((used, visibility("default"))) int ashell_pane_exchange_foreground_pid(int pid) { return atomic_exchange(&foreground_command_pid, pid); } @@ -347,7 +347,7 @@ static int prepare_input_relay(pane_job *job) { // Wait on the editor's real input and resize event without changing process // signal handlers or inserting synthetic keys into the command stream. // Return 0 for an unrelated reader, 1 for input, 2 for resize, or -errno. -__attribute__((used)) int ashell_pane_wait_editor_input(FILE *input) { +__attribute__((used, visibility("default"))) int ashell_pane_wait_editor_input(FILE *input) { pane_job *job = atomic_load(&foreground_pane); if (!job || !job->input_relay || input != job->streams[0]) return 0; struct kevent event; @@ -360,7 +360,7 @@ static int prepare_input_relay(pane_job *job) { return event.filter == EVFILT_USER ? 2 : 1; } -__attribute__((used)) int ashell_pane_begin_foreground_input(FILE **input, void **token) { +__attribute__((used, visibility("default"))) int ashell_pane_begin_foreground_input(FILE **input, void **token) { if (!input || !token || *token) return EINVAL; pane_job *job = atomic_load(&foreground_pane); if (!job || !job->input_relay || *input != job->streams[0]) return 0; @@ -416,7 +416,7 @@ static int prepare_input_relay(pane_job *job) { return 0; } -__attribute__((used)) int ashell_pane_end_foreground_input(void *token) { +__attribute__((used, visibility("default"))) int ashell_pane_end_foreground_input(void *token) { command_input *command = token; if (!command) return EINVAL; int cancellation; diff --git a/ShellWorkers/Worker.m b/ShellWorkers/Worker.m index e215311c..c8bb984a 100644 --- a/ShellWorkers/Worker.m +++ b/ShellWorkers/Worker.m @@ -12,13 +12,13 @@ // ios_system discovers these stock host callbacks with dlsym. Each extension // owns one shell; its socket already carries input directly to the command. static atomic_bool interactive_input; -__attribute__((used)) void startInteractive(void) { atomic_store(&interactive_input, true); } -__attribute__((used)) void stopInteractive(void) { atomic_store(&interactive_input, false); } -__attribute__((used)) int storeInteractive(void) { return atomic_load(&interactive_input); } +__attribute__((used, visibility("default"))) void startInteractive(void) { atomic_store(&interactive_input, true); } +__attribute__((used, visibility("default"))) void stopInteractive(void) { atomic_store(&interactive_input, false); } +__attribute__((used, visibility("default"))) int storeInteractive(void) { return atomic_load(&interactive_input); } // The stock host clear command calls its UIKit delegate. Workers clear through // the terminal stream, just like an external terminal command. -__attribute__((used)) int clear(int argc, char **argv) { +__attribute__((used, visibility("default"))) int clear(int argc, char **argv) { if (!thread_stdout) return 1; return fputs("\033[H\033[2J", thread_stdout) == EOF || fflush(thread_stdout) == EOF ? 1 : 0; } diff --git a/tests/worker-pool.m b/tests/worker-pool.m index 64f32d16..2a4a9ddc 100644 --- a/tests/worker-pool.m +++ b/tests/worker-pool.m @@ -6,6 +6,7 @@ #include #include #include +#include // Keep each command alive at its public stdin boundary while the other slots start. @@ -15,14 +16,16 @@ static int capacity_check(void) { void (*release)(void *) = dlsym(RTLD_DEFAULT, "ashell_pane_release"); if (!spawn || !pollJob || !release) { fputs("worker pool unavailable: FAIL\n", thread_stdout); return 1; } NSString *root = [NSHomeDirectory() stringByAppendingPathComponent:[@"Documents/worker-pool-" stringByAppendingString:NSUUID.UUID.UUIDString]]; + const int capacity = [[NSBundle.mainBundle objectForInfoDictionaryKey:@"ShellWorkerCount"] intValue]; + if (capacity < 1 || capacity > 10) return 64; void *jobs[10] = {0}; int descriptors[10]; char output[10][2048] = {{0}}; size_t used[10] = {0}; int pids[10] = {0}; BOOL passed = YES; - for (int i = 0; i < 10; i++) descriptors[i] = -1; - for (int i = 0; i < 10; i++) { + for (int i = 0; i < capacity; i++) descriptors[i] = -1; + for (int i = 0; i < capacity; i++) { NSString *directory = [root stringByAppendingPathComponent:[NSString stringWithFormat:@"slot-%d", i]]; if (![[NSFileManager defaultManager] createDirectoryAtPath:directory withIntermediateDirectories:YES attributes:nil error:nil]) { passed = NO; break; } char value[32], command[256]; @@ -37,7 +40,7 @@ static int capacity_check(void) { BOOL allReady = NO; while (passed && NSProcessInfo.processInfo.systemUptime < deadline) { allReady = YES; - for (int i = 0; i < 10; i++) { + for (int i = 0; i < capacity; i++) { ssize_t count = read(descriptors[i], output[i] + used[i], sizeof(output[i]) - used[i] - 1); if (count > 0) used[i] += count; if (!strchr(output[i], '\n') || !strchr(strchr(output[i], '\n') + 1, '\n')) allReady = NO; @@ -48,13 +51,19 @@ static int capacity_check(void) { usleep(10000); } if (!allReady) passed = NO; - for (int i = 0; passed && i < 10; i++) { + for (int i = 0; passed && i < capacity; i++) { char value[32]; if (sscanf(output[i], "READY=%d,%31[^\n]", &pids[i], value) != 2 || pids[i] <= 0) { passed = NO; break; } char expectedValue[32]; snprintf(expectedValue, sizeof(expectedValue), "slot-%d", i); - NSString *expectedDirectory = [[root stringByAppendingPathComponent:[NSString stringWithFormat:@"slot-%d", i]] stringByAppendingString:@"\n"]; - if (strcmp(value, expectedValue) || strcmp(strchr(output[i], '\n') + 1, expectedDirectory.fileSystemRepresentation)) passed = NO; + NSString *expectedDirectory = [root stringByAppendingPathComponent:[NSString stringWithFormat:@"slot-%d", i]]; + NSString *observedDirectory = [[NSString stringWithUTF8String:strchr(output[i], '\n') + 1] stringByTrimmingCharactersInSet:NSCharacterSet.newlineCharacterSet]; + struct stat expectedStat, observedStat; + BOOL sameDirectory = stat(expectedDirectory.fileSystemRepresentation, &expectedStat) == 0 && stat(observedDirectory.fileSystemRepresentation, &observedStat) == 0 && expectedStat.st_dev == observedStat.st_dev && expectedStat.st_ino == observedStat.st_ino; + if (strcmp(value, expectedValue) || !sameDirectory) { + fprintf(thread_stdout, "expected cwd=%s\nactual=%s", expectedDirectory.fileSystemRepresentation, output[i]); + passed = NO; + } for (int j = 0; j < i; j++) if (pids[i] == pids[j]) passed = NO; fprintf(thread_stdout, "slot=%d real_pid=%d cwd/env=%s\n", i, pids[i], passed ? "PASS" : "FAIL"); } @@ -63,7 +72,7 @@ static int capacity_check(void) { void *job = NULL; int error = spawn("dash -c 'exit 0'", root.fileSystemRepresentation, 80, 24, NULL, &descriptor, &job); BOOL full = error == ENOSPC && descriptor == -1 && job == NULL; - fprintf(thread_stdout, "eleventh worker error=%d: %s\n", error, full ? "PASS" : "FAIL"); + fprintf(thread_stdout, "worker=%d error=%d: %s\n", capacity + 1, error, full ? "PASS" : "FAIL"); if (job) release(job); if (descriptor >= 0) close(descriptor); passed = full; @@ -71,8 +80,8 @@ static int capacity_check(void) { // The same open sessions must still accept input after more than five seconds. if (passed) { sleep(6); - for (int i = 0; i < 10; i++) if (write(descriptors[i], "continue\n", 9) != 9) passed = NO; - for (int i = 0; i < 10; i++) { + for (int i = 0; i < capacity; i++) if (write(descriptors[i], "continue\n", 9) != 9) passed = NO; + for (int i = 0; i < capacity; i++) { BOOL exited = NO, eof = NO; int status = -1; deadline = NSProcessInfo.processInfo.systemUptime + 10; @@ -90,12 +99,12 @@ static int capacity_check(void) { if (!completed) passed = NO; } } - for (int i = 0; i < 10; i++) { + for (int i = 0; i < capacity; i++) { if (jobs[i]) release(jobs[i]); if (descriptors[i] >= 0) close(descriptors[i]); } [[NSFileManager defaultManager] removeItemAtPath:root error:nil]; - fprintf(thread_stdout, "ten-worker capacity: %s\n", passed ? "PASS" : "FAIL"); + fprintf(thread_stdout, "workers=%d capacity: %s\n", capacity, passed ? "PASS" : "FAIL"); return passed ? 0 : 1; } @@ -111,7 +120,7 @@ static int lifecycle_check(BOOL busy, BOOL closeOnly) { int descriptors[3] = {-1, -1, -1}, pids[3] = {0}; BOOL passed = YES; for (int i = 0; i < 3; i++) { - const char *command = busy && i == 0 ? "dash -c 'printf \"READY=%s\\n\" \"$ASHELL_WORKER_PID\"; while :; do :; done'" : "dash -c 'printf \"READY=%s\\n\" \"$ASHELL_WORKER_PID\"; read value; printf \"DONE=%s\\n\" \"$value\"; exit 7'"; + const char *command = i == 1 ? "dash -c 'printf \"READY=%s\\n\" \"$ASHELL_WORKER_PID\"; read value; worker-pool-crash'" : busy && i == 0 ? "dash -c 'printf \"READY=%s\\n\" \"$ASHELL_WORKER_PID\"; while :; do :; done'" : "dash -c 'printf \"READY=%s\\n\" \"$ASHELL_WORKER_PID\"; read value; printf \"DONE=%s\\n\" \"$value\"; exit 7'"; int error = spawn(command, directory.fileSystemRepresentation, 80, 24, NULL, &descriptors[i], &jobs[i]); if (error) { fprintf(thread_stdout, "lifecycle slot=%d startup=%d: FAIL\n", i, error); passed = NO; break; } struct pollfd ready = {.fd = descriptors[i], .events = POLLIN}; @@ -138,9 +147,9 @@ static int lifecycle_check(BOOL busy, BOOL closeOnly) { close(descriptors[0]); descriptors[0] = -1; } if (passed) { - // Inject OS process loss only after that process identified itself through - // its public output and was proved distinct from the consumer and peers. - BOOL lost = kill(pids[1], SIGKILL) == 0; + // Ask the disposable crash command to terminate its own worker process. + // iOS denies a host process permission to signal an extension directly. + BOOL lost = write(descriptors[1], "crash\n", 6) == 6; int status = -1, result = 0; NSTimeInterval deadline = NSProcessInfo.processInfo.systemUptime + 5; while (lost && NSProcessInfo.processInfo.systemUptime < deadline) { @@ -177,7 +186,6 @@ static int lifecycle_check(BOOL busy, BOOL closeOnly) { for (int i = 0; i < 3; i++) { if (jobs[i]) release(jobs[i]); if (descriptors[i] >= 0) close(descriptors[i]); } // Re-enter the full public capacity check after asynchronous teardown. if (passed) passed = capacity_check() == 0; - if (!passed && busy && pids[0] > 0 && pids[0] != getpid()) kill(pids[0], SIGKILL); fprintf(thread_stdout, "worker lifecycle and full capacity reuse: %s\n", passed ? "PASS" : "FAIL"); return passed ? 0 : 1; } @@ -224,3 +232,9 @@ int worker_pool_check_main(int argc, char **argv) { return passed ? 0 : 1; } } + +// Register only in disposable worker command catalogues for loss acceptance. +int worker_pool_crash_main(int argc, char **argv) { + kill(getpid(), SIGKILL); + return 1; +} diff --git a/tests/worker-pool.md b/tests/worker-pool.md index a362535d..b14ddb2c 100644 --- a/tests/worker-pool.md +++ b/tests/worker-pool.md @@ -1,8 +1,10 @@ # Extension worker pool consumer -`worker-pool.m` is an app command that calls the public C interface in `ShellWorkers/NativePane.h` through `dlsym`. It uses real extensions, XPC, socket streams and Dash. It does not mock the pool or add a production test entry point. +`worker-pool.m` calls the public C interface in `ShellWorkers/NativePane.h` through `dlsym`. It uses real extensions, XPC, socket streams and Dash. -In a disposable a-Shell mini build, add this source to the host target and register `replaceCommand("worker-pool-check", "worker_pool_check_main", true)` after `initializeEnvironment()` in `AppDelegate.swift`. Build the host with the matched ios_system#179–#184 and dash_iOS#1–#2 frameworks and libedit patch. Run these commands in the app terminal: +For a disposable app, compile this source into a dynamic framework named `WorkerPoolChecks.framework` linked with Foundation and ios_system. In the host command catalogue register `worker-pool-check` as `["WorkerPoolChecks.framework/WorkerPoolChecks", "worker_pool_check_main", "", "file"]`. In each worker catalogue register `worker-pool-crash` as `["@rpath/WorkerPoolChecks.framework/WorkerPoolChecks", "worker_pool_crash_main", "", "file"]` and add its executable PATH marker in the worker's `bin` directory. Embed the framework in the host's Frameworks directory and sign it before signing the extensions and app. These registrations belong only to the disposable test build. No production test entry point is needed. + +Use the matching ios_system session/stream and Dash terminal changes, then run these commands in the app terminal: ```sh worker-pool-check @@ -12,8 +14,10 @@ worker-pool-check lifecycle-busy worker-pool-check lifecycle-close ``` -Each command returns zero on success and prints its assertions to the command output stream. The capacity check keeps ten commands open, requires ten distinct real process IDs and independent cwd/environment, rejects the eleventh request with `ENOSPC`, then sends input after six seconds and checks exact exit statuses and output EOF. The lifecycle checks cancel or close one worker, inject `SIGKILL` into another worker that identified itself through stdout, check the surviving worker's input/output/status, and repeat the full capacity check. The busy variants make the first command ignore EOF. Run them only in a disposable build: they deliberately terminate worker processes belonging to that build. +Each command returns zero on success and prints assertions to its output stream. The capacity check reads `ShellWorkerCount` from the host Info.plist (1 through 10), keeps that many commands open, requires distinct real process IDs and independent cwd/environment, rejects one additional request with `ENOSPC`, then sends input after six seconds and checks exact exit statuses and output EOF. It compares directory device/inode pairs because the phone can return `/private/var/...` for a cwd requested through its `/var/...` alias. + +The lifecycle checks cancel or close one worker, ask a second worker to terminate itself with `SIGKILL`, verify the surviving worker's input/output/status, and repeat the full capacity check. The busy variants make the first command ignore EOF. iOS denies host-to-extension `kill` with `EPERM`, so process loss is injected by the normal disposable command inside the worker. Run these checks only in a disposable build: they deliberately terminate its worker processes. -For a baseline, register the same command in unmodified a-Shell mini. It must print `worker pool unavailable: FAIL` and return one. The final stock baseline and patched app were built with byte-identical consumer source (SHA-256 `6e9dd2d1bc22548c5932dbc173983a51bd423e0883bb9632bc63849532d35967`). The stock baseline returned the missing-API failure for `lifecycle-close`; the patched app completed the lifecycle and ten-slot checks. +Register the same consumer in an unmodified host for the baseline. It must print `worker pool unavailable: FAIL` and return one. A Release build must export the public C functions and callbacks loaded through `dlsym`; a consumer compiled into an executable with hidden symbols does not prove that interface usable by a dynamic command. -Verified on iOS 27 Simulator: the stock baseline failed at missing pool API; the patched app passed the single-command and ten-worker checks. A first busy-loop lifecycle run failed with `EBUSY` after invalidation; explicit worker termination made that same regression pass. Simulator does not qualify physical-device sandbox enforcement, memory limits, ten-worker device capacity, background lifetime or App Store acceptance. Keep those checks separate. +The upstream configuration remains ten workers. Historical iOS 27 Simulator tests passed ten-slot capacity and lifecycle checks. A physical run with three provisioned identities qualifies three-worker behavior only; it does not qualify ten-worker device capacity, background lifetime or App Store acceptance. From 6ad68a7605253458281947361228fea309c85c22 Mon Sep 17 00:00:00 2001 From: demrom <47463752+demrom@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:18:51 +0300 Subject: [PATCH 03/16] Avoid status RPCs while shell workers are running --- ShellWorkers/RemotePane.m | 12 ++++++++++++ tests/worker-pool.m | 30 ++++++++++++++++++++++++++++++ tests/worker-pool.md | 3 +++ 3 files changed, 45 insertions(+) diff --git a/ShellWorkers/RemotePane.m b/ShellWorkers/RemotePane.m index 09929eb1..a60cff2a 100644 --- a/ShellWorkers/RemotePane.m +++ b/ShellWorkers/RemotePane.m @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -22,12 +23,14 @@ static void log_host_library(const char *phase) { @interface RemotePane : NSObject @property(nonatomic, strong) NSXPCConnection *connection; @property(nonatomic, strong) id proxy; +@property(nonatomic, strong) NSFileHandle *output; @property(atomic) BOOL interrupted; @property(nonatomic, strong) NSMutableArray *directories; @property(nonatomic, copy) NSString *probeDirectory; @end @implementation RemotePane - (void)dealloc { + [_output closeAndReturnError:NULL]; // Only remove our empty disposable directory, never a project tree. if (_probeDirectory) rmdir(_probeDirectory.fileSystemRepresentation); log_host_library("before_scope_release"); @@ -116,6 +119,7 @@ int ashell_pane_spawn(const char *command, const char *cwd, unsigned cols, unsig fd = dup(handle.fileDescriptor); if (fd < 0) error = errno; else if (fcntl(fd, F_SETFD, FD_CLOEXEC)) { error = errno; close(fd); fd = -1; } + else pane.output = handle; } NSLog(@"Shell extension spawn: real_pid=%d error=%d startup_ms=%.3f", pid, error, (NSProcessInfo.processInfo.systemUptime - started) * 1000); }]; @@ -132,6 +136,14 @@ int ashell_pane_poll(void *job, int *status_out) { if (!pane || !status_out) return -EINVAL; // 137 is this backend's lost-worker status, not a recovered waitpid status. if (pane.interrupted) { *status_out = 137; return 1; } + // The worker closes its socket streams before publishing completion. A + // hangup is observable even with unread output; keep polling local until + // then instead of waking the worker with an XPC request on every poll. + struct pollfd output = {.fd = pane.output.fileDescriptor, .events = POLLIN}; + int ready = poll(&output, 1, 0); + if (ready < 0) return errno == EINTR ? 0 : -errno; + if (output.revents & POLLNVAL) return -EBADF; + if (!(output.revents & (POLLHUP | POLLERR))) return 0; __block int result = -EPIPE; [pane.proxy poll:^(int value, int status) { result = value; *status_out = status; }]; if (result == -EPIPE) { pane.interrupted = YES; *status_out = 137; return 1; } diff --git a/tests/worker-pool.m b/tests/worker-pool.m index 2a4a9ddc..f2a5f429 100644 --- a/tests/worker-pool.m +++ b/tests/worker-pool.m @@ -190,9 +190,39 @@ static int lifecycle_check(BOOL busy, BOOL closeOnly) { return passed ? 0 : 1; } +// Exit status must not depend on the consumer draining pending output first. +static int completion_check(void) { + int (*spawn)(const char *, const char *, unsigned, unsigned, const char *const *, int *, void **) = dlsym(RTLD_DEFAULT, "ashell_pane_spawn"); + int (*pollJob)(void *, int *) = dlsym(RTLD_DEFAULT, "ashell_pane_poll"); + void (*release)(void *) = dlsym(RTLD_DEFAULT, "ashell_pane_release"); + if (!spawn || !pollJob || !release) return 1; + NSString *directory = [NSHomeDirectory() stringByAppendingPathComponent:@"Documents"]; + int descriptor = -1, status = -1, result = 0; + void *job = NULL; + int error = spawn("dash -c 'printf pending-output; exit 7'", directory.fileSystemRepresentation, 80, 24, NULL, &descriptor, &job); + if (error) { fprintf(thread_stdout, "completion startup=%d: FAIL\n", error); return 1; } + NSTimeInterval deadline = NSProcessInfo.processInfo.systemUptime + 10; + while (!result && NSProcessInfo.processInfo.systemUptime < deadline) { + result = pollJob(job, &status); + if (!result) usleep(10000); + } + BOOL passed = result == 1 && status == 7; + status = -1; + passed = passed && pollJob(job, &status) == 1 && status == 7; + char output[64] = {0}; + ssize_t count = passed ? read(descriptor, output, sizeof(output) - 1) : -1; + passed = passed && count == 14 && !strcmp(output, "pending-output"); + if (passed) passed = read(descriptor, output, 1) == 0; + release(job); + close(descriptor); + fprintf(thread_stdout, "status before output drain and repeated status: %s\n", passed ? "PASS" : "FAIL"); + return passed ? 0 : 1; +} + // Run as a real app command. The same consumer must fail on the stock app. int worker_pool_check_main(int argc, char **argv) { @autoreleasepool { + if (argc == 2 && !strcmp(argv[1], "completion")) return completion_check(); if (argc == 2 && !strcmp(argv[1], "capacity")) return capacity_check(); if (argc == 2 && !strcmp(argv[1], "lifecycle")) return lifecycle_check(NO, NO); if (argc == 2 && !strcmp(argv[1], "lifecycle-busy")) return lifecycle_check(YES, NO); diff --git a/tests/worker-pool.md b/tests/worker-pool.md index b14ddb2c..70d22e1b 100644 --- a/tests/worker-pool.md +++ b/tests/worker-pool.md @@ -8,6 +8,7 @@ Use the matching ios_system session/stream and Dash terminal changes, then run t ```sh worker-pool-check +worker-pool-check completion worker-pool-check capacity worker-pool-check lifecycle worker-pool-check lifecycle-busy @@ -16,6 +17,8 @@ worker-pool-check lifecycle-close Each command returns zero on success and prints assertions to its output stream. The capacity check reads `ShellWorkerCount` from the host Info.plist (1 through 10), keeps that many commands open, requires distinct real process IDs and independent cwd/environment, rejects one additional request with `ENOSPC`, then sends input after six seconds and checks exact exit statuses and output EOF. It compares directory device/inode pairs because the phone can return `/private/var/...` for a cwd requested through its `/var/...` alias. +The completion check leaves output unread until polling reports status 7, checks that a second poll preserves it, then reads the exact pending output and EOF. + The lifecycle checks cancel or close one worker, ask a second worker to terminate itself with `SIGKILL`, verify the surviving worker's input/output/status, and repeat the full capacity check. The busy variants make the first command ignore EOF. iOS denies host-to-extension `kill` with `EPERM`, so process loss is injected by the normal disposable command inside the worker. Run these checks only in a disposable build: they deliberately terminate its worker processes. Register the same consumer in an unmodified host for the baseline. It must print `worker pool unavailable: FAIL` and return one. A Release build must export the public C functions and callbacks loaded through `dlsym`; a consumer compiled into an executable with hidden symbols does not prove that interface usable by a dynamic command. From 1ac4bdb3bf15bd9350d25ec60cbdb5f46030ef05 Mon Sep 17 00:00:00 2001 From: demrom <47463752+demrom@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:16:41 +0300 Subject: [PATCH 04/16] Cap concurrent shell workers at the physical core count, raisable in Settings The host runs min(worker_limit setting, hw.physicalcpu, bundled ShellWorkerCount) workers at once and returns ENOSPC beyond that. ShellWorkers/generate-targets.sh (POSIX sh and awk, like the other scripts of the repo) keeps the extension targets and the Settings list in line with ShellWorkerCount. The new Settings strings are localized in the existing Root.strings files. tests/worker-pool.m gains a limit check. --- README.md | 4 +- Resources_mini/Settings.bundle/Root.plist | 40 +++++ .../Settings.bundle/en.lproj/Root.strings | Bin 1238 -> 1466 bytes .../Settings.bundle/es.lproj/Root.strings | Bin 1552 -> 1796 bytes .../Settings.bundle/fr.lproj/Root.strings | Bin 1560 -> 1800 bytes .../Settings.bundle/it.lproj/Root.strings | Bin 1498 -> 1732 bytes .../Settings.bundle/ko.lproj/Root.strings | Bin 1456 -> 1628 bytes ShellWorkers/Host.swift | 26 ++- ShellWorkers/NativePane.h | 5 + ShellWorkers/RemotePane.m | 3 + ShellWorkers/generate-targets.sh | 148 ++++++++++++++++++ tests/worker-pool.m | 52 +++++- tests/worker-pool.md | 5 +- 13 files changed, 271 insertions(+), 12 deletions(-) create mode 100755 ShellWorkers/generate-targets.sh diff --git a/README.md b/README.md index c54f7dce..cb4a40c6 100644 --- a/README.md +++ b/README.md @@ -111,10 +111,10 @@ If you enable VoiceOver in Settings, a-Shell will work with VoiceOver: reading c ## Independent shell workers -On iOS 26 and later, the mini target includes ten dedicated Generic Non-UI extensions, `ShellWorkerA` through `ShellWorkerJ`. Each worker owns one native command and its ios_system state. The original Intents and IntentsUI targets remain separate. Consumers call `ashell_pane_spawn` from a background thread, read the returned full-duplex descriptor, poll completion and release the opaque handle. The API and stream ownership rules are in `ShellWorkers/NativePane.h`. +On iOS 26 and later, the mini target includes dedicated Generic Non-UI extensions, `ShellWorkerA` onward (ten at present; `ShellWorkerCount` in `a-Shell-mini-Info.plist` is the count, and `ShellWorkers/generate-targets.sh [N]` brings the project targets and the Settings list in line with it, a second run changes nothing). The host runs as many workers at once as the "Shell workers" setting says (`worker_limit` in Settings; Automatic is one per physical core, `hw.physicalcpu`), never more than the bundled count; a request beyond that gets `ENOSPC`. Each worker owns one native command and its ios_system state. The original Intents and IntentsUI targets remain separate. Consumers call `ashell_pane_spawn` from a background thread, read the returned full-duplex descriptor, poll completion and release the opaque handle. The API and stream ownership rules are in `ShellWorkers/NativePane.h`. The host retains each `AppExtensionProcess` and XPC connection until release or interruption. A full pool returns `ENOSPC`; earlier iOS versions return `ENOTSUP`. Cancel and release terminate the dedicated worker through XPC before freeing its reservation; releasing an ExtensionFoundation assertion alone permits process reuse. Commands that ignore EOF therefore cannot retain a released slot. A lost worker reports status 137 while the host and other workers remain available. Directory access uses security-scoped bookmarks and is checked with actual file operations in the worker. -Workers share the host's command libraries. Build with ios_system changes #179–#184 and dash_iOS#1–#2, including the matched libedit dependency described there. The build derives each worker's catalogue and executable markers from `Resources_mini/commandDictionary.plist`; it generates the ExtensionFoundation binding from `PRODUCT_BUNDLE_PREFIX`. Keep worker versions in `ShellWorkers/Worker.xcconfig` aligned with the containing app. The mini bundle suffixes are `.ShellWorkerA` through `.ShellWorkerJ`; each requires its own App ID and profile for a device build. +Workers share the host's command libraries. Build with ios_system changes #179–#184 and dash_iOS#1–#2, including the matched libedit dependency described there. The build derives each worker's catalogue and executable markers from `Resources_mini/commandDictionary.plist`; it generates the ExtensionFoundation binding from `PRODUCT_BUNDLE_PREFIX`. Keep worker versions in `ShellWorkers/Worker.xcconfig` aligned with the containing app. The mini bundle suffixes are `.ShellWorkerA` onward; each requires its own App ID and profile for a device build. `tests/worker-pool.m` is a real app-command consumer for Simulator qualification. Device memory limits, background execution without other host background modes, and App Store acceptance require separate validation. Retaining the process handle provides no promise of unlimited background execution. diff --git a/Resources_mini/Settings.bundle/Root.plist b/Resources_mini/Settings.bundle/Root.plist index cfde6e7e..4d06095b 100644 --- a/Resources_mini/Settings.bundle/Root.plist +++ b/Resources_mini/Settings.bundle/Root.plist @@ -144,6 +144,46 @@ DefaultValue + + + Type + PSMultiValueSpecifier + Title + Shell workers (Automatic: one per core) + Key + worker_limit + DefaultValue + 0 + Titles + + Automatic + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + + Values + + 0 + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + + + diff --git a/Resources_mini/Settings.bundle/en.lproj/Root.strings b/Resources_mini/Settings.bundle/en.lproj/Root.strings index 9a278b87f41042aa933db62fe0a119d61e8ebb13..8e0a92440e49143735c6dd39d856ee768b3030c2 100644 GIT binary patch delta 237 zcmcb{xr=+lHI{lMhG2#ahE#?eAXH!|XUJzLV#o&aix`T5d<_OihEj$SplB{bB9P8x zNM^7Cisv)r0o5ol6o6GK07XIiQW-QElz^hPKun~$sU#R|&As1z?p5Kv9ssR0d53C7`G+5GyeR07deFGO0k5ih-tp%qvE+3v6~F uis6MoWgufgR+Iv*09lX-GzMfrFk>l@1+u`JfeT1tIt*box*Wt@h)w`4*(*r^ delta 7 OcmeC+o58a|f(-x(fdUBt diff --git a/Resources_mini/Settings.bundle/it.lproj/Root.strings b/Resources_mini/Settings.bundle/it.lproj/Root.strings index 1604559b04a8f0388b0a573c73c753d6a0cf4994..5dce9216346f18d47fde7383c8c19314513de062 100644 GIT binary patch delta 243 zcmcb`eS~+zE!KJ^hG2#ahE#?eAXH!|XUJzLV#o&aix`T5d<_OihEj$SplB{bB9P8x zNM^7Cisv)r0o5ol6o6GK07XIiQW-QElz^hPK&-?N02Ijw%A^8KDhBFQ0FnrspeARd g7@ZF`ycB47J`TgJ8MuH(V>$|9Ev8&PPzOjK07}d&Z2$lO delta 7 OcmX@Ydy9L+Emi;x=mSOo diff --git a/Resources_mini/Settings.bundle/ko.lproj/Root.strings b/Resources_mini/Settings.bundle/ko.lproj/Root.strings index 207d03fba17343607bbbb70da88abfa0878322f2..9561556aea1b6ffa9d05196f87ed0c5e74fbdd93 100644 GIT binary patch delta 181 zcmdnMeTQem2G)8dhG2#ahE#?eAXH!|XUJzLV#o&aix`T5d<_OihEj$SplB{bB9P8x zNM^7Cisv)r0o5ol6o6GK07XIiQW-QElz^hPK&-@2aZrI_;_;(LCmaWvGvWBn%^;m2 j=eHdFxk-T`;_8eIK-Ibo)(l)g#hBJ03<7CY0;>Z6{Ix3h delta 7 Ocmcb^vw?fV237zKWCIBR diff --git a/ShellWorkers/Host.swift b/ShellWorkers/Host.swift index 9028e57e..bf9c3630 100644 --- a/ShellWorkers/Host.swift +++ b/ShellWorkers/Host.swift @@ -7,6 +7,29 @@ extension AppExtensionPoint { static var shellWorker: AppExtensionPoint { Name("ShellWorker") } } +// Concurrent workers: the user's "worker_limit" setting (0 or unset = automatic, the physical +// core count), never more than the bundled ShellWorkerCount extensions. 0 means no workers. +enum ShellWorkerCapacity { + static var bundled: Int { + let configured = Bundle.main.object(forInfoDictionaryKey: "ShellWorkerCount") + return (configured as? NSNumber)?.intValue ?? Int(configured as? String ?? "") ?? 0 + } + + static var physicalCores: Int { + var count: Int32 = 0 + var size = MemoryLayout.size + return sysctlbyname("hw.physicalcpu", &count, &size, nil, 0) == 0 ? Int(count) : 1 + } + + static var current: Int { + let requested = UserDefaults.standard.integer(forKey: "worker_limit") + return min(requested > 0 ? requested : physicalCores, bundled) + } +} + +@_cdecl("ashell_extension_capacity") +func shellExtensionCapacity() -> Int32 { Int32(ShellWorkerCapacity.current) } + // Process lifetime and discovery belong to MainActor. Blocking C ABI calls wait // only on the calling runner thread, never on the UI actor. @available(iOS 26.0, *) @@ -31,8 +54,7 @@ final class ShellWorkerPool { } let monitor = try await AppExtensionPoint.Monitor(appExtensionPoint: .shellWorker) for _ in 0..<100 { - let configured = Bundle.main.object(forInfoDictionaryKey: "ShellWorkerCount") - let capacity = (configured as? NSNumber)?.intValue ?? Int(configured as? String ?? "") ?? 0 + let capacity = ShellWorkerCapacity.current if capacity > 0 && slots.count >= capacity { throw NSError(domain: NSPOSIXErrorDomain, code: Int(ENOSPC), userInfo: [NSLocalizedDescriptionKey: "All shell workers are in use. Close a pane before opening another."]) } diff --git a/ShellWorkers/NativePane.h b/ShellWorkers/NativePane.h index 34f4294e..4a4f7172 100644 --- a/ShellWorkers/NativePane.h +++ b/ShellWorkers/NativePane.h @@ -8,6 +8,11 @@ extern "C" { // Keep the public C ABI discoverable in Release builds with hidden visibility. #pragma GCC visibility push(default) +// Host only: how many panes can run at once, so ashell_pane_spawn returns ENOSPC for the next one. +// The "worker_limit" setting (0 = automatic) or else the device's physical core count, +// limited to the bundled worker extensions. Read on every spawn, so a change applies at once. +int ashell_pane_capacity(void); + // command/cwd must be nonempty UTF-8; cwd must be an absolute accessible directory. // envp is NULL or a NULL-terminated list of UTF-8 key=value overrides, copied before return. // Dimensions are 1..9999. Returns 0 or positive errno; failure sets outputs to -1/NULL. diff --git a/ShellWorkers/RemotePane.m b/ShellWorkers/RemotePane.m index a60cff2a..5e9c0d13 100644 --- a/ShellWorkers/RemotePane.m +++ b/ShellWorkers/RemotePane.m @@ -9,6 +9,7 @@ extern void ashell_extension_connect(void (^)(NSXPCConnection *, NSError *)); extern void ashell_extension_disconnect(NSXPCConnection *); +extern int ashell_extension_capacity(void); static void log_host_library(const char *phase) { int saved = errno; @@ -42,6 +43,8 @@ - (void)dealloc { } @end +int ashell_pane_capacity(void) { return ashell_extension_capacity(); } + int ashell_pane_spawn(const char *command, const char *cwd, unsigned cols, unsigned rows, const char *const *envp, int *fd_out, void **job_out) { if (fd_out) *fd_out = -1; if (job_out) *job_out = NULL; diff --git a/ShellWorkers/generate-targets.sh b/ShellWorkers/generate-targets.sh new file mode 100755 index 00000000..30b77d90 --- /dev/null +++ b/ShellWorkers/generate-targets.sh @@ -0,0 +1,148 @@ +#!/bin/sh +# Make the project carry exactly N ShellWorker extension targets. +# +# ShellWorkers/generate-targets.sh bring everything in line with ShellWorkerCount +# ShellWorkers/generate-targets.sh N set ShellWorkerCount to N (1-26) first +# +# ShellWorkerCount in a-Shell-mini-Info.plist is the one source. ShellWorkerA is the template: +# every other worker is a copy of its objects (target, build configurations, build phases and +# files, product, embed entry, host dependency) with ids derived from the letter, so a second run +# changes nothing. The "Shell workers" list in Resources_mini/Settings.bundle/Root.plist is +# regenerated between its BEGIN/END markers. +set -eu +cd "$(dirname "$0")/.." + +PROJECT=a-Shell.xcodeproj/project.pbxproj +INFO=a-Shell-mini-Info.plist +SETTINGS=Resources_mini/Settings.bundle/Root.plist +BEGIN='' +END='' +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +if [ $# -gt 0 ]; then + case $1 in + '' | *[!0-9]*) echo "usage: $0 [N]" >&2; exit 2 ;; + esac + if [ "$1" -lt 1 ] || [ "$1" -gt 26 ]; then echo "N must be 1-26" >&2; exit 2; fi + sed -E "/ShellWorkerCount/{n;s/[0-9]+$1 "$tmp/info" + if ! cmp -s "$tmp/info" "$INFO"; then cat "$tmp/info" > "$INFO"; echo "info: ShellWorkerCount = $1"; fi +fi +count=$(plutil -extract ShellWorkerCount raw -o - "$INFO") + +# Project: add or remove whole workers. +awk -v count="$count" ' +function val(id, key, i, s) { + for (i = start[id] + 1; i < endl[id]; i++) + if (index(line[i], "\t\t\t" key " = ") == 1) { + s = line[i]; sub(/^\t+[A-Za-z]+ = "?/, "", s); return substr(s, 1, 24) + } + return "" +} +function items(id, key, out, i, s, k, inlist) { + k = 0 + for (i = start[id] + 1; i < endl[id]; i++) { + if (index(line[i], "\t\t\t" key " = (") == 1) { inlist = 1; continue } + if (inlist && line[i] == "\t\t\t);") break + if (inlist) { s = line[i]; sub(/^\t+"?/, "", s); out[++k] = substr(s, 1, 24) } + } + return k +} +function closure(letter, out, prod, t, cl, k, i, j, a, b, ph, nb, np) { + t = tid[letter]; k = 0 + out[++k] = t + cl = val(t, "buildConfigurationList"); out[++k] = cl + nb = items(cl, "buildConfigurations", a); for (i = 1; i <= nb; i++) out[++k] = a[i] + np = items(t, "buildPhases", ph); for (i = 1; i <= np; i++) out[++k] = ph[i] + for (i = 1; i <= np; i++) { nb = items(ph[i], "files", b); for (j = 1; j <= nb; j++) out[++k] = b[j] } + prod = val(t, "productReference"); out[++k] = prod + out[++k] = embed[prod] + out[++k] = dep[t] + return k +} +function listline(id, i) { + for (i = 1; i <= n; i++) + if (line[i] ~ /^\t+"?[0-9A-F]+"?( \/\*.*\*\/)?,$/ && index(line[i], id) > 0) return i + print "list element " id " not found" > "/dev/stderr"; exit 1 +} +function newid(letter, old, cmd, h) { + cmd = "printf %s ShellWorker" letter ":" old " | shasum -a 256" + cmd | getline h; close(cmd) + return toupper(substr(h, 1, 24)) +} +{ line[NR] = $0 } +END { + n = NR; cur = "" + for (i = 1; i <= n; i++) { + if (line[i] ~ /^\t\t"?[0-9A-F]+"?( \/\*.*\*\/)? = \{$/) { + s = line[i]; sub(/^\t\t"?/, "", s); cur = substr(s, 1, 24); start[cur] = i + } else if (cur != "" && line[i] == "\t\t};") { endl[cur] = i; cur = "" } + } + for (id in start) { + kind = line[start[id] + 1] + if (kind ~ /PBXNativeTarget/) { + for (i = start[id]; i < endl[id]; i++) + if (line[i] ~ /^\t\t\tname = "?ShellWorker[A-Z]"?;$/) { s = line[i]; tid[substr(s, index(s, "ShellWorker") + 11, 1)] = id } + } else if (kind ~ /PBXBuildFile/) embed[val(id, "fileRef")] = id + else if (kind ~ /PBXTargetDependency/) dep[val(id, "target")] = id + } + L = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + for (present = 0; substr(L, present + 1, 1) in tid; present++) ; + total = 0; for (x in tid) total++ + if (total != present) { print "workers in the project are not a prefix of A..Z" > "/dev/stderr"; exit 1 } + for (c = present; c > count; c--) { # remove the surplus, last letter first + m = closure(substr(L, c, 1), T) + for (k = 1; k <= m; k++) for (i = start[T[k]]; i <= endl[T[k]]; i++) skip[i] = 1 + skip[listline(T[1])] = 1; skip[listline(T[m - 1])] = 1; skip[listline(T[m])] = 1 + } + if (count > present) { + m = closure("A", T); closure(substr(L, present, 1), P) + for (c = present + 1; c <= count; c++) { + letter = substr(L, c, 1) + for (k = 1; k <= m; k++) map[T[k]] = newid(letter, T[k]) + for (k = 1; k <= m; k++) { + for (i = start[T[k]]; i <= endl[T[k]]; i++) { + s = line[i] + for (j = 1; j <= m; j++) gsub(T[j], map[T[j]], s) + gsub(/ShellWorkerA/, "ShellWorker" letter, s) + after[endl[P[k]]] = after[endl[P[k]]] s "\n" + } + } + for (k = 1; k <= 3; k++) { # target, embed and dependency lists + id = (k == 1) ? T[1] : (k == 2) ? T[m - 1] : T[m] + pid = (k == 1) ? P[1] : (k == 2) ? P[m - 1] : P[m] + at = listline(pid); ind = line[at]; sub(/[^\t].*$/, "", ind) + after[at] = after[at] ind "\"" map[id] "\",\n" + } + } + } + for (i = 1; i <= n; i++) { if (!skip[i]) print line[i]; if (i in after) printf "%s", after[i] } +}' "$PROJECT" > "$tmp/project" +if cmp -s "$tmp/project" "$PROJECT"; then echo "project: $count workers, no change" +else cat "$tmp/project" > "$PROJECT"; echo "project: now $count workers"; fi + +# Settings: "Automatic" (0) and 1..N between the markers. +{ + printf '\t\t%s\n\t\t\n' "$BEGIN" + printf '\t\t\tType\n\t\t\tPSMultiValueSpecifier\n' + printf '\t\t\tTitle\n\t\t\tShell workers (Automatic: one per core)\n' + printf '\t\t\tKey\n\t\t\tworker_limit\n' + printf '\t\t\tDefaultValue\n\t\t\t0\n' + printf '\t\t\tTitles\n\t\t\t\n\t\t\t\tAutomatic\n' + seq 1 "$count" | sed 's|.*|\t\t\t\t&|' + printf '\t\t\t\n\t\t\tValues\n\t\t\t\n\t\t\t\t0\n' + seq 1 "$count" | sed 's|.*|\t\t\t\t&|' + printf '\t\t\t\n\t\t\n\t\t%s\n' "$END" +} > "$tmp/block" +if grep -qF "$BEGIN" "$SETTINGS"; then + awk -v b="$BEGIN" -v e="$END" -v f="$tmp/block" ' + index($0, b) { while ((getline l < f) > 0) print l; skipping = 1 } + !skipping { print } + skipping && index($0, e) { skipping = 0 }' "$SETTINGS" > "$tmp/settings" +else + awk -v f="$tmp/block" ' + /^\t<\/array>$/ && !done { while ((getline l < f) > 0) print l; done = 1 } + { print }' "$SETTINGS" > "$tmp/settings" +fi +if cmp -s "$tmp/settings" "$SETTINGS"; then echo "settings: no change" +else cat "$tmp/settings" > "$SETTINGS"; echo "settings: updated"; fi diff --git a/tests/worker-pool.m b/tests/worker-pool.m index f2a5f429..90a77bf7 100644 --- a/tests/worker-pool.m +++ b/tests/worker-pool.m @@ -7,8 +7,11 @@ #include #include #include +#include +#define MAX_WORKERS 64 + // Keep each command alive at its public stdin boundary while the other slots start. static int capacity_check(void) { int (*spawn)(const char *, const char *, unsigned, unsigned, const char *const *, int *, void **) = dlsym(RTLD_DEFAULT, "ashell_pane_spawn"); @@ -16,13 +19,16 @@ static int capacity_check(void) { void (*release)(void *) = dlsym(RTLD_DEFAULT, "ashell_pane_release"); if (!spawn || !pollJob || !release) { fputs("worker pool unavailable: FAIL\n", thread_stdout); return 1; } NSString *root = [NSHomeDirectory() stringByAppendingPathComponent:[@"Documents/worker-pool-" stringByAppendingString:NSUUID.UUID.UUIDString]]; - const int capacity = [[NSBundle.mainBundle objectForInfoDictionaryKey:@"ShellWorkerCount"] intValue]; - if (capacity < 1 || capacity > 10) return 64; - void *jobs[10] = {0}; - int descriptors[10]; - char output[10][2048] = {{0}}; - size_t used[10] = {0}; - int pids[10] = {0}; + // The cap in force now: the setting or the core count, limited to the bundled extensions. + int (*capacityOf)(void) = dlsym(RTLD_DEFAULT, "ashell_pane_capacity"); + const int capacity = capacityOf ? capacityOf() : [[NSBundle.mainBundle objectForInfoDictionaryKey:@"ShellWorkerCount"] intValue]; + if (capacity < 1 || capacity > MAX_WORKERS) return 64; + void *jobs[MAX_WORKERS] = {0}; + int descriptors[MAX_WORKERS]; + static char output[MAX_WORKERS][2048]; + memset(output, 0, sizeof(output)); + size_t used[MAX_WORKERS] = {0}; + int pids[MAX_WORKERS] = {0}; BOOL passed = YES; for (int i = 0; i < capacity; i++) descriptors[i] = -1; for (int i = 0; i < capacity; i++) { @@ -219,11 +225,43 @@ static int completion_check(void) { return passed ? 0 : 1; } +// The cap follows the setting: default is the physical core count, a larger setting allows more +// concurrent workers up to the bundled count, a smaller one fewer, and ENOSPC comes right after it. +static int limit_check(void) { + int (*capacityOf)(void) = dlsym(RTLD_DEFAULT, "ashell_pane_capacity"); + if (!capacityOf) { fputs("worker pool capacity unavailable: FAIL\n", thread_stdout); return 1; } + int bundled = [[NSBundle.mainBundle objectForInfoDictionaryKey:@"ShellWorkerCount"] intValue]; + int cores = 0; + size_t size = sizeof(cores); + if (sysctlbyname("hw.physicalcpu", &cores, &size, NULL, 0) || cores < 1) return 64; + NSUserDefaults *defaults = NSUserDefaults.standardUserDefaults; + id previous = [defaults objectForKey:@"worker_limit"]; + BOOL passed = YES; + [defaults removeObjectForKey:@"worker_limit"]; + int expected = cores < bundled ? cores : bundled; + fprintf(thread_stdout, "default capacity=%d expected=%d (cores=%d bundled=%d): %s\n", capacityOf(), expected, cores, bundled, capacityOf() == expected ? "PASS" : "FAIL"); + passed = capacityOf() == expected; + if (passed) passed = capacity_check() == 0; + // A setting of 2, the bundled count and more than the bundled count. + int settings[] = {2, bundled, bundled + 5}; + for (int i = 0; passed && i < 3; i++) { + [defaults setInteger:settings[i] forKey:@"worker_limit"]; + expected = settings[i] < bundled ? settings[i] : bundled; + passed = capacityOf() == expected; + fprintf(thread_stdout, "worker_limit=%d capacity=%d expected=%d: %s\n", settings[i], capacityOf(), expected, passed ? "PASS" : "FAIL"); + if (passed) passed = capacity_check() == 0; + } + if (previous) [defaults setObject:previous forKey:@"worker_limit"]; else [defaults removeObjectForKey:@"worker_limit"]; + fprintf(thread_stdout, "worker limit setting: %s\n", passed ? "PASS" : "FAIL"); + return passed ? 0 : 1; +} + // Run as a real app command. The same consumer must fail on the stock app. int worker_pool_check_main(int argc, char **argv) { @autoreleasepool { if (argc == 2 && !strcmp(argv[1], "completion")) return completion_check(); if (argc == 2 && !strcmp(argv[1], "capacity")) return capacity_check(); + if (argc == 2 && !strcmp(argv[1], "limit")) return limit_check(); if (argc == 2 && !strcmp(argv[1], "lifecycle")) return lifecycle_check(NO, NO); if (argc == 2 && !strcmp(argv[1], "lifecycle-busy")) return lifecycle_check(YES, NO); if (argc == 2 && !strcmp(argv[1], "lifecycle-close")) return lifecycle_check(YES, YES); diff --git a/tests/worker-pool.md b/tests/worker-pool.md index 70d22e1b..4dacd8b3 100644 --- a/tests/worker-pool.md +++ b/tests/worker-pool.md @@ -10,15 +10,18 @@ Use the matching ios_system session/stream and Dash terminal changes, then run t worker-pool-check worker-pool-check completion worker-pool-check capacity +worker-pool-check limit worker-pool-check lifecycle worker-pool-check lifecycle-busy worker-pool-check lifecycle-close ``` -Each command returns zero on success and prints assertions to its output stream. The capacity check reads `ShellWorkerCount` from the host Info.plist (1 through 10), keeps that many commands open, requires distinct real process IDs and independent cwd/environment, rejects one additional request with `ENOSPC`, then sends input after six seconds and checks exact exit statuses and output EOF. It compares directory device/inode pairs because the phone can return `/private/var/...` for a cwd requested through its `/var/...` alias. +Each command returns zero on success and prints assertions to its output stream. The capacity check asks the host for the effective cap (`ashell_pane_capacity`: the `worker_limit` setting, or the physical core count when unset, at most `ShellWorkerCount` from the host Info.plist), keeps that many commands open, requires distinct real process IDs and independent cwd/environment, rejects one additional request with `ENOSPC`, then sends input after six seconds and checks exact exit statuses and output EOF. It compares directory device/inode pairs because the phone can return `/private/var/...` for a cwd requested through its `/var/...` alias. The completion check leaves output unread until polling reports status 7, checks that a second poll preserves it, then reads the exact pending output and EOF. +The limit check requires the default cap to equal `min(hw.physicalcpu, ShellWorkerCount)`, then sets `worker_limit` to 2, to the bundled count and above it (clamped to the bundled count), repeats the capacity check under each and restores the setting. Build the host with more workers than cores to see the default and the raised cap differ. + The lifecycle checks cancel or close one worker, ask a second worker to terminate itself with `SIGKILL`, verify the surviving worker's input/output/status, and repeat the full capacity check. The busy variants make the first command ignore EOF. iOS denies host-to-extension `kill` with `EPERM`, so process loss is injected by the normal disposable command inside the worker. Run these checks only in a disposable build: they deliberately terminate its worker processes. Register the same consumer in an unmodified host for the baseline. It must print `worker pool unavailable: FAIL` and return one. A Release build must export the public C functions and callbacks loaded through `dlsym`; a consumer compiled into an executable with hidden symbols does not prove that interface usable by a dynamic command. From 12c08494daf05219d22d6dfbd42f9365ccee21df Mon Sep 17 00:00:00 2001 From: demrom <47463752+demrom@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:25:04 +0300 Subject: [PATCH 05/16] Add ashell_pane_spawn_ex: separate stderr and an optional terminal for a pane --- ShellWorkers/NativePane.h | 21 ++++++++++++++++ ShellWorkers/NativePane.m | 53 ++++++++++++++++++++++++++++++++++----- ShellWorkers/PaneWire.h | 2 +- ShellWorkers/RemotePane.m | 23 ++++++++++++++--- ShellWorkers/Worker.m | 32 ++++++++++++----------- 5 files changed, 106 insertions(+), 25 deletions(-) diff --git a/ShellWorkers/NativePane.h b/ShellWorkers/NativePane.h index 4a4f7172..6c3da7ab 100644 --- a/ShellWorkers/NativePane.h +++ b/ShellWorkers/NativePane.h @@ -1,6 +1,8 @@ #ifndef ASHELL_NATIVE_PANE_H #define ASHELL_NATIVE_PANE_H +#include + #ifdef __cplusplus extern "C" { #endif @@ -23,6 +25,25 @@ int ashell_pane_capacity(void); int ashell_pane_spawn(const char *command, const char *cwd, unsigned cols, unsigned rows, const char *const *envp, int *fd_out, void **job_out); +// Options for ashell_pane_spawn_ex. The caller sets size to sizeof(ashell_pane_options). +typedef struct ashell_pane_options { + size_t size; + // Nonzero: the command sees a terminal on stdin, stdout and stderr (isatty is true). + // Zero: it sees plain streams, as when the caller redirects them to a file or a pipe. + int tty; + // Nonzero: stderr is a separate stream; its read side is written to stderr_fd. + int separate_stderr; + // Output, -1 unless separate_stderr succeeded. The caller owns and closes it. Drain it + // as well as the main fd; a full unread stderr blocks the command like a full stdout. + int stderr_fd; +} ashell_pane_options; + +// ashell_pane_spawn with options. ashell_pane_spawn is this call with tty = 1 and +// separate_stderr = 0, so its behaviour is unchanged. Returns 0 or positive errno. +int ashell_pane_spawn_ex(const char *command, const char *cwd, unsigned cols, unsigned rows, + const char *const *envp, ashell_pane_options *options, + int *fd_out, void **job_out); + // Returns 0 running, 1 exited (writes final command status), or -errno. // Repeated polling after exit preserves status. Drain the returned fd to EOF independently. // A cleanup/restore error returns -errno after completion instead of hiding the failure. diff --git a/ShellWorkers/NativePane.m b/ShellWorkers/NativePane.m index f48a8411..94cdd49f 100644 --- a/ShellWorkers/NativePane.m +++ b/ShellWorkers/NativePane.m @@ -32,6 +32,8 @@ int peer_control, input_closed, is_pty; unsigned char eof_key; FILE *streams[3]; + // Without a tty the session streams differ from the command streams, so stock isatty reports false. + FILE *session_in, *session_out, *session_err; void *registration; char key[64]; char *command, *cwd, *previous_cwd; @@ -260,6 +262,12 @@ static int request_input(pane_input_relay *relay, int request, command_input *co return error; } +static void baseline_streams(pane_job *job, FILE *input) { + ios_setStreams(job->session_in ? job->session_in : input, + job->session_out ? job->session_out : job->streams[1], + job->session_err ? job->session_err : job->streams[2]); +} + static int stop_input_relay(pane_job *job) { pane_input_relay *relay = job->input_relay; if (!relay) return 0; @@ -276,7 +284,7 @@ static int stop_input_relay(pane_job *job) { if (relay->command) { command_input *command = relay->command; ashell_wait_owned_streams(command->registration); - ios_setStreams(job->streams[0], job->streams[1], job->streams[2]); + baseline_streams(job, job->streams[0]); ios_settty(job->streams[0]); ashell_unregister_owned_streams(&command->registration); if (fclose(command->reader) && !error) error = errno; @@ -409,7 +417,7 @@ static int prepare_input_relay(pane_job *job) { *token = command; // Stock tty detection compares command streams against the session streams. // Keep baseline outputs so explicit redirects remain non-terminal streams. - ios_setStreams(command->reader, job->streams[1], job->streams[2]); + baseline_streams(job, command->reader); // Stock ios_opentty borrowers must receive this command's input as well. ios_settty(command->reader); pthread_setcancelstate(cancellation, NULL); @@ -427,7 +435,7 @@ static int prepare_input_relay(pane_job *job) { // Dash. Borrow counts include asynchronous children through their cleanup. ashell_wait_owned_streams(command->registration); pane_job *job = command->job; - ios_setStreams(job->streams[0], job->streams[1], job->streams[2]); + baseline_streams(job, job->streams[0]); ios_settty(job->streams[0]); int retired = request_input(command->relay, INPUT_RETIRE, command); if (!error) error = retired; @@ -467,6 +475,11 @@ static int close_streams(pane_job *job) { if (job->streams[i] && fclose(job->streams[i]) && !error) error = errno; job->streams[i] = NULL; } + FILE **session[3] = {&job->session_in, &job->session_out, &job->session_err}; + for (int i = 0; i < 3; i++) { + if (*session[i] && fclose(*session[i]) && !error) error = errno; + *session[i] = NULL; + } return error; } @@ -484,7 +497,7 @@ static int socket_options(int fd) { // This runner is new: no parent session or TLS streams are switched or overwritten. ios_switchSession(job->key); ios_setContext(job->key); - ios_setStreams(job->streams[0], job->streams[1], job->streams[2]); + baseline_streams(job, job->streams[0]); ios_settty(job->streams[0]); thread_stdin = job->streams[0]; thread_stdout = job->streams[1]; @@ -558,8 +571,18 @@ static int socket_options(int fd) { int ashell_pane_spawn(const char *command, const char *cwd, unsigned cols, unsigned rows, const char *const *envp, int *fd_out, void **job_out) { + ashell_pane_options options = {.size = sizeof(options), .tty = 1}; + return ashell_pane_spawn_ex(command, cwd, cols, rows, envp, &options, fd_out, job_out); +} + +int ashell_pane_spawn_ex(const char *command, const char *cwd, unsigned cols, unsigned rows, + const char *const *envp, ashell_pane_options *options, + int *fd_out, void **job_out) { if (fd_out) *fd_out = -1; if (job_out) *job_out = NULL; + if (!options || options->size < sizeof(*options)) return EINVAL; + options->stderr_fd = -1; + int peer_err = -1; if (!fd_out || !job_out || !command || !*command || !cwd || cwd[0] != '/' || !cols || cols > 9999 || !rows || rows > 9999) return EINVAL; @autoreleasepool { @@ -647,8 +670,18 @@ int ashell_pane_spawn(const char *command, const char *cwd, unsigned cols, error = job->is_pty ? (fcntl(job->peer_control, F_SETFD, FD_CLOEXEC) ? errno : 0) : socket_options(job->peer_control); if (!error && job->is_pty && fcntl(peer, F_SETFD, FD_CLOEXEC)) error = errno; if (error) { close(child); goto failed; } + int err_child = -1; + if (options->separate_stderr) { + int pair[2]; + if (socketpair(AF_UNIX, SOCK_STREAM, 0, pair)) { error = errno; goto failed; } + peer_err = pair[0]; + err_child = pair[1]; + error = socket_options(peer_err); + if (!error) error = socket_options(err_child); + if (error) { close(err_child); goto failed; } + } for (int i = 0; i < 3; i++) { - int fd = i == 0 ? child : dup(child); + int fd = i == 0 ? child : i == 2 && err_child >= 0 ? err_child : dup(child); if (fd < 0) { error = errno; goto failed; } job->streams[i] = fdopen(fd, i == 0 ? "r" : "w"); if (!job->streams[i]) { error = errno; close(fd); goto failed; } @@ -657,6 +690,12 @@ int ashell_pane_spawn(const char *command, const char *cwd, unsigned cols, int buffering = !job->is_pty && i == 1 ? _IOLBF : _IONBF; if (setvbuf(job->streams[i], NULL, buffering, 0)) { error = EIO; goto failed; } } + if (!options->tty) { + job->session_in = fopen("/dev/null", "r"); + job->session_out = fopen("/dev/null", "w"); + job->session_err = fopen("/dev/null", "w"); + if (!job->session_in || !job->session_out || !job->session_err) { error = errno; goto failed; } + } if (!job->is_pty) error = prepare_input_relay(job); if (!error) error = ashell_register_owned_streams(job->streams[0], job->streams[1], job->streams[2], &job->registration); if (error) goto failed; @@ -673,13 +712,15 @@ int ashell_pane_spawn(const char *command, const char *cwd, unsigned cols, while (!job->initialized) pthread_cond_wait(&job->ready, &job->mutex); error = job->startup_error; pthread_mutex_unlock(&job->mutex); - if (error) { close(peer); drop_reference(job); return error; } + if (error) { close(peer); if (peer_err >= 0) close(peer_err); drop_reference(job); return error; } *fd_out = peer; + options->stderr_fd = peer_err; *job_out = job; return 0; failed: close_streams(job); if (peer >= 0) close(peer); + if (peer_err >= 0) close(peer_err); drop_reference(job); atomic_store(&pane_in_flight, 0); return error; diff --git a/ShellWorkers/PaneWire.h b/ShellWorkers/PaneWire.h index a1b80a9c..63ddec83 100644 --- a/ShellWorkers/PaneWire.h +++ b/ShellWorkers/PaneWire.h @@ -2,7 +2,7 @@ // One connection owns one real process and one existing native pane handle. @protocol AShellPaneWire -- (void)spawn:(NSString *)command cwd:(NSData *)cwd home:(NSData *)home documents:(NSData *)documents probe:(NSString *)probe env:(NSArray *)env cols:(unsigned)cols rows:(unsigned)rows reply:(void (^)(int, NSFileHandle *, int))reply; +- (void)spawn:(NSString *)command cwd:(NSData *)cwd home:(NSData *)home documents:(NSData *)documents probe:(NSString *)probe env:(NSArray *)env cols:(unsigned)cols rows:(unsigned)rows tty:(BOOL)tty separateStderr:(BOOL)separateStderr reply:(void (^)(int, NSFileHandle *, NSFileHandle *, int))reply; - (void)poll:(void (^)(int, int))reply; - (void)resize:(unsigned)cols rows:(unsigned)rows reply:(void (^)(int))reply; - (void)closeInput:(void (^)(int))reply; diff --git a/ShellWorkers/RemotePane.m b/ShellWorkers/RemotePane.m index 5e9c0d13..ddca44df 100644 --- a/ShellWorkers/RemotePane.m +++ b/ShellWorkers/RemotePane.m @@ -25,6 +25,7 @@ @interface RemotePane : NSObject @property(nonatomic, strong) NSXPCConnection *connection; @property(nonatomic, strong) id proxy; @property(nonatomic, strong) NSFileHandle *output; +@property(nonatomic, strong) NSFileHandle *errorOutput; @property(atomic) BOOL interrupted; @property(nonatomic, strong) NSMutableArray *directories; @property(nonatomic, copy) NSString *probeDirectory; @@ -32,6 +33,7 @@ @interface RemotePane : NSObject @implementation RemotePane - (void)dealloc { [_output closeAndReturnError:NULL]; + [_errorOutput closeAndReturnError:NULL]; // Only remove our empty disposable directory, never a project tree. if (_probeDirectory) rmdir(_probeDirectory.fileSystemRepresentation); log_host_library("before_scope_release"); @@ -46,8 +48,14 @@ - (void)dealloc { int ashell_pane_capacity(void) { return ashell_extension_capacity(); } int ashell_pane_spawn(const char *command, const char *cwd, unsigned cols, unsigned rows, const char *const *envp, int *fd_out, void **job_out) { + ashell_pane_options options = {.size = sizeof(options), .tty = 1}; + return ashell_pane_spawn_ex(command, cwd, cols, rows, envp, &options, fd_out, job_out); +} +int ashell_pane_spawn_ex(const char *command, const char *cwd, unsigned cols, unsigned rows, const char *const *envp, ashell_pane_options *options, int *fd_out, void **job_out) { if (fd_out) *fd_out = -1; if (job_out) *job_out = NULL; + if (!options || options->size < sizeof(*options)) return EINVAL; + options->stderr_fd = -1; if (!command || !cwd || !fd_out || !job_out || !cols || !rows) return EINVAL; if (NSThread.isMainThread) return EDEADLK; @autoreleasepool { @@ -114,22 +122,31 @@ int ashell_pane_spawn(const char *command, const char *cwd, unsigned cols, unsig pane.proxy = [pane.connection synchronousRemoteObjectProxyWithErrorHandler:^(NSError *failure) { error = EPIPE; }]; NSMutableArray *env = [NSMutableArray array]; for (size_t i = 0; envp && envp[i]; i++) [env addObject:@(envp[i])]; - __block int fd = -1; - [pane.proxy spawn:@(command) cwd:cwdBookmark home:homeBookmark documents:documentsBookmark probe:pane.probeDirectory env:env cols:cols rows:rows reply:^(int code, NSFileHandle *handle, int pid) { + __block int fd = -1, err_fd = -1; + BOOL separate = options->separate_stderr != 0; + [pane.proxy spawn:@(command) cwd:cwdBookmark home:homeBookmark documents:documentsBookmark probe:pane.probeDirectory env:env cols:cols rows:rows tty:options->tty != 0 separateStderr:separate reply:^(int code, NSFileHandle *handle, NSFileHandle *errorHandle, int pid) { error = code; - if (!code && !handle) error = EPROTO; + if (!code && (!handle || (separate && !errorHandle))) error = EPROTO; if (!error) { fd = dup(handle.fileDescriptor); if (fd < 0) error = errno; else if (fcntl(fd, F_SETFD, FD_CLOEXEC)) { error = errno; close(fd); fd = -1; } else pane.output = handle; } + if (!error && separate) { + err_fd = dup(errorHandle.fileDescriptor); + if (err_fd < 0) error = errno; + else if (fcntl(err_fd, F_SETFD, FD_CLOEXEC)) { error = errno; close(err_fd); err_fd = -1; } + else pane.errorOutput = errorHandle; + if (error && fd >= 0) { close(fd); fd = -1; } + } NSLog(@"Shell extension spawn: real_pid=%d error=%d startup_ms=%.3f", pid, error, (NSProcessInfo.processInfo.systemUptime - started) * 1000); }]; if (rmdir(pane.probeDirectory.fileSystemRepresentation) == 0) pane.probeDirectory = nil; if (!error && fd < 0) error = EPROTO; if (error) { ashell_extension_disconnect(pane.connection); return error; } *fd_out = fd; + options->stderr_fd = err_fd; *job_out = (__bridge_retained void *)pane; return 0; } diff --git a/ShellWorkers/Worker.m b/ShellWorkers/Worker.m index c8bb984a..827dcac6 100644 --- a/ShellWorkers/Worker.m +++ b/ShellWorkers/Worker.m @@ -57,9 +57,9 @@ @implementation PaneService { void *_pane; NSMutableArray *_directories; } -- (void)spawn:(NSString *)command cwd:(NSData *)cwd home:(NSData *)home documents:(NSData *)documents probe:(NSString *)probe env:(NSArray *)env cols:(unsigned)cols rows:(unsigned)rows reply:(void (^)(int, NSFileHandle *, int))reply { - if (_pane) { reply(EBUSY, nil, getpid()); return; } - if (!cwd || !home || !documents || !probe) { reply(EINVAL, nil, getpid()); return; } +- (void)spawn:(NSString *)command cwd:(NSData *)cwd home:(NSData *)home documents:(NSData *)documents probe:(NSString *)probe env:(NSArray *)env cols:(unsigned)cols rows:(unsigned)rows tty:(BOOL)tty separateStderr:(BOOL)separateStderr reply:(void (^)(int, NSFileHandle *, NSFileHandle *, int))reply { + if (_pane) { reply(EBUSY, nil, nil, getpid()); return; } + if (!cwd || !home || !documents || !probe) { reply(EINVAL, nil, nil, getpid()); return; } // Resolve the host's ephemeral grants explicitly and retain them for this // pane, including HOME for shared command configuration and authentication. _directories = [NSMutableArray array]; @@ -77,7 +77,7 @@ - (void)spawn:(NSString *)command cwd:(NSData *)cwd home:(NSData *)home document if (!url) { NSLog(@"Shell worker directory bookmark failed: %@", failure); [self releasePane]; - reply([failure.domain isEqualToString:NSPOSIXErrorDomain] ? (int)failure.code : EIO, nil, getpid()); + reply([failure.domain isEqualToString:NSPOSIXErrorDomain] ? (int)failure.code : EIO, nil, nil, getpid()); return; } BOOL scoped = [url startAccessingSecurityScopedResource]; @@ -94,14 +94,14 @@ - (void)spawn:(NSString *)command cwd:(NSData *)cwd home:(NSData *)home document NSLog(@"Shell worker directory capability: url=%@ read_error=%d", url.path, code); if (code && !readError) readError = code; } - if (readError) { [self releasePane]; reply(readError, nil, getpid()); return; } + if (readError) { [self releasePane]; reply(readError, nil, nil, getpid()); return; } int capabilityError = check_documents_write(documentsURL, probe); NSLog(@"Shell worker Documents capability: real_pid=%d read_write_error=%d", getpid(), capabilityError); - if (capabilityError) { [self releasePane]; reply(capabilityError, nil, getpid()); return; } + if (capabilityError) { [self releasePane]; reply(capabilityError, nil, nil, getpid()); return; } // ios_system globals are private to this extension. The adapter keeps its // original dedicated pthread and stream ownership/cleanup barrier. NSString *temporaryDirectory = NSTemporaryDirectory(); - if (!temporaryDirectory.length) { [self releasePane]; reply(EIO, nil, getpid()); return; } + if (!temporaryDirectory.length) { [self releasePane]; reply(EIO, nil, nil, getpid()); return; } // The host's directory bookmark does not grant its tmp directory. Keep the // extension's stock Foundation/libc temp domain in both environment views. NSMutableArray *environment = [NSMutableArray arrayWithCapacity:env.count + 1]; @@ -115,20 +115,20 @@ - (void)spawn:(NSString *)command cwd:(NSData *)cwd home:(NSData *)home document // environment as well as ios_system's forked command environment. for (NSString *value in env) { NSRange separator = [value rangeOfString:@"="]; - if (separator.location == NSNotFound || separator.location == 0) { [self releasePane]; reply(EINVAL, nil, getpid()); return; } + if (separator.location == NSNotFound || separator.location == 0) { [self releasePane]; reply(EINVAL, nil, nil, getpid()); return; } if (setenv([value substringToIndex:separator.location].UTF8String, - [value substringFromIndex:separator.location + 1].UTF8String, 1)) { int error = errno; [self releasePane]; reply(error, nil, getpid()); return; } + [value substringFromIndex:separator.location + 1].UTF8String, 1)) { int error = errno; [self releasePane]; reply(error, nil, nil, getpid()); return; } } // Match stock SceneDelegate before libedit decodes Unicode or measures it. - if (setenv("LC_CTYPE", "UTF-8", 1)) { int error = errno; [self releasePane]; reply(error, nil, getpid()); return; } + if (setenv("LC_CTYPE", "UTF-8", 1)) { int error = errno; [self releasePane]; reply(error, nil, nil, getpid()); return; } const char *locale = setlocale(LC_CTYPE, "UTF-8"); - if (!locale) { [self releasePane]; reply(EINVAL, nil, getpid()); return; } + if (!locale) { [self releasePane]; reply(EINVAL, nil, nil, getpid()); return; } NSLog(@"Shell worker locale: real_pid=%d ctype=%s", getpid(), locale); // The physical device has no kernel tty. Use the same socket-backed route // on Simulator so its acceptance cannot pass through a desktop-only PTY. - if (setenv("ASHELL_IOS_PTY", "0", 1)) { int error = errno; [self releasePane]; reply(error, nil, getpid()); return; } + if (setenv("ASHELL_IOS_PTY", "0", 1)) { int error = errno; [self releasePane]; reply(error, nil, nil, getpid()); return; } const char **values = calloc(env.count + 4, sizeof(char *)); - if (!values) { [self releasePane]; reply(ENOMEM, nil, getpid()); return; } + if (!values) { [self releasePane]; reply(ENOMEM, nil, nil, getpid()); return; } for (NSUInteger i = 0; i < env.count; i++) values[i] = [env[i] hasPrefix:@"ASHELL_IOS_PTY="] ? "ASHELL_IOS_PTY=0" : [env[i] hasPrefix:@"LC_CTYPE="] ? "LC_CTYPE=UTF-8" : env[i].UTF8String; @@ -137,11 +137,13 @@ - (void)spawn:(NSString *)command cwd:(NSData *)cwd home:(NSData *)home document values[env.count + 1] = "LC_CTYPE=UTF-8"; values[env.count + 2] = "ASHELL_IOS_SOCKET_INPUT=1"; int fd = -1; - int error = ashell_pane_spawn(command.UTF8String, directory.path.UTF8String, cols, rows, values, &fd, &_pane); + ashell_pane_options options = {.size = sizeof(options), .tty = tty, .separate_stderr = separateStderr}; + int error = ashell_pane_spawn_ex(command.UTF8String, directory.path.UTF8String, cols, rows, values, &options, &fd, &_pane); free(values); if (error) [self releasePane]; NSFileHandle *handle = error ? nil : [[NSFileHandle alloc] initWithFileDescriptor:fd closeOnDealloc:YES]; - reply(error, handle, getpid()); + NSFileHandle *errorHandle = !error && options.stderr_fd >= 0 ? [[NSFileHandle alloc] initWithFileDescriptor:options.stderr_fd closeOnDealloc:YES] : nil; + reply(error, handle, errorHandle, getpid()); } - (void)poll:(void (^)(int, int))reply { int status = 0; From b48641df8a0ca2bdc41a192e6f27e375eba2ba0f Mon Sep 17 00:00:00 2001 From: demrom <47463752+demrom@users.noreply.github.com> Date: Fri, 23 Oct 2026 08:21:18 +0300 Subject: [PATCH 06/16] Broker nested shell spawns through the host worker pool --- ShellWorkers/Host.swift | 80 ++++++++++++++++++--- ShellWorkers/NativePane.h | 3 + ShellWorkers/NativePane.m | 19 ++++- ShellWorkers/PaneWire.h | 13 ++++ ShellWorkers/RemotePane.m | 83 +++++++++++++++++++++- ShellWorkers/Worker.m | 113 ++++++++++++++++++++++++++++-- a-Shell/a-Shell-Bridging-Header.h | 1 + 7 files changed, 294 insertions(+), 18 deletions(-) diff --git a/ShellWorkers/Host.swift b/ShellWorkers/Host.swift index bf9c3630..df006016 100644 --- a/ShellWorkers/Host.swift +++ b/ShellWorkers/Host.swift @@ -38,11 +38,12 @@ final class ShellWorkerPool { static let shared = ShellWorkerPool() private enum Slot { case launching(UUID) - case connected(UUID, AppExtensionProcess, NSXPCConnection) + case connected(UUID, AppExtensionProcess, NSXPCConnection, DispatchSourceProcess?) + case retiring(UUID, DispatchSourceProcess?) var generation: UUID { switch self { - case .launching(let generation), .connected(let generation, _, _): generation + case .launching(let generation), .connected(let generation, _, _, _), .retiring(let generation, _): generation } } } @@ -56,6 +57,10 @@ final class ShellWorkerPool { for _ in 0..<100 { let capacity = ShellWorkerCapacity.current if capacity > 0 && slots.count >= capacity { + if slots.values.contains(where: { if case .retiring = $0 { return true }; return false }) { + try await Task.sleep(for: .milliseconds(100)) + continue + } throw NSError(domain: NSPOSIXErrorDomain, code: Int(ENOSPC), userInfo: [NSLocalizedDescriptionKey: "All shell workers are in use. Close a pane before opening another."]) } if let identity = monitor.identities.sorted(by: { $0.bundleIdentifier < $1.bundleIdentifier }).first(where: { slots[$0.bundleIdentifier] == nil }) { @@ -66,7 +71,10 @@ final class ShellWorkerPool { let process = try await AppExtensionProcess(configuration: .init(appExtensionIdentity: identity, onInterruption: { Task { @MainActor in guard self.slots[key]?.generation == generation else { return } - self.slots.removeValue(forKey: key) + // A connected worker is retired using its spawn-reply PID. + if case .launching = self.slots[key] { + self.slots.removeValue(forKey: key) + } } })) guard slots[key]?.generation == generation else { @@ -74,7 +82,9 @@ final class ShellWorkerPool { throw CancellationError() } let connection = try process.makeXPCConnection() - slots[key] = .connected(generation, process, connection) + connection.exportedInterface = NSXPCInterface(with: AShellSpawnBrokerWire.self) + connection.exportedObject = ashell_spawn_broker() + slots[key] = .connected(generation, process, connection, nil) return connection } catch { if slots[key]?.generation == generation { slots.removeValue(forKey: key) } @@ -86,20 +96,68 @@ final class ShellWorkerPool { throw NSError(domain: NSPOSIXErrorDomain, code: Int(ETIMEDOUT), userInfo: [NSLocalizedDescriptionKey: "shell worker discovery timed out"]) } + func observe(_ connection: NSXPCConnection, pid: pid_t, registered: @escaping () -> Void) { + for (key, slot) in slots { + guard case .connected(let generation, let process, let stored, nil) = slot, stored === connection else { continue } + let exit = DispatchSource.makeProcessSource(identifier: pid, eventMask: .exit, queue: .main) + slots[key] = .connected(generation, process, connection, exit) + exit.setEventHandler { [weak self] in + Task { @MainActor in + guard let self, let current = self.slots[key], current.generation == generation else { return } + let source: DispatchSourceProcess? + switch current { + case .connected(_, _, _, let value), .retiring(_, let value): source = value + case .launching: return + } + source?.cancel() + connection.invalidate() + process.invalidate() + self.slots.removeValue(forKey: key) + } + } + // Arm observation while the admitted process is still alive. + exit.setRegistrationHandler(handler: registered) + exit.resume() + return + } + registered() + } + func disconnect(_ connection: NSXPCConnection) { for (key, slot) in slots { - guard case .connected(_, let process, let stored) = slot, stored === connection else { continue } - // Explicit invalidation does not guarantee an interruption callback. - // Release the reservation in this MainActor transaction; generation - // checks keep a late callback from removing a replacement process. - connection.invalidate() - process.invalidate() - slots.removeValue(forKey: key) + guard case .connected(let generation, let process, let stored, let exit) = slot, stored === connection else { continue } + if exit == nil { + connection.invalidate() + process.invalidate() + slots.removeValue(forKey: key) + return + } + slots[key] = .retiring(generation, exit) + // Keep the process assertion until exit, with a deadline if observation fails. + Task { @MainActor in + try? await Task.sleep(for: .seconds(2)) + guard case .retiring(let current, let source) = self.slots[key], current == generation else { return } + source?.cancel() + connection.invalidate() + process.invalidate() + self.slots.removeValue(forKey: key) + } return } } } +@_cdecl("ashell_extension_observe") +func shellExtensionObserve(_ connection: NSXPCConnection, _ pid: pid_t) { + if #available(iOS 26.0, *), pid > 0 { + let registered = DispatchSemaphore(value: 0) + Task { @MainActor in + ShellWorkerPool.shared.observe(connection, pid: pid) { registered.signal() } + } + _ = registered.wait(timeout: .now() + 1) + } +} + @_cdecl("ashell_extension_disconnect") func shellExtensionDisconnect(_ connection: NSXPCConnection) { if #available(iOS 26.0, *) { diff --git a/ShellWorkers/NativePane.h b/ShellWorkers/NativePane.h index 6c3da7ab..b7dc1057 100644 --- a/ShellWorkers/NativePane.h +++ b/ShellWorkers/NativePane.h @@ -2,6 +2,7 @@ #define ASHELL_NATIVE_PANE_H #include +#include #ifdef __cplusplus extern "C" { @@ -48,6 +49,8 @@ int ashell_pane_spawn_ex(const char *command, const char *cwd, unsigned cols, un // Repeated polling after exit preserves status. Drain the returned fd to EOF independently. // A cleanup/restore error returns -errno after completion instead of hiding the failure. int ashell_pane_poll(void *job, int *status_out); +// Optional usage is the worker's RUSAGE_SELF snapshot at exit, unavailable for lost workers. +int ashell_pane_poll_usage(void *job, int *status_out, struct rusage *usage_out, int *usage_available); // These operations return 0 or POSITIVE errno, unlike poll. // Resize updates the worker command dimensions through ios_setWindowSize. diff --git a/ShellWorkers/NativePane.m b/ShellWorkers/NativePane.m index 94cdd49f..f6f9e747 100644 --- a/ShellWorkers/NativePane.m +++ b/ShellWorkers/NativePane.m @@ -29,6 +29,8 @@ pthread_cond_t ready; unsigned refs; int initialized, startup_error, done, status, completion_error; + struct rusage usage; + int usage_available; int peer_control, input_closed, is_pty; unsigned char eof_key; FILE *streams[3]; @@ -522,6 +524,11 @@ static int socket_options(int fd) { if (ios_setenv_pid(job->env[i], equals + 1, 1, pid)) error = EINVAL; *equals = '='; } + // Identify this worker's root by its reserved command PID. Setup + // calls cannot consume it, and nested children have different PIDs. + char root_pid[32]; + snprintf(root_pid, sizeof(root_pid), "%d", pid); + if (ios_setenv_pid("ASHELL_WORKER_ROOT_PID", root_pid, 1, pid)) error = EINVAL; pthread_mutex_lock(&job->mutex); job->initialized = 1; pthread_cond_signal(&job->ready); @@ -556,6 +563,7 @@ static int socket_options(int fd) { job->startup_error = job->initialized ? 0 : error; job->completion_error = error; job->initialized = 1; + job->usage_available = getrusage(RUSAGE_SELF, &job->usage) == 0; job->done = 1; NSLog(@"Shell pane lifecycle: exit key=%s status=%d error=%d", job->key, job->status, error); // Close the retained peer before announcing exit so output EOF is observable. @@ -728,11 +736,20 @@ int ashell_pane_spawn_ex(const char *command, const char *cwd, unsigned cols, un } int ashell_pane_poll(void *handle, int *status_out) { + return ashell_pane_poll_usage(handle, status_out, NULL, NULL); +} + +int ashell_pane_poll_usage(void *handle, int *status_out, struct rusage *usage_out, int *usage_available) { + if (usage_available) *usage_available = 0; if (!handle || !status_out) return -EINVAL; pane_job *job = handle; pthread_mutex_lock(&job->mutex); int result = job->completion_error ? -job->completion_error : job->done; - if (job->done) *status_out = job->status; + if (job->done) { + *status_out = job->status; + if (usage_out && job->usage_available) *usage_out = job->usage; + if (usage_available) *usage_available = job->usage_available; + } pthread_mutex_unlock(&job->mutex); return result; } diff --git a/ShellWorkers/PaneWire.h b/ShellWorkers/PaneWire.h index 63ddec83..5f0cf820 100644 --- a/ShellWorkers/PaneWire.h +++ b/ShellWorkers/PaneWire.h @@ -4,6 +4,7 @@ @protocol AShellPaneWire - (void)spawn:(NSString *)command cwd:(NSData *)cwd home:(NSData *)home documents:(NSData *)documents probe:(NSString *)probe env:(NSArray *)env cols:(unsigned)cols rows:(unsigned)rows tty:(BOOL)tty separateStderr:(BOOL)separateStderr reply:(void (^)(int, NSFileHandle *, NSFileHandle *, int))reply; - (void)poll:(void (^)(int, int))reply; +- (void)pollUsage:(void (^)(int, int, NSData *))reply; - (void)resize:(unsigned)cols rows:(unsigned)rows reply:(void (^)(int))reply; - (void)closeInput:(void (^)(int))reply; - (void)interrupt:(void (^)(int))reply; @@ -13,3 +14,15 @@ @end NSObject *ashell_worker_service(void); + +// A worker asks its owning host to allocate another isolated process. +@protocol AShellSpawnBrokerWire +- (void)spawnChild:(NSString *)command cwd:(NSString *)cwd env:(NSArray *)env cols:(unsigned)cols rows:(unsigned)rows tty:(BOOL)tty separateStderr:(BOOL)separate reply:(void (^)(int, NSString *, NSFileHandle *, NSFileHandle *))reply; +- (void)pollChild:(NSString *)token reply:(void (^)(int, int))reply; +- (void)pollChildUsage:(NSString *)token reply:(void (^)(int, int, NSData *))reply; +- (void)closeChildInput:(NSString *)token reply:(void (^)(int))reply; +- (void)cancelChild:(NSString *)token reply:(void (^)(int))reply; +- (void)releaseChild:(NSString *)token; +@end + +NSObject *ashell_spawn_broker(void); diff --git a/ShellWorkers/RemotePane.m b/ShellWorkers/RemotePane.m index ddca44df..a4870e0a 100644 --- a/ShellWorkers/RemotePane.m +++ b/ShellWorkers/RemotePane.m @@ -9,6 +9,7 @@ extern void ashell_extension_connect(void (^)(NSXPCConnection *, NSError *)); extern void ashell_extension_disconnect(NSXPCConnection *); +extern void ashell_extension_observe(NSXPCConnection *, pid_t); extern int ashell_extension_capacity(void); static void log_host_library(const char *phase) { @@ -45,6 +46,65 @@ - (void)dealloc { } @end +// A connection owns its descendants; invalidation releases the whole subtree. +@interface BrokerChild : NSObject +@property(nonatomic) void *job; +@end +@implementation BrokerChild +- (void)dealloc { if (_job) ashell_pane_release(_job); } +@end + +@interface SpawnBroker : NSObject +@property(nonatomic, strong) NSMutableDictionary *children; +@end +@implementation SpawnBroker +- (instancetype)init { + if ((self = [super init])) _children = [NSMutableDictionary new]; + return self; +} +- (void)spawnChild:(NSString *)command cwd:(NSString *)cwd env:(NSArray *)env cols:(unsigned)cols rows:(unsigned)rows tty:(BOOL)tty separateStderr:(BOOL)separate reply:(void (^)(int, NSString *, NSFileHandle *, NSFileHandle *))reply { + const char **values = calloc(env.count + 1, sizeof(*values)); + if (!values) { reply(ENOMEM, nil, nil, nil); return; } + for (NSUInteger i = 0; i < env.count; i++) values[i] = env[i].UTF8String; + int fd = -1; + BrokerChild *child = [BrokerChild new]; + ashell_pane_options options = { .size = sizeof(options), .tty = tty, .separate_stderr = separate }; + void *job = NULL; + int error = ashell_pane_spawn_ex(command.UTF8String, cwd.fileSystemRepresentation, cols, rows, values, &options, &fd, &job); + free(values); + if (error) { reply(error, nil, nil, nil); return; } + child.job = job; + NSString *token = NSUUID.UUID.UUIDString; + @synchronized (self) { self.children[token] = child; } + NSFileHandle *output = [[NSFileHandle alloc] initWithFileDescriptor:fd closeOnDealloc:YES]; + NSFileHandle *errors = options.stderr_fd >= 0 ? [[NSFileHandle alloc] initWithFileDescriptor:options.stderr_fd closeOnDealloc:YES] : nil; + reply(0, token, output, errors); +} +- (BrokerChild *)child:(NSString *)token { @synchronized (self) { return self.children[token]; } } +- (void)pollChild:(NSString *)token reply:(void (^)(int, int))reply { + [self pollChildUsage:token reply:^(int result, int status, NSData *usage) { reply(result, status); }]; +} +- (void)pollChildUsage:(NSString *)token reply:(void (^)(int, int, NSData *))reply { + BrokerChild *child = [self child:token]; + int status = 0; + int available = 0; + struct rusage usage; + int result = child ? ashell_pane_poll_usage(child.job, &status, &usage, &available) : -ESRCH; + reply(result, status, result == 1 && available ? [NSData dataWithBytes:&usage length:sizeof(usage)] : nil); +} +- (void)closeChildInput:(NSString *)token reply:(void (^)(int))reply { + BrokerChild *child = [self child:token]; + reply(child ? ashell_pane_close_stdin(child.job) : ESRCH); +} +- (void)cancelChild:(NSString *)token reply:(void (^)(int))reply { + BrokerChild *child = [self child:token]; + reply(child ? ashell_pane_cancel(child.job) : ESRCH); +} +- (void)releaseChild:(NSString *)token { @synchronized (self) { [self.children removeObjectForKey:token]; } } +@end + + + int ashell_pane_capacity(void) { return ashell_extension_capacity(); } int ashell_pane_spawn(const char *command, const char *cwd, unsigned cols, unsigned rows, const char *const *envp, int *fd_out, void **job_out) { @@ -125,6 +185,7 @@ int ashell_pane_spawn_ex(const char *command, const char *cwd, unsigned cols, un __block int fd = -1, err_fd = -1; BOOL separate = options->separate_stderr != 0; [pane.proxy spawn:@(command) cwd:cwdBookmark home:homeBookmark documents:documentsBookmark probe:pane.probeDirectory env:env cols:cols rows:rows tty:options->tty != 0 separateStderr:separate reply:^(int code, NSFileHandle *handle, NSFileHandle *errorHandle, int pid) { + ashell_extension_observe(pane.connection, pid); error = code; if (!code && (!handle || (separate && !errorHandle))) error = EPROTO; if (!error) { @@ -144,7 +205,12 @@ int ashell_pane_spawn_ex(const char *command, const char *cwd, unsigned cols, un }]; if (rmdir(pane.probeDirectory.fileSystemRepresentation) == 0) pane.probeDirectory = nil; if (!error && fd < 0) error = EPROTO; - if (error) { ashell_extension_disconnect(pane.connection); return error; } + if (error) { + int spawnError = error; + [pane.proxy terminate:^{}]; + ashell_extension_disconnect(pane.connection); + return spawnError; + } *fd_out = fd; options->stderr_fd = err_fd; *job_out = (__bridge_retained void *)pane; @@ -152,6 +218,10 @@ int ashell_pane_spawn_ex(const char *command, const char *cwd, unsigned cols, un } } int ashell_pane_poll(void *job, int *status_out) { + return ashell_pane_poll_usage(job, status_out, NULL, NULL); +} +int ashell_pane_poll_usage(void *job, int *status_out, struct rusage *usage_out, int *usage_available) { + if (usage_available) *usage_available = 0; RemotePane *pane = (__bridge RemotePane *)job; if (!pane || !status_out) return -EINVAL; // 137 is this backend's lost-worker status, not a recovered waitpid status. @@ -165,7 +235,14 @@ int ashell_pane_poll(void *job, int *status_out) { if (output.revents & POLLNVAL) return -EBADF; if (!(output.revents & (POLLHUP | POLLERR))) return 0; __block int result = -EPIPE; - [pane.proxy poll:^(int value, int status) { result = value; *status_out = status; }]; + [pane.proxy pollUsage:^(int value, int status, NSData *usage) { + result = value; + *status_out = status; + if (value == 1 && usage.length == sizeof(struct rusage)) { + if (usage_out) memcpy(usage_out, usage.bytes, sizeof(struct rusage)); + if (usage_available) *usage_available = 1; + } + }]; if (result == -EPIPE) { pane.interrupted = YES; *status_out = 137; return 1; } return result; } @@ -205,3 +282,5 @@ void ashell_pane_release(void *job) { [pane.proxy terminate:^{}]; ashell_extension_disconnect(pane.connection); } + +NSObject *ashell_spawn_broker(void) { return [SpawnBroker new]; } diff --git a/ShellWorkers/Worker.m b/ShellWorkers/Worker.m index 827dcac6..f4608aa2 100644 --- a/ShellWorkers/Worker.m +++ b/ShellWorkers/Worker.m @@ -8,6 +8,8 @@ #include #include #include +#undef kill +#include // ios_system discovers these stock host callbacks with dlsym. Each extension // owns one shell; its socket already carries input directly to the command. @@ -50,6 +52,96 @@ static int check_documents_write(NSURL *documents, NSString *probe) { return code; } +static id spawnBroker; +@interface BrokerRemoteChild : NSObject +@property(nonatomic, strong) id broker; +@property(nonatomic, copy) NSString *token; +@property(nonatomic, strong) NSFileHandle *output; +@property(nonatomic, strong) NSFileHandle *errors; +@end +@implementation BrokerRemoteChild +- (void)dealloc { if (_token) [_broker releaseChild:_token]; } +@end + +__attribute__((used, visibility("default"))) int ashell_worker_spawn_ex(const char *command, const char *cwd, unsigned cols, unsigned rows, const char *const *envp, ashell_pane_options *options, int *fd_out, void **job_out) { + if (fd_out) *fd_out = -1; + if (job_out) *job_out = NULL; + if (!options || options->size < sizeof(*options)) return EINVAL; + options->stderr_fd = -1; + if (!command || !*command || !cwd || cwd[0] != '/' || !cols || cols > 9999 || !rows || rows > 9999 || !fd_out || !job_out) return EINVAL; + if (!spawnBroker) return ENOTSUP; + NSMutableArray *env = [NSMutableArray new]; + for (size_t i = 0; envp && envp[i]; i++) [env addObject:@(envp[i])]; + BrokerRemoteChild *child = [BrokerRemoteChild new]; + child.broker = spawnBroker; + __block int error = EPIPE; + [child.broker spawnChild:@(command) cwd:@(cwd) env:env cols:cols rows:rows tty:options->tty != 0 separateStderr:options->separate_stderr != 0 reply:^(int code, NSString *token, NSFileHandle *output, NSFileHandle *errors) { + error = code; + if (error) return; + child.token = token; + child.output = output; + child.errors = errors; + if (!token || !output || (options->separate_stderr && !errors)) { error = EPROTO; return; } + *fd_out = dup(output.fileDescriptor); + if (*fd_out < 0) { error = errno; return; } + if (fcntl(*fd_out, F_SETFD, FD_CLOEXEC)) { error = errno; close(*fd_out); *fd_out = -1; return; } + if (errors) { + options->stderr_fd = dup(errors.fileDescriptor); + if (options->stderr_fd < 0) error = errno; + else if (fcntl(options->stderr_fd, F_SETFD, FD_CLOEXEC)) { + error = errno; + close(options->stderr_fd); + options->stderr_fd = -1; + } + if (error) { close(*fd_out); *fd_out = -1; } + } + }]; + if (error) return error; + *job_out = (__bridge_retained void *)child; + return 0; +} +__attribute__((used, visibility("default"))) int ashell_worker_poll_usage(void *handle, int *status, struct rusage *usage_out, int *usage_available) { + if (usage_available) *usage_available = 0; + if (!handle || !status) return -EINVAL; + BrokerRemoteChild *child = (__bridge BrokerRemoteChild *)handle; + struct pollfd fd = { child.output.fileDescriptor, POLLIN, 0 }; + if (poll(&fd, 1, 0) < 0) return errno == EINTR ? 0 : -errno; + if (fd.revents & POLLNVAL) return -EBADF; + if (!(fd.revents & (POLLHUP | POLLERR))) return 0; + __block int result = 1; + *status = 137; + [child.broker pollChildUsage:child.token reply:^(int value, int code, NSData *usage) { + result = value; + *status = code; + if (value == 1 && usage.length == sizeof(struct rusage)) { + if (usage_out) memcpy(usage_out, usage.bytes, sizeof(struct rusage)); + if (usage_available) *usage_available = 1; + } + }]; + return result; +} +__attribute__((used, visibility("default"))) int ashell_worker_poll(void *handle, int *status) { + return ashell_worker_poll_usage(handle, status, NULL, NULL); +} +__attribute__((used, visibility("default"))) int ashell_worker_close_stdin(void *handle) { + if (!handle) return EINVAL; + BrokerRemoteChild *child = (__bridge BrokerRemoteChild *)handle; + __block int result = EPIPE; + [child.broker closeChildInput:child.token reply:^(int code) { result = code; }]; + return result; +} +__attribute__((used, visibility("default"))) int ashell_worker_cancel(void *handle) { + if (!handle) return EINVAL; + BrokerRemoteChild *child = (__bridge BrokerRemoteChild *)handle; + __block int result = EPIPE; + [child.broker cancelChild:child.token reply:^(int code) { result = code; }]; + return result; +} +__attribute__((used, visibility("default"))) void ashell_worker_release(void *handle) { + BrokerRemoteChild *child = (__bridge_transfer BrokerRemoteChild *)handle; + (void)child; +} + @interface PaneService : NSObject @end @@ -59,6 +151,11 @@ @implementation PaneService { } - (void)spawn:(NSString *)command cwd:(NSData *)cwd home:(NSData *)home documents:(NSData *)documents probe:(NSString *)probe env:(NSArray *)env cols:(unsigned)cols rows:(unsigned)rows tty:(BOOL)tty separateStderr:(BOOL)separateStderr reply:(void (^)(int, NSFileHandle *, NSFileHandle *, int))reply { if (_pane) { reply(EBUSY, nil, nil, getpid()); return; } + NSXPCConnection *connection = NSXPCConnection.currentConnection; + connection.remoteObjectInterface = [NSXPCInterface interfaceWithProtocol:@protocol(AShellSpawnBrokerWire)]; + spawnBroker = [connection synchronousRemoteObjectProxyWithErrorHandler:^(NSError *failure) { + NSLog(@"Shell worker spawn broker disconnected: %@", failure); + }]; if (!cwd || !home || !documents || !probe) { reply(EINVAL, nil, nil, getpid()); return; } // Resolve the host's ephemeral grants explicitly and retain them for this // pane, including HOME for shared command configuration and authentication. @@ -104,10 +201,13 @@ - (void)spawn:(NSString *)command cwd:(NSData *)cwd home:(NSData *)home document if (!temporaryDirectory.length) { [self releasePane]; reply(EIO, nil, nil, getpid()); return; } // The host's directory bookmark does not grant its tmp directory. Keep the // extension's stock Foundation/libc temp domain in both environment views. - NSMutableArray *environment = [NSMutableArray arrayWithCapacity:env.count + 1]; + NSMutableArray *environment = [NSMutableArray arrayWithCapacity:env.count + 2]; for (NSString *value in env) - if (![value hasPrefix:@"TMPDIR="]) [environment addObject:value]; + if (![value hasPrefix:@"TMPDIR="] && ![value hasPrefix:@"HOME="]) [environment addObject:value]; [environment addObject:[@"TMPDIR=" stringByAppendingString:temporaryDirectory]]; + // A home-root bookmark can resolve relative to the extension's own home. + // Documents retains the host path; nested spawns need that same host HOME. + [environment addObject:[@"HOME=" stringByAppendingString:documentsURL.URLByDeletingLastPathComponent.path]]; env = environment; initializeEnvironment(); replaceCommand(@"clear", @"clear", true); @@ -146,9 +246,14 @@ - (void)spawn:(NSString *)command cwd:(NSData *)cwd home:(NSData *)home document reply(error, handle, errorHandle, getpid()); } - (void)poll:(void (^)(int, int))reply { + [self pollUsage:^(int result, int status, NSData *usage) { reply(result, status); }]; +} +- (void)pollUsage:(void (^)(int, int, NSData *))reply { int status = 0; - int result = _pane ? ashell_pane_poll(_pane, &status) : -ESRCH; - reply(result, status); + int available = 0; + struct rusage usage; + int result = _pane ? ashell_pane_poll_usage(_pane, &status, &usage, &available) : -ESRCH; + reply(result, status, result == 1 && available ? [NSData dataWithBytes:&usage length:sizeof(usage)] : nil); } - (void)resize:(unsigned)cols rows:(unsigned)rows reply:(void (^)(int))reply { reply(_pane ? ashell_pane_resize(_pane, cols, rows) : ESRCH); diff --git a/a-Shell/a-Shell-Bridging-Header.h b/a-Shell/a-Shell-Bridging-Header.h index 0282b277..1efe2b86 100644 --- a/a-Shell/a-Shell-Bridging-Header.h +++ b/a-Shell/a-Shell-Bridging-Header.h @@ -3,3 +3,4 @@ // #import "KBWebViewBase.h" +#import "../ShellWorkers/PaneWire.h" From cfe5cdcbceb40944b048221e9ec727fef9c699dc Mon Sep 17 00:00:00 2001 From: demrom <47463752+demrom@users.noreply.github.com> Date: Fri, 23 Oct 2026 08:45:10 +0300 Subject: [PATCH 07/16] Expose an explicit host pane broker and report lost worker connections --- Resources/bin/dash | 0 Resources_mini/bin/dash | 0 ShellWorkers/HostPane.h | 42 +++++++++ ShellWorkers/HostPane.m | 12 +++ ShellWorkers/NativePane.h | 2 + ShellWorkers/NativePane.m | 3 + ShellWorkers/PaneWire.h | 3 + ShellWorkers/RemotePane.m | 45 +++++++--- ShellWorkers/Worker.m | 55 +++++++++--- a-Shell.xcodeproj/project.pbxproj | 10 +++ tests/worker-pool.m | 142 ++++++++++++++++++++++++++++-- tests/worker-pool.md | 9 ++ 12 files changed, 294 insertions(+), 29 deletions(-) create mode 100644 Resources/bin/dash create mode 100644 Resources_mini/bin/dash create mode 100644 ShellWorkers/HostPane.h create mode 100644 ShellWorkers/HostPane.m diff --git a/Resources/bin/dash b/Resources/bin/dash new file mode 100644 index 00000000..e69de29b diff --git a/Resources_mini/bin/dash b/Resources_mini/bin/dash new file mode 100644 index 00000000..e69de29b diff --git a/ShellWorkers/HostPane.h b/ShellWorkers/HostPane.h new file mode 100644 index 00000000..b5f6c7f9 --- /dev/null +++ b/ShellWorkers/HostPane.h @@ -0,0 +1,42 @@ +#ifndef ASHELL_HOST_PANE_H +#define ASHELL_HOST_PANE_H + +#include "NativePane.h" + +#ifdef __cplusplus +extern "C" { +#endif +#pragma GCC visibility push(default) + +// Explicit app-process runner for commands that exceed the extension memory limit. +// Uses the same streams, options and handle lifetime as NativePane.h. One live host +// pane is admitted (EBUSY for another); it shares the app's process cwd and memory. +// No extension allocation or automatic routing is performed. Do not forward worker +// identity environment overrides. Cancellation returns ENOTSUP while running; +// interrupt targets the foreground command, and release retains live cleanup. +int ashell_host_pane_spawn(const char *, const char *, unsigned, unsigned, const char *const *, int *, void **); +int ashell_host_pane_spawn_ex(const char *, const char *, unsigned, unsigned, const char *const *, ashell_pane_options *, int *, void **); +int ashell_host_pane_poll(void *, int *); +int ashell_host_pane_poll_usage(void *, int *, struct rusage *, int *); +int ashell_host_pane_resize(void *, unsigned, unsigned); +int ashell_host_pane_close_stdin(void *); +int ashell_host_pane_interrupt(void *); +int ashell_host_pane_cancel(void *); +void ashell_host_pane_release(void *); + +// Explicit worker-to-host broker route. Handles remain in the calling worker; +// poll reports -EPIPE if the host connection is lost. No admission policy here. +int ashell_worker_host_spawn_ex(const char *, const char *, unsigned, unsigned, const char *const *, ashell_pane_options *, int *, void **); +int ashell_worker_host_poll(void *, int *); +int ashell_worker_host_poll_usage(void *, int *, struct rusage *, int *); +int ashell_worker_host_resize(void *, unsigned, unsigned); +int ashell_worker_host_close_stdin(void *); +int ashell_worker_host_interrupt(void *); +int ashell_worker_host_cancel(void *); +void ashell_worker_host_release(void *); + +#pragma GCC visibility pop +#ifdef __cplusplus +} +#endif +#endif diff --git a/ShellWorkers/HostPane.m b/ShellWorkers/HostPane.m new file mode 100644 index 00000000..76521dee --- /dev/null +++ b/ShellWorkers/HostPane.m @@ -0,0 +1,12 @@ +// Compile the same native runner in the app under a distinct public ABI. +#define ASHELL_HOST_PANE 1 +#define ashell_pane_spawn ashell_host_pane_spawn +#define ashell_pane_spawn_ex ashell_host_pane_spawn_ex +#define ashell_pane_poll ashell_host_pane_poll +#define ashell_pane_poll_usage ashell_host_pane_poll_usage +#define ashell_pane_resize ashell_host_pane_resize +#define ashell_pane_close_stdin ashell_host_pane_close_stdin +#define ashell_pane_interrupt ashell_host_pane_interrupt +#define ashell_pane_cancel ashell_host_pane_cancel +#define ashell_pane_release ashell_host_pane_release +#include "NativePane.m" diff --git a/ShellWorkers/NativePane.h b/ShellWorkers/NativePane.h index b7dc1057..85b2e028 100644 --- a/ShellWorkers/NativePane.h +++ b/ShellWorkers/NativePane.h @@ -48,6 +48,8 @@ int ashell_pane_spawn_ex(const char *command, const char *cwd, unsigned cols, un // Returns 0 running, 1 exited (writes final command status), or -errno. // Repeated polling after exit preserves status. Drain the returned fd to EOF independently. // A cleanup/restore error returns -errno after completion instead of hiding the failure. +// Lost XPC returns -EPIPE without inventing an exit status. Explicit cancellation +// returns 137; that status does not identify the cause of an unexpected process loss. int ashell_pane_poll(void *job, int *status_out); // Optional usage is the worker's RUSAGE_SELF snapshot at exit, unavailable for lost workers. int ashell_pane_poll_usage(void *job, int *status_out, struct rusage *usage_out, int *usage_available); diff --git a/ShellWorkers/NativePane.m b/ShellWorkers/NativePane.m index f6f9e747..920dee96 100644 --- a/ShellWorkers/NativePane.m +++ b/ShellWorkers/NativePane.m @@ -524,11 +524,14 @@ static int socket_options(int fd) { if (ios_setenv_pid(job->env[i], equals + 1, 1, pid)) error = EINVAL; *equals = '='; } +#ifndef ASHELL_HOST_PANE // Identify this worker's root by its reserved command PID. Setup // calls cannot consume it, and nested children have different PIDs. char root_pid[32]; snprintf(root_pid, sizeof(root_pid), "%d", pid); if (ios_setenv_pid("ASHELL_WORKER_ROOT_PID", root_pid, 1, pid)) error = EINVAL; + if (setenv("ASHELL_WORKER_ROOT_PID", root_pid, 1)) error = errno; +#endif pthread_mutex_lock(&job->mutex); job->initialized = 1; pthread_cond_signal(&job->ready); diff --git a/ShellWorkers/PaneWire.h b/ShellWorkers/PaneWire.h index 5f0cf820..e83a8805 100644 --- a/ShellWorkers/PaneWire.h +++ b/ShellWorkers/PaneWire.h @@ -18,10 +18,13 @@ NSObject *ashell_worker_service(void); // A worker asks its owning host to allocate another isolated process. @protocol AShellSpawnBrokerWire - (void)spawnChild:(NSString *)command cwd:(NSString *)cwd env:(NSArray *)env cols:(unsigned)cols rows:(unsigned)rows tty:(BOOL)tty separateStderr:(BOOL)separate reply:(void (^)(int, NSString *, NSFileHandle *, NSFileHandle *))reply; +- (void)spawnHostChild:(NSString *)command cwd:(NSString *)cwd env:(NSArray *)env cols:(unsigned)cols rows:(unsigned)rows tty:(BOOL)tty separateStderr:(BOOL)separate reply:(void (^)(int, NSString *, NSFileHandle *, NSFileHandle *))reply; - (void)pollChild:(NSString *)token reply:(void (^)(int, int))reply; - (void)pollChildUsage:(NSString *)token reply:(void (^)(int, int, NSData *))reply; - (void)closeChildInput:(NSString *)token reply:(void (^)(int))reply; - (void)cancelChild:(NSString *)token reply:(void (^)(int))reply; +- (void)resizeChild:(NSString *)token cols:(unsigned)cols rows:(unsigned)rows reply:(void (^)(int))reply; +- (void)interruptChild:(NSString *)token reply:(void (^)(int))reply; - (void)releaseChild:(NSString *)token; @end diff --git a/ShellWorkers/RemotePane.m b/ShellWorkers/RemotePane.m index a4870e0a..df4a2db4 100644 --- a/ShellWorkers/RemotePane.m +++ b/ShellWorkers/RemotePane.m @@ -1,4 +1,5 @@ #import "PaneWire.h" +#import "HostPane.h" #include "NativePane.h" #include #include @@ -28,6 +29,7 @@ @interface RemotePane : NSObject @property(nonatomic, strong) NSFileHandle *output; @property(nonatomic, strong) NSFileHandle *errorOutput; @property(atomic) BOOL interrupted; +@property(atomic) BOOL cancelled; @property(nonatomic, strong) NSMutableArray *directories; @property(nonatomic, copy) NSString *probeDirectory; @end @@ -49,9 +51,10 @@ - (void)dealloc { // A connection owns its descendants; invalidation releases the whole subtree. @interface BrokerChild : NSObject @property(nonatomic) void *job; +@property(nonatomic) BOOL host; @end @implementation BrokerChild -- (void)dealloc { if (_job) ashell_pane_release(_job); } +- (void)dealloc { if (_job) { if (_host) ashell_host_pane_release(_job); else ashell_pane_release(_job); } } @end @interface SpawnBroker : NSObject @@ -63,14 +66,27 @@ - (instancetype)init { return self; } - (void)spawnChild:(NSString *)command cwd:(NSString *)cwd env:(NSArray *)env cols:(unsigned)cols rows:(unsigned)rows tty:(BOOL)tty separateStderr:(BOOL)separate reply:(void (^)(int, NSString *, NSFileHandle *, NSFileHandle *))reply { - const char **values = calloc(env.count + 1, sizeof(*values)); + [self spawn:command cwd:cwd env:env cols:cols rows:rows tty:tty separateStderr:separate host:NO reply:reply]; +} +- (void)spawnHostChild:(NSString *)command cwd:(NSString *)cwd env:(NSArray *)env cols:(unsigned)cols rows:(unsigned)rows tty:(BOOL)tty separateStderr:(BOOL)separate reply:(void (^)(int, NSString *, NSFileHandle *, NSFileHandle *))reply { + [self spawn:command cwd:cwd env:env cols:cols rows:rows tty:tty separateStderr:separate host:YES reply:reply]; +} +- (void)spawn:(NSString *)command cwd:(NSString *)cwd env:(NSArray *)env cols:(unsigned)cols rows:(unsigned)rows tty:(BOOL)tty separateStderr:(BOOL)separate host:(BOOL)host reply:(void (^)(int, NSString *, NSFileHandle *, NSFileHandle *))reply { + const char **values = calloc(env.count + 2, sizeof(*values)); if (!values) { reply(ENOMEM, nil, nil, nil); return; } - for (NSUInteger i = 0; i < env.count; i++) values[i] = env[i].UTF8String; + NSUInteger count = 0; + for (NSString *value in env) { + if (host && ([value hasPrefix:@"ASHELL_WORKER_PID="] || [value hasPrefix:@"ASHELL_WORKER_ROOT_PID="] || [value hasPrefix:@"TMPDIR="])) continue; + values[count++] = value.UTF8String; + } + NSString *temporary = host ? [@"TMPDIR=" stringByAppendingString:NSTemporaryDirectory()] : nil; + if (temporary) values[count++] = temporary.UTF8String; int fd = -1; BrokerChild *child = [BrokerChild new]; + child.host = host; ashell_pane_options options = { .size = sizeof(options), .tty = tty, .separate_stderr = separate }; void *job = NULL; - int error = ashell_pane_spawn_ex(command.UTF8String, cwd.fileSystemRepresentation, cols, rows, values, &options, &fd, &job); + int error = (host ? ashell_host_pane_spawn_ex : ashell_pane_spawn_ex)(command.UTF8String, cwd.fileSystemRepresentation, cols, rows, values, &options, &fd, &job); free(values); if (error) { reply(error, nil, nil, nil); return; } child.job = job; @@ -89,16 +105,24 @@ - (void)pollChildUsage:(NSString *)token reply:(void (^)(int, int, NSData *))rep int status = 0; int available = 0; struct rusage usage; - int result = child ? ashell_pane_poll_usage(child.job, &status, &usage, &available) : -ESRCH; + int result = child ? (child.host ? ashell_host_pane_poll_usage : ashell_pane_poll_usage)(child.job, &status, &usage, &available) : -ESRCH; reply(result, status, result == 1 && available ? [NSData dataWithBytes:&usage length:sizeof(usage)] : nil); } - (void)closeChildInput:(NSString *)token reply:(void (^)(int))reply { BrokerChild *child = [self child:token]; - reply(child ? ashell_pane_close_stdin(child.job) : ESRCH); + reply(child ? (child.host ? ashell_host_pane_close_stdin : ashell_pane_close_stdin)(child.job) : ESRCH); } - (void)cancelChild:(NSString *)token reply:(void (^)(int))reply { BrokerChild *child = [self child:token]; - reply(child ? ashell_pane_cancel(child.job) : ESRCH); + reply(child ? (child.host ? ashell_host_pane_cancel : ashell_pane_cancel)(child.job) : ESRCH); +} +- (void)resizeChild:(NSString *)token cols:(unsigned)cols rows:(unsigned)rows reply:(void (^)(int))reply { + BrokerChild *child = [self child:token]; + reply(child ? (child.host ? ashell_host_pane_resize : ashell_pane_resize)(child.job, cols, rows) : ESRCH); +} +- (void)interruptChild:(NSString *)token reply:(void (^)(int))reply { + BrokerChild *child = [self child:token]; + reply(child ? (child.host ? ashell_host_pane_interrupt : ashell_pane_interrupt)(child.job) : ESRCH); } - (void)releaseChild:(NSString *)token { @synchronized (self) { [self.children removeObjectForKey:token]; } } @end @@ -224,8 +248,8 @@ int ashell_pane_poll_usage(void *job, int *status_out, struct rusage *usage_out, if (usage_available) *usage_available = 0; RemotePane *pane = (__bridge RemotePane *)job; if (!pane || !status_out) return -EINVAL; - // 137 is this backend's lost-worker status, not a recovered waitpid status. - if (pane.interrupted) { *status_out = 137; return 1; } + if (pane.cancelled) { *status_out = 137; return 1; } + if (pane.interrupted) return -EPIPE; // The worker closes its socket streams before publishing completion. A // hangup is observable even with unread output; keep polling local until // then instead of waking the worker with an XPC request on every poll. @@ -243,7 +267,7 @@ int ashell_pane_poll_usage(void *job, int *status_out, struct rusage *usage_out, if (usage_available) *usage_available = 1; } }]; - if (result == -EPIPE) { pane.interrupted = YES; *status_out = 137; return 1; } + if (result == -EPIPE) pane.interrupted = YES; return result; } int ashell_pane_resize(void *job, unsigned cols, unsigned rows) { @@ -270,6 +294,7 @@ int ashell_pane_interrupt(void *job) { int ashell_pane_cancel(void *job) { RemotePane *pane = (__bridge RemotePane *)job; if (!pane) return EINVAL; + pane.cancelled = YES; pane.interrupted = YES; [pane.proxy terminate:^{}]; ashell_extension_disconnect(pane.connection); diff --git a/ShellWorkers/Worker.m b/ShellWorkers/Worker.m index f4608aa2..79b8052a 100644 --- a/ShellWorkers/Worker.m +++ b/ShellWorkers/Worker.m @@ -63,7 +63,7 @@ @implementation BrokerRemoteChild - (void)dealloc { if (_token) [_broker releaseChild:_token]; } @end -__attribute__((used, visibility("default"))) int ashell_worker_spawn_ex(const char *command, const char *cwd, unsigned cols, unsigned rows, const char *const *envp, ashell_pane_options *options, int *fd_out, void **job_out) { +static int broker_spawn(BOOL host, const char *command, const char *cwd, unsigned cols, unsigned rows, const char *const *envp, ashell_pane_options *options, int *fd_out, void **job_out) { if (fd_out) *fd_out = -1; if (job_out) *job_out = NULL; if (!options || options->size < sizeof(*options)) return EINVAL; @@ -75,7 +75,7 @@ - (void)dealloc { if (_token) [_broker releaseChild:_token]; } BrokerRemoteChild *child = [BrokerRemoteChild new]; child.broker = spawnBroker; __block int error = EPIPE; - [child.broker spawnChild:@(command) cwd:@(cwd) env:env cols:cols rows:rows tty:options->tty != 0 separateStderr:options->separate_stderr != 0 reply:^(int code, NSString *token, NSFileHandle *output, NSFileHandle *errors) { + void (^reply)(int, NSString *, NSFileHandle *, NSFileHandle *) = ^(int code, NSString *token, NSFileHandle *output, NSFileHandle *errors) { error = code; if (error) return; child.token = token; @@ -95,11 +95,19 @@ - (void)dealloc { if (_token) [_broker releaseChild:_token]; } } if (error) { close(*fd_out); *fd_out = -1; } } - }]; + }; + if (host) [child.broker spawnHostChild:@(command) cwd:@(cwd) env:env cols:cols rows:rows tty:options->tty != 0 separateStderr:options->separate_stderr != 0 reply:reply]; + else [child.broker spawnChild:@(command) cwd:@(cwd) env:env cols:cols rows:rows tty:options->tty != 0 separateStderr:options->separate_stderr != 0 reply:reply]; if (error) return error; *job_out = (__bridge_retained void *)child; return 0; } +__attribute__((used, visibility("default"))) int ashell_worker_spawn_ex(const char *command, const char *cwd, unsigned cols, unsigned rows, const char *const *envp, ashell_pane_options *options, int *fd_out, void **job_out) { + return broker_spawn(NO, command, cwd, cols, rows, envp, options, fd_out, job_out); +} +__attribute__((used, visibility("default"))) int ashell_worker_host_spawn_ex(const char *command, const char *cwd, unsigned cols, unsigned rows, const char *const *envp, ashell_pane_options *options, int *fd_out, void **job_out) { + return broker_spawn(YES, command, cwd, cols, rows, envp, options, fd_out, job_out); +} __attribute__((used, visibility("default"))) int ashell_worker_poll_usage(void *handle, int *status, struct rusage *usage_out, int *usage_available) { if (usage_available) *usage_available = 0; if (!handle || !status) return -EINVAL; @@ -108,11 +116,10 @@ - (void)dealloc { if (_token) [_broker releaseChild:_token]; } if (poll(&fd, 1, 0) < 0) return errno == EINTR ? 0 : -errno; if (fd.revents & POLLNVAL) return -EBADF; if (!(fd.revents & (POLLHUP | POLLERR))) return 0; - __block int result = 1; - *status = 137; + __block int result = -EPIPE; [child.broker pollChildUsage:child.token reply:^(int value, int code, NSData *usage) { result = value; - *status = code; + if (value == 1) *status = code; if (value == 1 && usage.length == sizeof(struct rusage)) { if (usage_out) memcpy(usage_out, usage.bytes, sizeof(struct rusage)); if (usage_available) *usage_available = 1; @@ -142,6 +149,27 @@ - (void)dealloc { if (_token) [_broker releaseChild:_token]; } (void)child; } +// Host children use the same broker tokens and stream ownership as extension children. +__attribute__((used, visibility("default"))) int ashell_worker_host_poll(void *job, int *status) { return ashell_worker_poll(job, status); } +__attribute__((used, visibility("default"))) int ashell_worker_host_poll_usage(void *job, int *status, struct rusage *usage, int *available) { return ashell_worker_poll_usage(job, status, usage, available); } +__attribute__((used, visibility("default"))) int ashell_worker_host_close_stdin(void *job) { return ashell_worker_close_stdin(job); } +__attribute__((used, visibility("default"))) int ashell_worker_host_cancel(void *job) { return ashell_worker_cancel(job); } +__attribute__((used, visibility("default"))) void ashell_worker_host_release(void *job) { ashell_worker_release(job); } +__attribute__((used, visibility("default"))) int ashell_worker_host_resize(void *job, unsigned cols, unsigned rows) { + if (!job) return EINVAL; + BrokerRemoteChild *child = (__bridge BrokerRemoteChild *)job; + __block int result = EPIPE; + [child.broker resizeChild:child.token cols:cols rows:rows reply:^(int code) { result = code; }]; + return result; +} +__attribute__((used, visibility("default"))) int ashell_worker_host_interrupt(void *job) { + if (!job) return EINVAL; + BrokerRemoteChild *child = (__bridge BrokerRemoteChild *)job; + __block int result = EPIPE; + [child.broker interruptChild:child.token reply:^(int code) { result = code; }]; + return result; +} + @interface PaneService : NSObject @end @@ -201,13 +229,16 @@ - (void)spawn:(NSString *)command cwd:(NSData *)cwd home:(NSData *)home document if (!temporaryDirectory.length) { [self releasePane]; reply(EIO, nil, nil, getpid()); return; } // The host's directory bookmark does not grant its tmp directory. Keep the // extension's stock Foundation/libc temp domain in both environment views. - NSMutableArray *environment = [NSMutableArray arrayWithCapacity:env.count + 2]; + NSMutableArray *environment = [NSMutableArray arrayWithCapacity:env.count + 3]; for (NSString *value in env) - if (![value hasPrefix:@"TMPDIR="] && ![value hasPrefix:@"HOME="]) [environment addObject:value]; + if (![value hasPrefix:@"TMPDIR="] && ![value hasPrefix:@"HOME="] && + ![value hasPrefix:@"ASHELL_WORKER_PID="] && ![value hasPrefix:@"ASHELL_WORKER_ROOT_PID="]) [environment addObject:value]; [environment addObject:[@"TMPDIR=" stringByAppendingString:temporaryDirectory]]; // A home-root bookmark can resolve relative to the extension's own home. // Documents retains the host path; nested spawns need that same host HOME. [environment addObject:[@"HOME=" stringByAppendingString:documentsURL.URLByDeletingLastPathComponent.path]]; + // Dash imports libc environ when it initializes its own variable table. + [environment addObject:[NSString stringWithFormat:@"ASHELL_WORKER_PID=%d", getpid()]]; env = environment; initializeEnvironment(); replaceCommand(@"clear", @"clear", true); @@ -227,15 +258,13 @@ - (void)spawn:(NSString *)command cwd:(NSData *)cwd home:(NSData *)home document // The physical device has no kernel tty. Use the same socket-backed route // on Simulator so its acceptance cannot pass through a desktop-only PTY. if (setenv("ASHELL_IOS_PTY", "0", 1)) { int error = errno; [self releasePane]; reply(error, nil, nil, getpid()); return; } - const char **values = calloc(env.count + 4, sizeof(char *)); + const char **values = calloc(env.count + 3, sizeof(char *)); if (!values) { [self releasePane]; reply(ENOMEM, nil, nil, getpid()); return; } for (NSUInteger i = 0; i < env.count; i++) values[i] = [env[i] hasPrefix:@"ASHELL_IOS_PTY="] ? "ASHELL_IOS_PTY=0" : [env[i] hasPrefix:@"LC_CTYPE="] ? "LC_CTYPE=UTF-8" : env[i].UTF8String; - NSString *identity = [NSString stringWithFormat:@"ASHELL_WORKER_PID=%d", getpid()]; - values[env.count] = identity.UTF8String; - values[env.count + 1] = "LC_CTYPE=UTF-8"; - values[env.count + 2] = "ASHELL_IOS_SOCKET_INPUT=1"; + values[env.count] = "LC_CTYPE=UTF-8"; + values[env.count + 1] = "ASHELL_IOS_SOCKET_INPUT=1"; int fd = -1; ashell_pane_options options = {.size = sizeof(options), .tty = tty, .separate_stderr = separateStderr}; int error = ashell_pane_spawn_ex(command.UTF8String, directory.path.UTF8String, cols, rows, values, &options, &fd, &_pane); diff --git a/a-Shell.xcodeproj/project.pbxproj b/a-Shell.xcodeproj/project.pbxproj index 049ec457..5ccda210 100644 --- a/a-Shell.xcodeproj/project.pbxproj +++ b/a-Shell.xcodeproj/project.pbxproj @@ -996,6 +996,10 @@ isa = "PBXBuildFile"; fileRef = "D847238C57D48CADF264887A"; }; + 601A00000000000000000001 = { + isa = "PBXBuildFile"; + fileRef = "601A00000000000000000002"; + }; C608987E0D35D7D8C92CF091 = { isa = "PBXBuildFile"; fileRef = "11189940A2AF26A6542BDEAF"; @@ -3064,6 +3068,11 @@ path = "ShellWorkers/Host.swift"; sourceTree = "SOURCE_ROOT"; }; + 601A00000000000000000002 = { + isa = "PBXFileReference"; + path = "ShellWorkers/HostPane.m"; + sourceTree = "SOURCE_ROOT"; + }; 11189940A2AF26A6542BDEAF = { isa = "PBXFileReference"; path = "ShellWorkers/RemotePane.m"; @@ -5257,6 +5266,7 @@ buildActionMask = 2147483647; files = ( C608987E0D35D7D8C92CF091, + "601A00000000000000000001", 7A025EB47CCEBDAA7E49CB3E, 229D2477257931E4004A78AC /* ExtraCommands.swift in Sources */, 707000000000000000000003 /* TerminalFont.swift in Sources */, diff --git a/tests/worker-pool.m b/tests/worker-pool.m index 90a77bf7..dc9394a1 100644 --- a/tests/worker-pool.m +++ b/tests/worker-pool.m @@ -8,6 +8,7 @@ #include #include #include +#include "../ShellWorkers/NativePane.h" #define MAX_WORKERS 64 @@ -163,8 +164,8 @@ static int lifecycle_check(BOOL busy, BOOL closeOnly) { if (result) break; usleep(10000); } - lost = lost && result == 1 && status == 137; - fprintf(thread_stdout, "lost worker status=%d: %s\n", status, lost ? "PASS" : "FAIL"); + lost = lost && result == -EPIPE && status == -1; + fprintf(thread_stdout, "lost worker result=%d status=%d: %s\n", result, status, lost ? "PASS" : "FAIL"); passed = lost; release(jobs[1]); jobs[1] = NULL; close(descriptors[1]); descriptors[1] = -1; @@ -197,7 +198,7 @@ static int lifecycle_check(BOOL busy, BOOL closeOnly) { } // Exit status must not depend on the consumer draining pending output first. -static int completion_check(void) { +static int completion_check(BOOL identity) { int (*spawn)(const char *, const char *, unsigned, unsigned, const char *const *, int *, void **) = dlsym(RTLD_DEFAULT, "ashell_pane_spawn"); int (*pollJob)(void *, int *) = dlsym(RTLD_DEFAULT, "ashell_pane_poll"); void (*release)(void *) = dlsym(RTLD_DEFAULT, "ashell_pane_release"); @@ -205,7 +206,9 @@ static int completion_check(void) { NSString *directory = [NSHomeDirectory() stringByAppendingPathComponent:@"Documents"]; int descriptor = -1, status = -1, result = 0; void *job = NULL; - int error = spawn("dash -c 'printf pending-output; exit 7'", directory.fileSystemRepresentation, 80, 24, NULL, &descriptor, &job); + const char *environment[] = {"ASHELL_WORKER_PID=-1", "ASHELL_WORKER_ROOT_PID=-1", NULL}; + const char *command = identity ? "dash -c 'test \"$ASHELL_WORKER_PID\" -gt 0 && test \"$ASHELL_WORKER_ROOT_PID\" -gt 0 && dash -c \"test \\\"\\$ASHELL_WORKER_PID\\\" -gt 0 && test \\\"\\$ASHELL_WORKER_ROOT_PID\\\" -gt 0\" && printf pending-output; exit 7'" : "dash -c 'printf pending-output; exit 7'"; + int error = spawn(command, directory.fileSystemRepresentation, 80, 24, identity ? environment : NULL, &descriptor, &job); if (error) { fprintf(thread_stdout, "completion startup=%d: FAIL\n", error); return 1; } NSTimeInterval deadline = NSProcessInfo.processInfo.systemUptime + 10; while (!result && NSProcessInfo.processInfo.systemUptime < deadline) { @@ -221,7 +224,7 @@ static int completion_check(void) { if (passed) passed = read(descriptor, output, 1) == 0; release(job); close(descriptor); - fprintf(thread_stdout, "status before output drain and repeated status: %s\n", passed ? "PASS" : "FAIL"); + fprintf(thread_stdout, "%s: %s\n", identity ? "Dash identity and nested command lookup" : "status before output drain and repeated status", passed ? "PASS" : "FAIL"); return passed ? 0 : 1; } @@ -256,10 +259,137 @@ static int limit_check(void) { return passed ? 0 : 1; } +// Exercise the same public ABI from a host command and from its real worker child. +static int spawn_contract(BOOL nested, BOOL host) { + int (*spawn)(const char *, const char *, unsigned, unsigned, const char *const *, ashell_pane_options *, int *, void **) = dlsym(RTLD_DEFAULT, nested ? (host ? "ashell_worker_host_spawn_ex" : "ashell_worker_spawn_ex") : "ashell_pane_spawn_ex"); + int (*pollJob)(void *, int *, struct rusage *, int *) = dlsym(RTLD_DEFAULT, nested ? (host ? "ashell_worker_host_poll_usage" : "ashell_worker_poll_usage") : "ashell_pane_poll_usage"); + void (*release)(void *) = dlsym(RTLD_DEFAULT, nested ? (host ? "ashell_worker_host_release" : "ashell_worker_release") : "ashell_pane_release"); + if (!spawn || !pollJob || !release) { fputs("spawn contract unavailable: FAIL\n", thread_stdout); return 1; } + NSString *home = nested ? @(getenv("HOME")) : NSHomeDirectory(); + NSString *directory = [home stringByAppendingPathComponent:@"Documents"]; + NSString *homeValue = [@"HOME=" stringByAppendingString:home]; + NSString *expected = [@"POOL_EXPECTED_HOME=" stringByAppendingString:home]; + NSString *parent = [NSString stringWithFormat:@"POOL_PARENT_PID=%d", getpid()]; + NSString *hostPID = nested ? @(getenv("POOL_HOST_PID") ?: "") : [NSString stringWithFormat:@"%d", getpid()]; + NSString *hostValue = [@"POOL_HOST_PID=" stringByAppendingString:hostPID]; + const char *environment[] = {homeValue.UTF8String, expected.UTF8String, parent.UTF8String, hostValue.UTF8String, NULL}; + ashell_pane_options options = {.size = sizeof(options), .tty = 0, .separate_stderr = 1}; + int fd = -1, status = -1; + void *job = NULL; + const char *command = nested ? (host ? "worker-pool-host-payload" : "worker-pool-payload") : (host ? "worker-pool-nested-host" : "worker-pool-nested"); + int error = spawn(command, directory.fileSystemRepresentation, 80, 24, environment, &options, &fd, &job); + if (error) { fprintf(thread_stdout, "spawn contract startup=%d: FAIL\n", error); return 1; } + int descriptors[] = {fd, options.stderr_fd}; + char bytes[2][256] = {{0}}; + size_t used[2] = {0}; + BOOL eof[2] = {NO, NO}, completed = NO, valid = YES; + for (int i = 0; i < 2; i++) + if (fcntl(descriptors[i], F_SETFL, O_NONBLOCK) || !(fcntl(descriptors[i], F_GETFD) & FD_CLOEXEC)) valid = NO; + NSTimeInterval deadline = NSProcessInfo.processInfo.systemUptime + 20; + struct rusage usage; + int available = 0; + while (valid && NSProcessInfo.processInfo.systemUptime < deadline && (!completed || !eof[0] || !eof[1])) { + for (int i = 0; i < 2; i++) { + ssize_t count = read(descriptors[i], bytes[i] + used[i], sizeof(bytes[i]) - used[i] - 1); + if (count > 0) used[i] += count; + else if (!count) eof[i] = YES; + else if (errno != EAGAIN && errno != EINTR) valid = NO; + } + int result = pollJob(job, &status, &usage, &available); + if (result < 0) valid = NO; + if (result == 1) completed = YES; + if (!completed || !eof[0] || !eof[1]) usleep(10000); + } + BOOL passed = valid && completed && eof[0] && eof[1] && available && status == 7 && + !strcmp(bytes[0], "PAYLOAD\n") && !strcmp(bytes[1], "ERROR\n"); + if (!passed) fprintf(thread_stderr, "spawn contract nested=%d valid=%d completed=%d eof=%d,%d usage=%d status=%d stdout=[%s] stderr=[%s]\n", nested, valid, completed, eof[0], eof[1], available, status, bytes[0], bytes[1]); + release(job); + close(fd); + close(options.stderr_fd); + if (!nested) fprintf(thread_stdout, "nested %s streams/tty/home/tmpdir/status/usage: %s\n", host ? "host" : "worker", passed ? "PASS" : "FAIL"); + return passed ? 0 : 1; +} + +int worker_pool_nested_main(int argc, char **argv) { + const char *home = getenv("HOME"), *expected = getenv("POOL_EXPECTED_HOME"); + const char *root = ios_getenv("ASHELL_WORKER_ROOT_PID"); + if (!root || atoi(root) != ios_currentPid() || !home || !expected || strcmp(home, expected) || spawn_contract(YES, NO)) return 1; + fputs("PAYLOAD\n", thread_stdout); + fputs("ERROR\n", thread_stderr); + return 7; +} + +int worker_pool_nested_host_main(int argc, char **argv) { + if (spawn_contract(YES, YES)) return 1; + fputs("PAYLOAD\n", thread_stdout); + fputs("ERROR\n", thread_stderr); + return 7; +} + +int worker_pool_host_payload_main(int argc, char **argv) { + const char *host = ios_getenv("POOL_HOST_PID"); + if (!host || atoi(host) != getpid() || ios_getenv("ASHELL_WORKER_PID") || ios_getenv("ASHELL_WORKER_ROOT_PID") || ios_isatty(1)) return 1; + int (*spawn)(const char *, const char *, unsigned, unsigned, const char *const *, int *, void **) = dlsym(RTLD_DEFAULT, "ashell_host_pane_spawn"); + int fd = -1; + void *job = NULL; + NSString *directory = [NSHomeDirectory() stringByAppendingPathComponent:@"Documents"]; + if (!spawn || spawn("worker-pool-host-payload", directory.fileSystemRepresentation, 80, 24, NULL, &fd, &job) != EBUSY || fd != -1 || job) return 1; + fputs("PAYLOAD\n", thread_stdout); + fputs("ERROR\n", thread_stderr); + return 7; +} + +static int loss_check(void) { + int (*spawn)(const char *, const char *, unsigned, unsigned, const char *const *, int *, void **) = dlsym(RTLD_DEFAULT, "ashell_pane_spawn"); + int (*pollJob)(void *, int *) = dlsym(RTLD_DEFAULT, "ashell_pane_poll"); + void (*release)(void *) = dlsym(RTLD_DEFAULT, "ashell_pane_release"); + if (!spawn || !pollJob || !release) return 1; + int fd = -1, status = -1, result = 0; + void *job = NULL; + NSString *directory = [NSHomeDirectory() stringByAppendingPathComponent:@"Documents"]; + int error = spawn("worker-pool-crash", directory.fileSystemRepresentation, 80, 24, NULL, &fd, &job); + if (error) return 1; + NSTimeInterval deadline = NSProcessInfo.processInfo.systemUptime + 5; + while (!result && NSProcessInfo.processInfo.systemUptime < deadline) { + result = pollJob(job, &status); + if (!result) usleep(10000); + } + BOOL passed = result == -EPIPE && status == -1; + fprintf(thread_stdout, "lost worker result=%d status=%d: %s\n", result, status, passed ? "PASS" : "FAIL"); + release(job); + close(fd); + return passed ? 0 : 1; +} + +int worker_pool_payload_main(int argc, char **argv) { + const char *home = getenv("HOME"), *expected = getenv("POOL_EXPECTED_HOME"); + const char *temporary = getenv("TMPDIR"); + const char *parent = getenv("POOL_PARENT_PID"); + const char *root = ios_getenv("ASHELL_WORKER_ROOT_PID"); + if (!root || atoi(root) != ios_currentPid() || !home || !expected || strcmp(home, expected) || !temporary || + strcmp(temporary, NSTemporaryDirectory().fileSystemRepresentation) || !parent || getpid() == atoi(parent) || ios_isatty(1)) { + fprintf(thread_stderr, "payload root=%s pid=%d home=%d tmp_domain=%d process_distinct=%d tty=%d\n", root ?: "missing", ios_currentPid(), home && expected && !strcmp(home, expected), temporary && !strcmp(temporary, NSTemporaryDirectory().fileSystemRepresentation), parent && getpid() != atoi(parent), ios_isatty(1)); + return 1; + } + NSString *file = [@(temporary) stringByAppendingPathComponent:NSUUID.UUID.UUIDString]; + int fd = open(file.fileSystemRepresentation, O_CREAT | O_EXCL | O_RDWR, 0600); + if (fd < 0) return 1; + BOOL passed = write(fd, "x", 1) == 1; + if (close(fd) || unlink(file.fileSystemRepresentation)) passed = NO; + if (!passed) return 1; + fputs("PAYLOAD\n", thread_stdout); + fputs("ERROR\n", thread_stderr); + return 7; +} + // Run as a real app command. The same consumer must fail on the stock app. int worker_pool_check_main(int argc, char **argv) { @autoreleasepool { - if (argc == 2 && !strcmp(argv[1], "completion")) return completion_check(); + if (argc == 2 && !strcmp(argv[1], "nested")) return spawn_contract(NO, NO); + if (argc == 2 && !strcmp(argv[1], "host")) return spawn_contract(NO, YES); + if (argc == 2 && !strcmp(argv[1], "loss")) return loss_check(); + if (argc == 2 && !strcmp(argv[1], "completion")) return completion_check(NO); + if (argc == 2 && !strcmp(argv[1], "identity")) return completion_check(YES); if (argc == 2 && !strcmp(argv[1], "capacity")) return capacity_check(); if (argc == 2 && !strcmp(argv[1], "limit")) return limit_check(); if (argc == 2 && !strcmp(argv[1], "lifecycle")) return lifecycle_check(NO, NO); diff --git a/tests/worker-pool.md b/tests/worker-pool.md index 4dacd8b3..896256bb 100644 --- a/tests/worker-pool.md +++ b/tests/worker-pool.md @@ -9,6 +9,7 @@ Use the matching ios_system session/stream and Dash terminal changes, then run t ```sh worker-pool-check worker-pool-check completion +worker-pool-check identity worker-pool-check capacity worker-pool-check limit worker-pool-check lifecycle @@ -20,6 +21,8 @@ Each command returns zero on success and prints assertions to its output stream. The completion check leaves output unread until polling reports status 7, checks that a second poll preserves it, then reads the exact pending output and EOF. +The identity check supplies stale worker markers and runs real Dash with a nested Dash command. Both shells must see positive worker and root markers through their own environment import, and the nested command must resolve through the host's packaged PATH. It also checks status, pending output and EOF through the public pane API. + The limit check requires the default cap to equal `min(hw.physicalcpu, ShellWorkerCount)`, then sets `worker_limit` to 2, to the bundled count and above it (clamped to the bundled count), repeats the capacity check under each and restores the setting. Build the host with more workers than cores to see the default and the raised cap differ. The lifecycle checks cancel or close one worker, ask a second worker to terminate itself with `SIGKILL`, verify the surviving worker's input/output/status, and repeat the full capacity check. The busy variants make the first command ignore EOF. iOS denies host-to-extension `kill` with `EPERM`, so process loss is injected by the normal disposable command inside the worker. Run these checks only in a disposable build: they deliberately terminate its worker processes. @@ -27,3 +30,9 @@ The lifecycle checks cancel or close one worker, ask a second worker to terminat Register the same consumer in an unmodified host for the baseline. It must print `worker pool unavailable: FAIL` and return one. A Release build must export the public C functions and callbacks loaded through `dlsym`; a consumer compiled into an executable with hidden symbols does not prove that interface usable by a dynamic command. The upstream configuration remains ten workers. Historical iOS 27 Simulator tests passed ten-slot capacity and lifecycle checks. A physical run with three provisioned identities qualifies three-worker behavior only; it does not qualify ten-worker device capacity, background lifetime or App Store acceptance. + +`worker-pool-check loss` starts the disposable crash command and requires `-EPIPE` with no fabricated command status. Explicit cancellation remains distinct from unexpected XPC loss. + +`worker-pool-check host` starts a real extension command, which calls `ashell_worker_host_spawn_ex` to run the payload back in the app process. Register `worker-pool-nested-host` and `worker-pool-host-payload` with entries `worker_pool_nested_host_main` and `worker_pool_host_payload_main` in the disposable catalogues. It checks the original app PID, absence of worker identity markers, plain streams, separate stderr, status, resource usage and `EBUSY` for a second host pane. The command admission policy and high-memory workload qualification belong to the caller. + +`worker-pool-check nested` exercises the public `spawn_ex` contract through two real extension processes. Register `worker-pool-nested` and `worker-pool-payload` in the disposable worker catalogues with entries `worker_pool_nested_main` and `worker_pool_payload_main` from the same checks framework and corresponding PATH markers. The outer command calls the host API; its worker calls `ashell_worker_spawn_ex` over the broker. The payload checks a distinct process, non-terminal output, preserved host HOME and a writable TMPDIR matching its own Foundation temporary directory. Both levels drain stdout and stderr independently, require exact bytes, exit status 7, descriptor close-on-exec and a final resource-usage snapshot. The baseline without the extended API must fail. No Herdr engine or terminal UI is involved. From b33ae540a18dc763b46aedbe4c227e5a5f071de2 Mon Sep 17 00:00:00 2001 From: demrom <47463752+demrom@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:31:34 +0300 Subject: [PATCH 08/16] Close pane descriptors when stream setup fails --- ShellWorkers/NativePane.m | 23 ++++++++++++--------- tests/pane-descriptors.m | 43 +++++++++++++++++++++++++++++++++++++++ tests/worker-pool.md | 2 ++ 3 files changed, 58 insertions(+), 10 deletions(-) create mode 100644 tests/pane-descriptors.m diff --git a/ShellWorkers/NativePane.m b/ShellWorkers/NativePane.m index 920dee96..51e60221 100644 --- a/ShellWorkers/NativePane.m +++ b/ShellWorkers/NativePane.m @@ -611,7 +611,7 @@ int ashell_pane_spawn_ex(const char *command, const char *cwd, unsigned cols, un } int expected = 0; if (!atomic_compare_exchange_strong(&pane_in_flight, &expected, 1)) return EBUSY; - int error = ENOMEM, peer = -1; + int error = ENOMEM, peer = -1, child = -1, err_child = -1; pane_job *job = calloc(1, sizeof(*job)); if (!job) { atomic_store(&pane_in_flight, 0); return ENOMEM; } job->peer_control = -1; @@ -660,11 +660,11 @@ int ashell_pane_spawn_ex(const char *command, const char *cwd, unsigned cols, un } if (!wants_pty && socketpair(AF_UNIX, SOCK_STREAM, 0, fds)) { error = errno; goto failed; } peer = fds[0]; - int child = fds[1]; + child = fds[1]; if (job->is_pty) { struct termios settings; - if (tcgetattr(child, &settings)) { error = errno; close(child); goto failed; } - if (settings.c_cc[VEOF] == _POSIX_VDISABLE) { error = ENOTSUP; close(child); goto failed; } + if (tcgetattr(child, &settings)) { error = errno; goto failed; } + if (settings.c_cc[VEOF] == _POSIX_VDISABLE) { error = ENOTSUP; goto failed; } job->eof_key = settings.c_cc[VEOF]; // Commands share an application process: never enable kernel process-group signals. settings.c_lflag &= ~ISIG; @@ -675,13 +675,12 @@ int ashell_pane_spawn_ex(const char *command, const char *cwd, unsigned cols, un error = socket_options(peer); if (!error) error = socket_options(child); } - if (error) { close(child); goto failed; } + if (error) goto failed; job->peer_control = dup(peer); - if (job->peer_control < 0) { error = errno; close(child); goto failed; } + if (job->peer_control < 0) { error = errno; goto failed; } error = job->is_pty ? (fcntl(job->peer_control, F_SETFD, FD_CLOEXEC) ? errno : 0) : socket_options(job->peer_control); if (!error && job->is_pty && fcntl(peer, F_SETFD, FD_CLOEXEC)) error = errno; - if (error) { close(child); goto failed; } - int err_child = -1; + if (error) goto failed; if (options->separate_stderr) { int pair[2]; if (socketpair(AF_UNIX, SOCK_STREAM, 0, pair)) { error = errno; goto failed; } @@ -689,11 +688,13 @@ int ashell_pane_spawn_ex(const char *command, const char *cwd, unsigned cols, un err_child = pair[1]; error = socket_options(peer_err); if (!error) error = socket_options(err_child); - if (error) { close(err_child); goto failed; } + if (error) goto failed; } for (int i = 0; i < 3; i++) { - int fd = i == 0 ? child : i == 2 && err_child >= 0 ? err_child : dup(child); + int fd = i == 0 ? child : i == 2 && err_child >= 0 ? err_child : dup(fileno(job->streams[0])); if (fd < 0) { error = errno; goto failed; } + if (i == 0) child = -1; + if (i == 2) err_child = -1; job->streams[i] = fdopen(fd, i == 0 ? "r" : "w"); if (!job->streams[i]) { error = errno; close(fd); goto failed; } if (fcntl(fd, F_SETFD, FD_CLOEXEC)) { error = errno; goto failed; } @@ -729,6 +730,8 @@ int ashell_pane_spawn_ex(const char *command, const char *cwd, unsigned cols, un *job_out = job; return 0; failed: + if (child >= 0) close(child); + if (err_child >= 0) close(err_child); close_streams(job); if (peer >= 0) close(peer); if (peer_err >= 0) close(peer_err); diff --git a/tests/pane-descriptors.m b/tests/pane-descriptors.m new file mode 100644 index 00000000..7b857a60 --- /dev/null +++ b/tests/pane-descriptors.m @@ -0,0 +1,43 @@ +#import +#import +#include +#include +#include +#include +#include +#include "../ShellWorkers/NativePane.h" + +// Run in a disposable process: resource limits apply to every thread. +int main(void) { + @autoreleasepool { + thread_stdin = stdin; + thread_stdout = stdout; + thread_stderr = stderr; + (void)NSUUID.UUID.UUIDString; + struct rlimit original; + if (getrlimit(RLIMIT_NOFILE, &original) || original.rlim_cur < 64) return 64; + for (int budget = 0; budget <= 6; budget++) { + struct rlimit limited = original; + limited.rlim_cur = 64; + if (setrlimit(RLIMIT_NOFILE, &limited)) return 64; + int fillers[64], count = 0, fd; + while (count < 64 && (fd = open("/dev/null", O_RDONLY)) >= 0) fillers[count++] = fd; + if (count < budget) return 64; + for (int i = 0; i < budget; i++) close(fillers[--count]); + int before = 0; + for (int i = 0; i < 64; i++) if (fcntl(i, F_GETFD) >= 0) before++; + ashell_pane_options options = { .size = sizeof(options), .tty = 0, .separate_stderr = 1 }; + int output = -1; + void *job = NULL; + int error = ashell_pane_spawn_ex("true", "/tmp", 80, 24, NULL, &options, &output, &job); + int after = 0; + for (int i = 0; i < 64; i++) if (fcntl(i, F_GETFD) >= 0) after++; + int restored = setrlimit(RLIMIT_NOFILE, &original); + for (int i = 0; i < count; i++) close(fillers[i]); + BOOL passed = !restored && error == EMFILE && output == -1 && job == NULL && options.stderr_fd == -1 && before == after; + printf("descriptor budget=%d error=%d delta=%d: %s\n", budget, error, after - before, passed ? "PASS" : "FAIL"); + if (!passed) return 1; + } + return 0; + } +} diff --git a/tests/worker-pool.md b/tests/worker-pool.md index 896256bb..77bfad2e 100644 --- a/tests/worker-pool.md +++ b/tests/worker-pool.md @@ -31,6 +31,8 @@ Register the same consumer in an unmodified host for the baseline. It must print The upstream configuration remains ten workers. Historical iOS 27 Simulator tests passed ten-slot capacity and lifecycle checks. A physical run with three provisioned identities qualifies three-worker behavior only; it does not qualify ten-worker device capacity, background lifetime or App Store acceptance. +`pane-descriptors.m` is a standalone consumer of the public native pane API. Compile it together with `ShellWorkers/NativePane.m`, linking Foundation and the matching ios_system framework, for an owned Simulator and run it with `xcrun simctl spawn `. It limits only its disposable process to 64 descriptors, fills the table, and retries separate-stderr/plain-stream spawns with zero through six available descriptors. Every call must return `EMFILE`, leave all outputs empty and preserve the open-descriptor count. It restores the original limit before reporting. The baseline leaks one descriptor with three available slots; the fixed runner passes all seven budgets. Do not run it inside the app process. + `worker-pool-check loss` starts the disposable crash command and requires `-EPIPE` with no fabricated command status. Explicit cancellation remains distinct from unexpected XPC loss. `worker-pool-check host` starts a real extension command, which calls `ashell_worker_host_spawn_ex` to run the payload back in the app process. Register `worker-pool-nested-host` and `worker-pool-host-payload` with entries `worker_pool_nested_host_main` and `worker_pool_host_payload_main` in the disposable catalogues. It checks the original app PID, absence of worker identity markers, plain streams, separate stderr, status, resource usage and `EBUSY` for a second host pane. The command admission policy and high-memory workload qualification belong to the caller. From cf020ff553537563e9d2f23c6b9d25ef274606e3 Mon Sep 17 00:00:00 2001 From: demrom <47463752+demrom@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:41:23 +0300 Subject: [PATCH 09/16] Document host pane interrupt and usage contracts --- ShellWorkers/HostPane.h | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/ShellWorkers/HostPane.h b/ShellWorkers/HostPane.h index b5f6c7f9..c9e7a863 100644 --- a/ShellWorkers/HostPane.h +++ b/ShellWorkers/HostPane.h @@ -12,8 +12,11 @@ extern "C" { // Uses the same streams, options and handle lifetime as NativePane.h. One live host // pane is admitted (EBUSY for another); it shares the app's process cwd and memory. // No extension allocation or automatic routing is performed. Do not forward worker -// identity environment overrides. Cancellation returns ENOTSUP while running; -// interrupt targets the foreground command, and release retains live cleanup. +// identity environment overrides. Cancellation returns ENOTSUP while running. +// Interrupt requires a Dash foreground command with ASHELL_IOS_SOCKET_INPUT=1 +// in envp; otherwise it can return ESRCH. Its PID hook is process-wide: unrelated +// Dash sessions must not enable that variable concurrently. Release retains live cleanup. +// poll_usage returns the app process RUSAGE_SELF at exit, not per-command usage. int ashell_host_pane_spawn(const char *, const char *, unsigned, unsigned, const char *const *, int *, void **); int ashell_host_pane_spawn_ex(const char *, const char *, unsigned, unsigned, const char *const *, ashell_pane_options *, int *, void **); int ashell_host_pane_poll(void *, int *); From ad7d258acca9deaab3777bfa434bae0aca97daa2 Mon Sep 17 00:00:00 2001 From: demrom <47463752+demrom@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:09:39 +0300 Subject: [PATCH 10/16] Report individual worker completion assertion failures --- tests/worker-pool.m | 23 ++++++++++++++++++++--- tests/worker-pool.md | 2 +- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/tests/worker-pool.m b/tests/worker-pool.m index dc9394a1..8e3fac44 100644 --- a/tests/worker-pool.m +++ b/tests/worker-pool.m @@ -215,13 +215,30 @@ static int completion_check(BOOL identity) { result = pollJob(job, &status); if (!result) usleep(10000); } + int first_status = status, repeated_result = 0, repeated_status = -1; BOOL passed = result == 1 && status == 7; status = -1; - passed = passed && pollJob(job, &status) == 1 && status == 7; + if (passed) { + repeated_result = pollJob(job, &status); + repeated_status = status; + passed = repeated_result == 1 && status == 7; + } char output[64] = {0}; ssize_t count = passed ? read(descriptor, output, sizeof(output) - 1) : -1; - passed = passed && count == 14 && !strcmp(output, "pending-output"); - if (passed) passed = read(descriptor, output, 1) == 0; + int read_error = passed && count < 0 ? errno : 0; + BOOL output_matches = count == 14 && !strcmp(output, "pending-output"); + passed = passed && output_matches; + ssize_t eof = -1; + if (passed) { + eof = read(descriptor, output, 1); + if (eof < 0) read_error = errno; + passed = eof == 0; + } + if (!passed) { + struct pollfd ready = {.fd = descriptor, .events = POLLIN}; + int ready_result = poll(&ready, 1, 0); + fprintf(thread_stdout, "completion result=%d status=%d repeated_result=%d repeated_status=%d bytes=%zd output_matches=%d eof=%zd read_errno=%d readiness=%d revents=%#x\n", result, first_status, repeated_result, repeated_status, count, output_matches, eof, read_error, ready_result, ready.revents); + } release(job); close(descriptor); fprintf(thread_stdout, "%s: %s\n", identity ? "Dash identity and nested command lookup" : "status before output drain and repeated status", passed ? "PASS" : "FAIL"); diff --git a/tests/worker-pool.md b/tests/worker-pool.md index 77bfad2e..d2ec2d24 100644 --- a/tests/worker-pool.md +++ b/tests/worker-pool.md @@ -19,7 +19,7 @@ worker-pool-check lifecycle-close Each command returns zero on success and prints assertions to its output stream. The capacity check asks the host for the effective cap (`ashell_pane_capacity`: the `worker_limit` setting, or the physical core count when unset, at most `ShellWorkerCount` from the host Info.plist), keeps that many commands open, requires distinct real process IDs and independent cwd/environment, rejects one additional request with `ENOSPC`, then sends input after six seconds and checks exact exit statuses and output EOF. It compares directory device/inode pairs because the phone can return `/private/var/...` for a cwd requested through its `/var/...` alias. -The completion check leaves output unread until polling reports status 7, checks that a second poll preserves it, then reads the exact pending output and EOF. +The completion check leaves output unread until polling reports status 7, checks that a second poll preserves it, then reads the exact pending output and EOF. On failure it prints the first and repeated poll results and statuses, byte count, output match, EOF result, read errno and socket readiness. A skipped repeated poll or read retains its initial sentinel; the diagnostic does not drain output before the status checks. The identity check supplies stale worker markers and runs real Dash with a nested Dash command. Both shells must see positive worker and root markers through their own environment import, and the nested command must resolve through the host's packaged PATH. It also checks status, pending output and EOF through the public pane API. From 97551c93cee6d4d4489043ca817608438cb15213 Mon Sep 17 00:00:00 2001 From: demrom <47463752+demrom@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:44:23 +0300 Subject: [PATCH 11/16] Keep worker fixtures independent of Dash login profiles --- tests/worker-pool.m | 15 +++++++++------ tests/worker-pool.md | 2 ++ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/tests/worker-pool.m b/tests/worker-pool.m index 8e3fac44..4bb3fe1f 100644 --- a/tests/worker-pool.m +++ b/tests/worker-pool.m @@ -13,6 +13,9 @@ #define MAX_WORKERS 64 +// Dash fixture commands must not source the user's login profile. +static const char *const nonlogin_environment[] = {"_DASH_LOGIN_SHELL=1", NULL}; + // Keep each command alive at its public stdin boundary while the other slots start. static int capacity_check(void) { int (*spawn)(const char *, const char *, unsigned, unsigned, const char *const *, int *, void **) = dlsym(RTLD_DEFAULT, "ashell_pane_spawn"); @@ -37,7 +40,7 @@ static int capacity_check(void) { if (![[NSFileManager defaultManager] createDirectoryAtPath:directory withIntermediateDirectories:YES attributes:nil error:nil]) { passed = NO; break; } char value[32], command[256]; snprintf(value, sizeof(value), "POOL_VALUE=slot-%d", i); - const char *environment[] = {value, NULL}; + const char *environment[] = {value, "_DASH_LOGIN_SHELL=1", NULL}; snprintf(command, sizeof(command), "dash -c 'printf \"READY=%%s,%%s\\n\" \"$ASHELL_WORKER_PID\" \"$POOL_VALUE\"; pwd; read value; printf \"END=%%s\\n\" \"$value\"; exit %d'", i); int error = spawn(command, directory.fileSystemRepresentation, 80, 24, environment, &descriptors[i], &jobs[i]); if (error) { fprintf(thread_stdout, "slot=%d startup error=%d: FAIL\n", i, error); passed = NO; break; } @@ -77,7 +80,7 @@ static int capacity_check(void) { if (passed) { int descriptor = -1; void *job = NULL; - int error = spawn("dash -c 'exit 0'", root.fileSystemRepresentation, 80, 24, NULL, &descriptor, &job); + int error = spawn("dash -c 'exit 0'", root.fileSystemRepresentation, 80, 24, nonlogin_environment, &descriptor, &job); BOOL full = error == ENOSPC && descriptor == -1 && job == NULL; fprintf(thread_stdout, "worker=%d error=%d: %s\n", capacity + 1, error, full ? "PASS" : "FAIL"); if (job) release(job); @@ -128,7 +131,7 @@ static int lifecycle_check(BOOL busy, BOOL closeOnly) { BOOL passed = YES; for (int i = 0; i < 3; i++) { const char *command = i == 1 ? "dash -c 'printf \"READY=%s\\n\" \"$ASHELL_WORKER_PID\"; read value; worker-pool-crash'" : busy && i == 0 ? "dash -c 'printf \"READY=%s\\n\" \"$ASHELL_WORKER_PID\"; while :; do :; done'" : "dash -c 'printf \"READY=%s\\n\" \"$ASHELL_WORKER_PID\"; read value; printf \"DONE=%s\\n\" \"$value\"; exit 7'"; - int error = spawn(command, directory.fileSystemRepresentation, 80, 24, NULL, &descriptors[i], &jobs[i]); + int error = spawn(command, directory.fileSystemRepresentation, 80, 24, nonlogin_environment, &descriptors[i], &jobs[i]); if (error) { fprintf(thread_stdout, "lifecycle slot=%d startup=%d: FAIL\n", i, error); passed = NO; break; } struct pollfd ready = {.fd = descriptors[i], .events = POLLIN}; char output[128] = {0}; @@ -206,9 +209,9 @@ static int completion_check(BOOL identity) { NSString *directory = [NSHomeDirectory() stringByAppendingPathComponent:@"Documents"]; int descriptor = -1, status = -1, result = 0; void *job = NULL; - const char *environment[] = {"ASHELL_WORKER_PID=-1", "ASHELL_WORKER_ROOT_PID=-1", NULL}; + const char *environment[] = {"ASHELL_WORKER_PID=-1", "ASHELL_WORKER_ROOT_PID=-1", "_DASH_LOGIN_SHELL=1", NULL}; const char *command = identity ? "dash -c 'test \"$ASHELL_WORKER_PID\" -gt 0 && test \"$ASHELL_WORKER_ROOT_PID\" -gt 0 && dash -c \"test \\\"\\$ASHELL_WORKER_PID\\\" -gt 0 && test \\\"\\$ASHELL_WORKER_ROOT_PID\\\" -gt 0\" && printf pending-output; exit 7'" : "dash -c 'printf pending-output; exit 7'"; - int error = spawn(command, directory.fileSystemRepresentation, 80, 24, identity ? environment : NULL, &descriptor, &job); + int error = spawn(command, directory.fileSystemRepresentation, 80, 24, identity ? environment : nonlogin_environment, &descriptor, &job); if (error) { fprintf(thread_stdout, "completion startup=%d: FAIL\n", error); return 1; } NSTimeInterval deadline = NSProcessInfo.processInfo.systemUptime + 10; while (!result && NSProcessInfo.processInfo.systemUptime < deadline) { @@ -420,7 +423,7 @@ int worker_pool_check_main(int argc, char **argv) { return 1; } NSString *directory = [NSHomeDirectory() stringByAppendingPathComponent:@"Documents"]; - const char *environment[] = {"POOL_VALUE=isolated", NULL}; + const char *environment[] = {"POOL_VALUE=isolated", "_DASH_LOGIN_SHELL=1", NULL}; int descriptor = -1; void *job = NULL; int error = spawn("dash -c 'printf \"WORKER=%s\\n\" \"$POOL_VALUE\"'", directory.fileSystemRepresentation, 80, 24, environment, &descriptor, &job); diff --git a/tests/worker-pool.md b/tests/worker-pool.md index d2ec2d24..7468375a 100644 --- a/tests/worker-pool.md +++ b/tests/worker-pool.md @@ -19,6 +19,8 @@ worker-pool-check lifecycle-close Each command returns zero on success and prints assertions to its output stream. The capacity check asks the host for the effective cap (`ashell_pane_capacity`: the `worker_limit` setting, or the physical core count when unset, at most `ShellWorkerCount` from the host Info.plist), keeps that many commands open, requires distinct real process IDs and independent cwd/environment, rejects one additional request with `ENOSPC`, then sends input after six seconds and checks exact exit statuses and output EOF. It compares directory device/inode pairs because the phone can return `/private/var/...` for a cwd requested through its `/var/...` alias. +Dash fixture spawns pass `_DASH_LOGIN_SHELL=1` through the public environment argument. The published Dash otherwise treats its first invocation as a login shell, even with `-c`, and reads the user's profile; an `alias exit=clear` can turn the fixture's `exit 7` into an unresolved command returning 127. The fixture leaves the user's profile and production shell behavior unchanged. For a regression check, retain that alias in the disposable Simulator profile: the original fixture must return 127, while the explicit non-login environment must preserve status 7, exact pending output and EOF. + The completion check leaves output unread until polling reports status 7, checks that a second poll preserves it, then reads the exact pending output and EOF. On failure it prints the first and repeated poll results and statuses, byte count, output match, EOF result, read errno and socket readiness. A skipped repeated poll or read retains its initial sentinel; the diagnostic does not drain output before the status checks. The identity check supplies stale worker markers and runs real Dash with a nested Dash command. Both shells must see positive worker and root markers through their own environment import, and the nested command must resolve through the host's packaged PATH. It also checks status, pending output and EOF through the public pane API. From 41192c3610567462a0856890dd623ea2b1d58b2f Mon Sep 17 00:00:00 2001 From: demrom <47463752+demrom@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:03:39 +0300 Subject: [PATCH 12/16] Compare worker fixture HOME by directory identity --- tests/worker-pool.m | 20 ++++++++++++++++---- tests/worker-pool.md | 2 +- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/tests/worker-pool.m b/tests/worker-pool.m index 4bb3fe1f..1ad39ff1 100644 --- a/tests/worker-pool.m +++ b/tests/worker-pool.m @@ -16,6 +16,13 @@ // Dash fixture commands must not source the user's login profile. static const char *const nonlogin_environment[] = {"_DASH_LOGIN_SHELL=1", NULL}; +// Host bookmarks can resolve /var and /private/var to the same HOME directory. +static BOOL same_directory(const char *left, const char *right) { + struct stat a, b; + return left && right && stat(left, &a) == 0 && stat(right, &b) == 0 && + S_ISDIR(a.st_mode) && S_ISDIR(b.st_mode) && a.st_dev == b.st_dev && a.st_ino == b.st_ino; +} + // Keep each command alive at its public stdin boundary while the other slots start. static int capacity_check(void) { int (*spawn)(const char *, const char *, unsigned, unsigned, const char *const *, int *, void **) = dlsym(RTLD_DEFAULT, "ashell_pane_spawn"); @@ -285,7 +292,8 @@ static int spawn_contract(BOOL nested, BOOL host) { int (*pollJob)(void *, int *, struct rusage *, int *) = dlsym(RTLD_DEFAULT, nested ? (host ? "ashell_worker_host_poll_usage" : "ashell_worker_poll_usage") : "ashell_pane_poll_usage"); void (*release)(void *) = dlsym(RTLD_DEFAULT, nested ? (host ? "ashell_worker_host_release" : "ashell_worker_release") : "ashell_pane_release"); if (!spawn || !pollJob || !release) { fputs("spawn contract unavailable: FAIL\n", thread_stdout); return 1; } - NSString *home = nested ? @(getenv("HOME")) : NSHomeDirectory(); + // Exercise a caller HOME spelling that the worker bookmark may normalize. + NSString *home = nested ? @(getenv("HOME")) : [NSHomeDirectory() stringByAppendingString:@"/."]; NSString *directory = [home stringByAppendingPathComponent:@"Documents"]; NSString *homeValue = [@"HOME=" stringByAppendingString:home]; NSString *expected = [@"POOL_EXPECTED_HOME=" stringByAppendingString:home]; @@ -333,7 +341,11 @@ static int spawn_contract(BOOL nested, BOOL host) { int worker_pool_nested_main(int argc, char **argv) { const char *home = getenv("HOME"), *expected = getenv("POOL_EXPECTED_HOME"); const char *root = ios_getenv("ASHELL_WORKER_ROOT_PID"); - if (!root || atoi(root) != ios_currentPid() || !home || !expected || strcmp(home, expected) || spawn_contract(YES, NO)) return 1; + if (!root || atoi(root) != ios_currentPid() || !same_directory(home, expected)) { + fprintf(thread_stderr, "nested context root=%s pid=%d home_same=%d home=%s expected=%s\n", root ?: "missing", ios_currentPid(), same_directory(home, expected), home ?: "missing", expected ?: "missing"); + return 1; + } + if (spawn_contract(YES, NO)) return 1; fputs("PAYLOAD\n", thread_stdout); fputs("ERROR\n", thread_stderr); return 7; @@ -386,9 +398,9 @@ int worker_pool_payload_main(int argc, char **argv) { const char *temporary = getenv("TMPDIR"); const char *parent = getenv("POOL_PARENT_PID"); const char *root = ios_getenv("ASHELL_WORKER_ROOT_PID"); - if (!root || atoi(root) != ios_currentPid() || !home || !expected || strcmp(home, expected) || !temporary || + if (!root || atoi(root) != ios_currentPid() || !home || !expected || !same_directory(home, expected) || !temporary || strcmp(temporary, NSTemporaryDirectory().fileSystemRepresentation) || !parent || getpid() == atoi(parent) || ios_isatty(1)) { - fprintf(thread_stderr, "payload root=%s pid=%d home=%d tmp_domain=%d process_distinct=%d tty=%d\n", root ?: "missing", ios_currentPid(), home && expected && !strcmp(home, expected), temporary && !strcmp(temporary, NSTemporaryDirectory().fileSystemRepresentation), parent && getpid() != atoi(parent), ios_isatty(1)); + fprintf(thread_stderr, "payload root=%s pid=%d home=%d tmp_domain=%d process_distinct=%d tty=%d\n", root ?: "missing", ios_currentPid(), home && expected && same_directory(home, expected), temporary && !strcmp(temporary, NSTemporaryDirectory().fileSystemRepresentation), parent && getpid() != atoi(parent), ios_isatty(1)); return 1; } NSString *file = [@(temporary) stringByAppendingPathComponent:NSUUID.UUID.UUIDString]; diff --git a/tests/worker-pool.md b/tests/worker-pool.md index 7468375a..685cdafb 100644 --- a/tests/worker-pool.md +++ b/tests/worker-pool.md @@ -39,4 +39,4 @@ The upstream configuration remains ten workers. Historical iOS 27 Simulator test `worker-pool-check host` starts a real extension command, which calls `ashell_worker_host_spawn_ex` to run the payload back in the app process. Register `worker-pool-nested-host` and `worker-pool-host-payload` with entries `worker_pool_nested_host_main` and `worker_pool_host_payload_main` in the disposable catalogues. It checks the original app PID, absence of worker identity markers, plain streams, separate stderr, status, resource usage and `EBUSY` for a second host pane. The command admission policy and high-memory workload qualification belong to the caller. -`worker-pool-check nested` exercises the public `spawn_ex` contract through two real extension processes. Register `worker-pool-nested` and `worker-pool-payload` in the disposable worker catalogues with entries `worker_pool_nested_main` and `worker_pool_payload_main` from the same checks framework and corresponding PATH markers. The outer command calls the host API; its worker calls `ashell_worker_spawn_ex` over the broker. The payload checks a distinct process, non-terminal output, preserved host HOME and a writable TMPDIR matching its own Foundation temporary directory. Both levels drain stdout and stderr independently, require exact bytes, exit status 7, descriptor close-on-exec and a final resource-usage snapshot. The baseline without the extended API must fail. No Herdr engine or terminal UI is involved. +`worker-pool-check nested` exercises the public `spawn_ex` contract through two real extension processes. Register `worker-pool-nested` and `worker-pool-payload` in the disposable worker catalogues with entries `worker_pool_nested_main` and `worker_pool_payload_main` from the same checks framework and corresponding PATH markers. The outer command calls the host API; its worker calls `ashell_worker_spawn_ex` over the broker. The caller supplies HOME with a trailing `/.` to exercise bookmark path normalization. The payload checks a distinct process, non-terminal output, HOME naming the same directory by device/inode, and a writable TMPDIR matching its own Foundation temporary directory. Literal HOME spelling can differ on the phone (`/var` versus `/private/var`); it must still name the same directory. The outer worker reports root identity and HOME context if its preconditions fail. Both levels drain stdout and stderr independently, require exact bytes, exit status 7, descriptor close-on-exec and a final resource-usage snapshot. The baseline without the extended API must fail. No Herdr engine or terminal UI is involved. From f497d976622c0e910ae4c970d6c779a4b2a0b9f5 Mon Sep 17 00:00:00 2001 From: demrom Date: Fri, 23 Oct 2026 23:06:35 +0300 Subject: [PATCH 13/16] Register stock OpenSSH clients with isolated workers --- README.md | 19 +++++++++++++++ Resources/commandDictionary.plist | 16 ++++++------- Resources_mini/commandDictionary.plist | 16 ++++++------- a-Shell.xcodeproj/project.pbxproj | 32 ++++++++++++++++++++++++++ a-Shell/AppDelegate.swift | 2 ++ 5 files changed, 69 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 0182bc80..b0d6c580 100644 --- a/README.md +++ b/README.md @@ -146,3 +146,22 @@ For font files, create `~/Documents/Fonts` and drop `.ttf` or `.otf` files there `config -n "face name" -s 17 -g` updates all open windows and saves the font and size. `-p` saves the current window's settings without applying them to other windows. In iOS Settings, open a-Shell or a-Shell mini and use "Font", "Custom font name", and "Font size". Choose "Custom" for another installed face or a file in `Documents/Fonts`. Changes apply when returning to the app and survive relaunch. Reopen the app's Settings page to refresh values changed from the terminal. Font files are refreshed when running `config` or returning to the app. Removing a file unregisters it; a window using an unavailable font displays Menlo until the file returns or another face is selected. An invalid name passed to `config -n` reports an error and keeps the current selection. + +## Stock OpenSSH clients + +The SSH client update requires the matching OpenSSH 10.6p1 recipe and worker-aware runtime from `holzschu/ios_system`, on top of ios_system#201. Build `SSHStock`, `SCPStock`, `SFTPStock` and `SSHKeygenStock` with that recipe for each SDK you package, using matching ios_system and OpenSSL framework inputs. Keep each SDK's output in a separate directory. No SSH server or security-key provider is required for these four clients. + +Package the resulting frameworks with Apple's `xcodebuild -create-xcframework`. For example, with the two recipe output directories in `SSH_DEVICE_OUTPUT` and `SSH_SIMULATOR_OUTPUT`: + +```sh +for name in SSHStock SCPStock SFTPStock SSHKeygenStock; do + xcodebuild -create-xcframework \ + -framework "$SSH_DEVICE_OUTPUT/sdk/$name.framework" \ + -framework "$SSH_SIMULATOR_OUTPUT/sdk/$name.framework" \ + -output "xcfs/$name.xcframework" +done +``` + +For a Simulator-only build, omit the device framework argument. Use fresh output paths when changing inputs. The existing Embed Frameworks phases sign these XCFrameworks. The main and mini command catalogs select their public entry points; extension catalogs are generated from the mini catalog. Application startup appends the four commands to the existing worker selection, preserving any configured commands. The matching runtime runs each admitted root once and requires a fresh worker for nested client invocations; missing worker APIs or exhausted admission return status 127. + +Run the producer's `phone-client.sh` through the app against a disposable SSH account with throwaway keys. Check version, key generation, strict login, scp/sftp byte round trips, multiple-source SCP errors and a following successful login. Keep existing config, keys and known_hosts for migration checks. Compilation and catalog validation alone do not prove these runtime behaviors. diff --git a/Resources/commandDictionary.plist b/Resources/commandDictionary.plist index 63fcbb47..910f3649 100644 --- a/Resources/commandDictionary.plist +++ b/Resources/commandDictionary.plist @@ -557,8 +557,8 @@ scp - ssh_cmd.framework/ssh_cmd - scp_main + SCPStock.framework/SCPStock + ashell_scp_main 12346ABCTdfpqrtvF:J:P:S:c:i:l:o: file @@ -585,8 +585,8 @@ sftp - ssh_cmd.framework/ssh_cmd - sftp_main + SFTPStock.framework/SFTPStock + ashell_sftp_main 1246AafhNpqrvCc:D:i:l:o:s:S:b:B:F:J:P:R: file @@ -599,15 +599,15 @@ ssh - ssh_cmd.framework/ssh_cmd - ssh_main + SSHStock.framework/SSHStock + ashell_ssh_main 1246ab:c:e:fgi:kl:m:no:p:qstvxAB:CD:E:F:GI:J:KL:MNO:PQ:R:S:TVw:W:XYy no ssh-keygen - ssh_cmd.framework/ssh_cmd - sshkeygen_main + SSHKeygenStock.framework/SSHKeygenStock + ashell_ssh_keygen_main ABHLQXceghiklopquvxyC:D:E:F:G:I:J:K:M:N:O:P:R:S:T:V:W:Z:a:b:f:g:j:m:n:r:s:t:z: file diff --git a/Resources_mini/commandDictionary.plist b/Resources_mini/commandDictionary.plist index 64dad81d..0c02871f 100644 --- a/Resources_mini/commandDictionary.plist +++ b/Resources_mini/commandDictionary.plist @@ -466,8 +466,8 @@ scp - ssh_cmd.framework/ssh_cmd - scp_main + SCPStock.framework/SCPStock + ashell_scp_main 12346ABCTdfpqrtvF:J:P:S:c:i:l:o: file @@ -494,8 +494,8 @@ sftp - ssh_cmd.framework/ssh_cmd - sftp_main + SFTPStock.framework/SFTPStock + ashell_sftp_main 1246AafhNpqrvCc:D:i:l:o:s:S:b:B:F:J:P:R: file @@ -508,15 +508,15 @@ ssh - ssh_cmd.framework/ssh_cmd - ssh_main + SSHStock.framework/SSHStock + ashell_ssh_main 1246ab:c:e:fgi:kl:m:no:p:qstvxAB:CD:E:F:GI:J:KL:MNO:PQ:R:S:TVw:W:XYy no ssh-keygen - ssh_cmd.framework/ssh_cmd - sshkeygen_main + SSHKeygenStock.framework/SSHKeygenStock + ashell_ssh_keygen_main ABHLQXceghiklopquvxyC:D:E:F:G:I:J:K:M:N:O:P:R:S:T:V:W:Z:a:b:f:g:j:m:n:r:s:t:z: file diff --git a/a-Shell.xcodeproj/project.pbxproj b/a-Shell.xcodeproj/project.pbxproj index 6b8ba760..f952c218 100644 --- a/a-Shell.xcodeproj/project.pbxproj +++ b/a-Shell.xcodeproj/project.pbxproj @@ -1256,6 +1256,18 @@ 610000000000000000000011 /* Themes in Resources */ = {isa = PBXBuildFile; fileRef = 610000000000000000000010 /* Themes */; }; 610000000000000000000012 /* Themes in Resources */ = {isa = PBXBuildFile; fileRef = 610000000000000000000010 /* Themes */; }; 610000000000000000000013 /* Themes in Resources */ = {isa = PBXBuildFile; fileRef = 610000000000000000000010 /* Themes */; }; + 731431410000000000000000 /* SSHStock.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 731431400000000000000000 /* SSHStock.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 731431410000000000000001 /* SSHStock.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 731431400000000000000000 /* SSHStock.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 731431410000000000000002 /* SSHStock.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 731431400000000000000000 /* SSHStock.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 731431410000000000000100 /* SCPStock.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 731431400000000000000001 /* SCPStock.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 731431410000000000000101 /* SCPStock.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 731431400000000000000001 /* SCPStock.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 731431410000000000000102 /* SCPStock.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 731431400000000000000001 /* SCPStock.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 731431410000000000000200 /* SFTPStock.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 731431400000000000000002 /* SFTPStock.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 731431410000000000000201 /* SFTPStock.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 731431400000000000000002 /* SFTPStock.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 731431410000000000000202 /* SFTPStock.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 731431400000000000000002 /* SFTPStock.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 731431410000000000000300 /* SSHKeygenStock.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 731431400000000000000003 /* SSHKeygenStock.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 731431410000000000000301 /* SSHKeygenStock.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 731431400000000000000003 /* SSHKeygenStock.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 731431410000000000000302 /* SSHKeygenStock.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 731431400000000000000003 /* SSHKeygenStock.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -1330,6 +1342,10 @@ 224A906E2ADD0999006DB9CC /* shell.xcframework in Embed Frameworks */, 225479362B73BE06005041FF /* ssh_cmdA.xcframework in Embed Frameworks */, 224A906F2ADD0999006DB9CC /* ssh_cmd.xcframework in Embed Frameworks */, + 731431410000000000000300 /* SSHKeygenStock.xcframework in Embed Frameworks */, + 731431410000000000000200 /* SFTPStock.xcframework in Embed Frameworks */, + 731431410000000000000100 /* SCPStock.xcframework in Embed Frameworks */, + 731431410000000000000000 /* SSHStock.xcframework in Embed Frameworks */, 224A90702ADD0999006DB9CC /* tar.xcframework in Embed Frameworks */, 224A90712ADD0999006DB9CC /* text.xcframework in Embed Frameworks */, 224A90722ADD0999006DB9CC /* mandoc.xcframework in Embed Frameworks */, @@ -1576,6 +1592,10 @@ 229D24A4257931E4004A78AC /* files.xcframework in Embed Frameworks */, 229D24A7257931E4004A78AC /* shell.xcframework in Embed Frameworks */, 229D24A8257931E4004A78AC /* ssh_cmd.xcframework in Embed Frameworks */, + 731431410000000000000301 /* SSHKeygenStock.xcframework in Embed Frameworks */, + 731431410000000000000201 /* SFTPStock.xcframework in Embed Frameworks */, + 731431410000000000000101 /* SCPStock.xcframework in Embed Frameworks */, + 731431410000000000000001 /* SSHStock.xcframework in Embed Frameworks */, 225479342B73B77C005041FF /* ssh_cmdA.xcframework in Embed Frameworks */, 229D24A9257931E4004A78AC /* tar.xcframework in Embed Frameworks */, 229D24AA257931E4004A78AC /* text.xcframework in Embed Frameworks */, @@ -1665,6 +1685,10 @@ 22DC1F992570139D0068004E /* shell.xcframework in Embed Frameworks */, 225479352B73B790005041FF /* ssh_cmdA.xcframework in Embed Frameworks */, 22DC1F9A2570139D0068004E /* ssh_cmd.xcframework in Embed Frameworks */, + 731431410000000000000302 /* SSHKeygenStock.xcframework in Embed Frameworks */, + 731431410000000000000202 /* SFTPStock.xcframework in Embed Frameworks */, + 731431410000000000000102 /* SCPStock.xcframework in Embed Frameworks */, + 731431410000000000000002 /* SSHStock.xcframework in Embed Frameworks */, 22DC1F9B2570139D0068004E /* tar.xcframework in Embed Frameworks */, 22DC1F9C2570139D0068004E /* text.xcframework in Embed Frameworks */, 22BA51D1258E478900923893 /* mandoc.xcframework in Embed Frameworks */, @@ -3167,6 +3191,10 @@ A53600000000000000000003 /* libpng.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = libpng.xcframework; path = xcfs/.build/artifacts/xcfs/libpng/libpng.xcframework; sourceTree = SOURCE_ROOT; }; AE1100000000000000000002 /* helix/helix.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = helix/helix.xcframework; sourceTree = SOURCE_ROOT; }; 610000000000000000000010 /* Themes */ = {isa = PBXFileReference; lastKnownFileType = folder; name = Themes; path = Resources/Themes; sourceTree = SOURCE_ROOT; }; + 731431400000000000000000 /* SSHStock.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = SSHStock.xcframework; path = xcfs/SSHStock.xcframework; sourceTree = SOURCE_ROOT; }; + 731431400000000000000001 /* SCPStock.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = SCPStock.xcframework; path = xcfs/SCPStock.xcframework; sourceTree = SOURCE_ROOT; }; + 731431400000000000000002 /* SFTPStock.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = SFTPStock.xcframework; path = xcfs/SFTPStock.xcframework; sourceTree = SOURCE_ROOT; }; + 731431400000000000000003 /* SSHKeygenStock.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = SSHKeygenStock.xcframework; path = xcfs/SSHKeygenStock.xcframework; sourceTree = SOURCE_ROOT; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -4346,6 +4374,10 @@ 22DC1F89257012F50068004E /* files.xcframework */, 22DC1F90257012F50068004E /* shell.xcframework */, 22DC1F8B257012F50068004E /* ssh_cmd.xcframework */, + 731431400000000000000003 /* SSHKeygenStock.xcframework */, + 731431400000000000000002 /* SFTPStock.xcframework */, + 731431400000000000000001 /* SCPStock.xcframework */, + 731431400000000000000000 /* SSHStock.xcframework */, 225478B52B7380A5005041FF /* ssh_cmdA.xcframework */, 224DF74B2B76428800F13C58 /* ssh_agent.xcframework */, 223EF3432B97490600A8ED5A /* sshd.xcframework */, diff --git a/a-Shell/AppDelegate.swift b/a-Shell/AppDelegate.swift index 8c481c64..df41c839 100644 --- a/a-Shell/AppDelegate.swift +++ b/a-Shell/AppDelegate.swift @@ -187,6 +187,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { NSLog("Application didFinishLaunchingWithOptions \(String(describing: launchOptions))") #endif _ = TerminalThemes.synchronizeSelection() + let workerCommands = getenv("ASHELL_WORKER_COMMANDS").map { String(cString: $0) } ?? "" + setenv("ASHELL_WORKER_COMMANDS", workerCommands + " ssh scp sftp ssh-keygen", 1) // Store variables in User Defaults: UserDefaults.standard.register(defaults: ["zshmarks" : true]) UserDefaults.standard.register(defaults: ["bashmarks" : false]) From 783cc8a833b018a87e6fcc35f5606736592bd1d9 Mon Sep 17 00:00:00 2001 From: demrom Date: Sat, 24 Oct 2026 00:08:03 +0300 Subject: [PATCH 14/16] Limit stock client registration to the worker-enabled mini target --- README.md | 2 +- Resources/commandDictionary.plist | 16 ++++++++-------- Resources_mini/commandDictionary.plist | 2 +- a-Shell.xcodeproj/project.pbxproj | 16 ---------------- a-Shell/AppDelegate.swift | 6 ++++-- 5 files changed, 14 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index b0d6c580..16c7162e 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,6 @@ for name in SSHStock SCPStock SFTPStock SSHKeygenStock; do done ``` -For a Simulator-only build, omit the device framework argument. Use fresh output paths when changing inputs. The existing Embed Frameworks phases sign these XCFrameworks. The main and mini command catalogs select their public entry points; extension catalogs are generated from the mini catalog. Application startup appends the four commands to the existing worker selection, preserving any configured commands. The matching runtime runs each admitted root once and requires a fresh worker for nested client invocations; missing worker APIs or exhausted admission return status 127. +For a Simulator-only build, omit the device framework argument. Use fresh output paths when changing inputs. The mini Embed Frameworks phase signs these XCFrameworks. The mini command catalog selects their public entry points; extension catalogs are generated from the mini catalog. Application startup appends the four commands to the existing worker selection only when the target declares a positive `ShellWorkerCount`, preserving any configured commands. Targets without shell workers retain the existing client selection. The matching runtime runs each admitted root once and requires a fresh worker for nested client invocations; missing worker APIs or exhausted admission return status 127. Run the producer's `phone-client.sh` through the app against a disposable SSH account with throwaway keys. Check version, key generation, strict login, scp/sftp byte round trips, multiple-source SCP errors and a following successful login. Keep existing config, keys and known_hosts for migration checks. Compilation and catalog validation alone do not prove these runtime behaviors. diff --git a/Resources/commandDictionary.plist b/Resources/commandDictionary.plist index 910f3649..63fcbb47 100644 --- a/Resources/commandDictionary.plist +++ b/Resources/commandDictionary.plist @@ -557,8 +557,8 @@ scp - SCPStock.framework/SCPStock - ashell_scp_main + ssh_cmd.framework/ssh_cmd + scp_main 12346ABCTdfpqrtvF:J:P:S:c:i:l:o: file @@ -585,8 +585,8 @@ sftp - SFTPStock.framework/SFTPStock - ashell_sftp_main + ssh_cmd.framework/ssh_cmd + sftp_main 1246AafhNpqrvCc:D:i:l:o:s:S:b:B:F:J:P:R: file @@ -599,15 +599,15 @@ ssh - SSHStock.framework/SSHStock - ashell_ssh_main + ssh_cmd.framework/ssh_cmd + ssh_main 1246ab:c:e:fgi:kl:m:no:p:qstvxAB:CD:E:F:GI:J:KL:MNO:PQ:R:S:TVw:W:XYy no ssh-keygen - SSHKeygenStock.framework/SSHKeygenStock - ashell_ssh_keygen_main + ssh_cmd.framework/ssh_cmd + sshkeygen_main ABHLQXceghiklopquvxyC:D:E:F:G:I:J:K:M:N:O:P:R:S:T:V:W:Z:a:b:f:g:j:m:n:r:s:t:z: file diff --git a/Resources_mini/commandDictionary.plist b/Resources_mini/commandDictionary.plist index 0c02871f..ea6ba8a1 100644 --- a/Resources_mini/commandDictionary.plist +++ b/Resources_mini/commandDictionary.plist @@ -517,7 +517,7 @@ SSHKeygenStock.framework/SSHKeygenStock ashell_ssh_keygen_main - ABHLQXceghiklopquvxyC:D:E:F:G:I:J:K:M:N:O:P:R:S:T:V:W:Z:a:b:f:g:j:m:n:r:s:t:z: + ABHLQXceghiklopquvxyC:D:E:F:G:I:J:K:M:N:O:P:R:S:T:V:W:Y:Z:a:b:f:g:j:m:n:r:s:t:w:z: file ssh-agent diff --git a/a-Shell.xcodeproj/project.pbxproj b/a-Shell.xcodeproj/project.pbxproj index f952c218..5a0b17be 100644 --- a/a-Shell.xcodeproj/project.pbxproj +++ b/a-Shell.xcodeproj/project.pbxproj @@ -1256,18 +1256,10 @@ 610000000000000000000011 /* Themes in Resources */ = {isa = PBXBuildFile; fileRef = 610000000000000000000010 /* Themes */; }; 610000000000000000000012 /* Themes in Resources */ = {isa = PBXBuildFile; fileRef = 610000000000000000000010 /* Themes */; }; 610000000000000000000013 /* Themes in Resources */ = {isa = PBXBuildFile; fileRef = 610000000000000000000010 /* Themes */; }; - 731431410000000000000000 /* SSHStock.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 731431400000000000000000 /* SSHStock.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 731431410000000000000001 /* SSHStock.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 731431400000000000000000 /* SSHStock.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 731431410000000000000002 /* SSHStock.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 731431400000000000000000 /* SSHStock.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 731431410000000000000100 /* SCPStock.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 731431400000000000000001 /* SCPStock.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 731431410000000000000101 /* SCPStock.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 731431400000000000000001 /* SCPStock.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 731431410000000000000102 /* SCPStock.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 731431400000000000000001 /* SCPStock.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 731431410000000000000200 /* SFTPStock.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 731431400000000000000002 /* SFTPStock.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 731431410000000000000201 /* SFTPStock.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 731431400000000000000002 /* SFTPStock.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 731431410000000000000202 /* SFTPStock.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 731431400000000000000002 /* SFTPStock.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 731431410000000000000300 /* SSHKeygenStock.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 731431400000000000000003 /* SSHKeygenStock.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 731431410000000000000301 /* SSHKeygenStock.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 731431400000000000000003 /* SSHKeygenStock.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 731431410000000000000302 /* SSHKeygenStock.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 731431400000000000000003 /* SSHKeygenStock.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -1342,10 +1334,6 @@ 224A906E2ADD0999006DB9CC /* shell.xcframework in Embed Frameworks */, 225479362B73BE06005041FF /* ssh_cmdA.xcframework in Embed Frameworks */, 224A906F2ADD0999006DB9CC /* ssh_cmd.xcframework in Embed Frameworks */, - 731431410000000000000300 /* SSHKeygenStock.xcframework in Embed Frameworks */, - 731431410000000000000200 /* SFTPStock.xcframework in Embed Frameworks */, - 731431410000000000000100 /* SCPStock.xcframework in Embed Frameworks */, - 731431410000000000000000 /* SSHStock.xcframework in Embed Frameworks */, 224A90702ADD0999006DB9CC /* tar.xcframework in Embed Frameworks */, 224A90712ADD0999006DB9CC /* text.xcframework in Embed Frameworks */, 224A90722ADD0999006DB9CC /* mandoc.xcframework in Embed Frameworks */, @@ -1685,10 +1673,6 @@ 22DC1F992570139D0068004E /* shell.xcframework in Embed Frameworks */, 225479352B73B790005041FF /* ssh_cmdA.xcframework in Embed Frameworks */, 22DC1F9A2570139D0068004E /* ssh_cmd.xcframework in Embed Frameworks */, - 731431410000000000000302 /* SSHKeygenStock.xcframework in Embed Frameworks */, - 731431410000000000000202 /* SFTPStock.xcframework in Embed Frameworks */, - 731431410000000000000102 /* SCPStock.xcframework in Embed Frameworks */, - 731431410000000000000002 /* SSHStock.xcframework in Embed Frameworks */, 22DC1F9B2570139D0068004E /* tar.xcframework in Embed Frameworks */, 22DC1F9C2570139D0068004E /* text.xcframework in Embed Frameworks */, 22BA51D1258E478900923893 /* mandoc.xcframework in Embed Frameworks */, diff --git a/a-Shell/AppDelegate.swift b/a-Shell/AppDelegate.swift index df41c839..42cb94a7 100644 --- a/a-Shell/AppDelegate.swift +++ b/a-Shell/AppDelegate.swift @@ -187,8 +187,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate { NSLog("Application didFinishLaunchingWithOptions \(String(describing: launchOptions))") #endif _ = TerminalThemes.synchronizeSelection() - let workerCommands = getenv("ASHELL_WORKER_COMMANDS").map { String(cString: $0) } ?? "" - setenv("ASHELL_WORKER_COMMANDS", workerCommands + " ssh scp sftp ssh-keygen", 1) + if ((Bundle.main.object(forInfoDictionaryKey: "ShellWorkerCount") as? NSNumber)?.intValue ?? 0) > 0 { + let workerCommands = getenv("ASHELL_WORKER_COMMANDS").map { String(cString: $0) } ?? "" + setenv("ASHELL_WORKER_COMMANDS", workerCommands + " ssh scp sftp ssh-keygen", 1) + } // Store variables in User Defaults: UserDefaults.standard.register(defaults: ["zshmarks" : true]) UserDefaults.standard.register(defaults: ["bashmarks" : false]) From a2fceba1030f08fb12e3c27347572a2dd8eaef74 Mon Sep 17 00:00:00 2001 From: demrom Date: Sat, 24 Oct 2026 00:16:40 +0300 Subject: [PATCH 15/16] Document stock client home-path migration --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 16c7162e..6369cd92 100644 --- a/README.md +++ b/README.md @@ -164,4 +164,4 @@ done For a Simulator-only build, omit the device framework argument. Use fresh output paths when changing inputs. The mini Embed Frameworks phase signs these XCFrameworks. The mini command catalog selects their public entry points; extension catalogs are generated from the mini catalog. Application startup appends the four commands to the existing worker selection only when the target declares a positive `ShellWorkerCount`, preserving any configured commands. Targets without shell workers retain the existing client selection. The matching runtime runs each admitted root once and requires a fresh worker for nested client invocations; missing worker APIs or exhausted admission return status 127. -Run the producer's `phone-client.sh` through the app against a disposable SSH account with throwaway keys. Check version, key generation, strict login, scp/sftp byte round trips, multiple-source SCP errors and a following successful login. Keep existing config, keys and known_hosts for migration checks. Compilation and catalog validation alone do not prove these runtime behaviors. +Run the producer's `phone-client.sh` through the app against a disposable SSH account with throwaway keys. Check version, key generation, strict login, scp/sftp byte round trips, multiple-source SCP errors and a following successful login. Stock clients resolve their default config, keys and known_hosts through `$HOME/.ssh`, with the app HOME pointing to Documents. They do not read the legacy `SSH_HOME` override; a custom SSH_HOME setup must select its config with `-F` and its identity/known_hosts paths in that config. Keep existing files for migration checks. Compilation and catalog validation alone do not prove these runtime behaviors. From 5dacf19091a18372f29c89ba002574f5d8390b56 Mon Sep 17 00:00:00 2001 From: demrom Date: Sat, 24 Oct 2026 00:20:05 +0300 Subject: [PATCH 16/16] Match migration instructions to the client home adapter --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6369cd92..e6be7584 100644 --- a/README.md +++ b/README.md @@ -164,4 +164,4 @@ done For a Simulator-only build, omit the device framework argument. Use fresh output paths when changing inputs. The mini Embed Frameworks phase signs these XCFrameworks. The mini command catalog selects their public entry points; extension catalogs are generated from the mini catalog. Application startup appends the four commands to the existing worker selection only when the target declares a positive `ShellWorkerCount`, preserving any configured commands. Targets without shell workers retain the existing client selection. The matching runtime runs each admitted root once and requires a fresh worker for nested client invocations; missing worker APIs or exhausted admission return status 127. -Run the producer's `phone-client.sh` through the app against a disposable SSH account with throwaway keys. Check version, key generation, strict login, scp/sftp byte round trips, multiple-source SCP errors and a following successful login. Stock clients resolve their default config, keys and known_hosts through `$HOME/.ssh`, with the app HOME pointing to Documents. They do not read the legacy `SSH_HOME` override; a custom SSH_HOME setup must select its config with `-F` and its identity/known_hosts paths in that config. Keep existing files for migration checks. Compilation and catalog validation alone do not prove these runtime behaviors. +Run the producer's `phone-client.sh` through the app against a disposable SSH account with throwaway keys. Check version, key generation, strict login, scp/sftp byte round trips, multiple-source SCP errors and a following successful login. Stock clients use the existing app `SSH_HOME` root (Documents by default) for `.ssh/config`, keys and known_hosts, falling back to HOME when SSH_HOME is absent. Keep existing files for migration checks. Compilation and catalog validation alone do not prove these runtime behaviors.