@linzumi/cli 1.0.292 → 1.0.293

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,655 @@
1
+ /* Spec: spec/native-remote-terminal-rules.md (:native-remote-terminal/lifecycle,
2
+ * :native-remote-terminal/termination, :native-remote-terminal/wire).
3
+ *
4
+ * One PTY lifetime per helper process. Commander owns the helper through pipes:
5
+ *
6
+ * argv: <cols> <rows> <cwd> <shell-path> <argv0>
7
+ * stdin: control frames, each [type:1][length:4 big-endian][payload]
8
+ * 'i' input bytes, written to the PTY master exactly once
9
+ * 'r' resize: cols u16 BE, rows u16 BE (clamped 20..400 x 5..200)
10
+ * 's' stop: SIGHUP+SIGTERM the shell group, SIGKILL after 2s
11
+ * EOF on stdin is treated as 's' (Commander died or detached us).
12
+ * SIGTERM/SIGHUP/SIGINT delivered to the helper itself are also 's',
13
+ * so a stop is never queued behind unconsumed input.
14
+ * stdout: raw PTY output bytes, unmodified (non-blocking, bounded buffer:
15
+ * when Commander does not drain stdout the helper stops reading the
16
+ * PTY - bounded backpressure on the shell - but keeps servicing
17
+ * stop, resize and deadlines; it never drops or reorders bytes).
18
+ * Input is never discarded while the session runs: a frame is consumed
19
+ * only when its bytes fit the bounded pending-input buffer; otherwise stdin
20
+ * is left unread (backpressure on Commander) until the shell reads. Stop
21
+ * fences input: bytes accepted but not yet written are dropped and nothing
22
+ * reaches the PTY afterwards.
23
+ * stderr: content-free status lines only:
24
+ * LINZUMI_PTY_STARTED pid=<pid>
25
+ * LINZUMI_PTY_EXIT code=<n> | signal=<n> group=<absent|present|unknown> discarded=<n>
26
+ * (group: every process of the shell's session - its own group
27
+ * and every job group, foreground or background - whether the
28
+ * shell exited on its own or was stopped; unknown when an
29
+ * enumeration or identity lookup failed - never a false absent)
30
+ * (discarded: post-Stop output bytes Commander was not draining;
31
+ * explicit, never silent)
32
+ * LINZUMI_PTY_ERROR <code>
33
+ *
34
+ * The helper never logs input/output content. The post-fork child does only
35
+ * async-signal-safe work (chdir, execve, _exit); argv/envp are built pre-fork.
36
+ *
37
+ * The shell is observed exiting with waitid(WNOWAIT) and reaped only after the
38
+ * last signal is sent: while it is an unreaped zombie its pid - which is also
39
+ * its process-group and session id - cannot be reused, so no group signal or
40
+ * session enumeration can ever reach an unrelated process
41
+ * (:native-remote-terminal/lifecycle: never PID identity alone). */
42
+ #define _DARWIN_C_SOURCE 1
43
+ #define _DEFAULT_SOURCE 1
44
+ #include <errno.h>
45
+ #include <fcntl.h>
46
+ #include <poll.h>
47
+ #include <signal.h>
48
+ #include <stdint.h>
49
+ #include <stdio.h>
50
+ #include <stdlib.h>
51
+ #include <string.h>
52
+ #include <sys/ioctl.h>
53
+ #include <sys/stat.h>
54
+ #include <sys/types.h>
55
+ #include <sys/wait.h>
56
+ #include <termios.h>
57
+ #include <time.h>
58
+ #include <unistd.h>
59
+ #if defined(__APPLE__)
60
+ #include <sys/event.h>
61
+ #include <sys/sysctl.h>
62
+ #include <util.h>
63
+ #else
64
+ #include <pty.h>
65
+ #endif
66
+
67
+ #define FRAME_HEADER 5
68
+ #define MAX_FRAME_PAYLOAD (64 * 1024)
69
+ #define MAX_PENDING_INPUT (256 * 1024)
70
+ #define OUTPUT_CHUNK (16 * 1024)
71
+ #define MAX_PENDING_OUTPUT (256 * 1024)
72
+ #define FINAL_DRAIN_MS 2000
73
+ #define STOP_GRACE_MS 2000
74
+ /* After SIGKILL, how long the helper waits (on kqueue exit events, never a
75
+ * fixed sleep) for the kernel to confirm the processes are gone. */
76
+ #define KILL_CONFIRM_MS 1000
77
+ /* Final cleanup re-enumerates and kills at most this many times. */
78
+ #define CLEANUP_ROUNDS 4
79
+
80
+ extern char **environ;
81
+
82
+ static void status_line(const char *line) {
83
+ size_t length = strlen(line);
84
+ while (length > 0) {
85
+ ssize_t wrote = write(STDERR_FILENO, line, length);
86
+ if (wrote < 0) {
87
+ if (errno == EINTR) continue;
88
+ return;
89
+ }
90
+ line += wrote;
91
+ length -= (size_t)wrote;
92
+ }
93
+ }
94
+
95
+ static int fail(const char *code, int status) {
96
+ char line[128];
97
+ snprintf(line, sizeof(line), "LINZUMI_PTY_ERROR %s\n", code);
98
+ status_line(line);
99
+ return status;
100
+ }
101
+
102
+ static int parse_dimension(const char *text, long min, long max, unsigned short *out) {
103
+ char *end = NULL;
104
+ errno = 0;
105
+ long value = strtol(text, &end, 10);
106
+ if (errno || !end || *end || value < 1) return -1;
107
+ if (value < min) value = min;
108
+ if (value > max) value = max;
109
+ *out = (unsigned short)value;
110
+ return 0;
111
+ }
112
+
113
+ static unsigned short clamp_u16(unsigned int value, unsigned int min, unsigned int max) {
114
+ if (value < min) return (unsigned short)min;
115
+ if (value > max) return (unsigned short)max;
116
+ return (unsigned short)value;
117
+ }
118
+
119
+ static long long monotonic_ms(void) {
120
+ struct timespec now;
121
+ clock_gettime(CLOCK_MONOTONIC, &now);
122
+ return (long long)now.tv_sec * 1000LL + now.tv_nsec / 1000000LL;
123
+ }
124
+
125
+ static int set_nonblocking(int fd) {
126
+ int flags = fcntl(fd, F_GETFL, 0);
127
+ if (flags < 0) return -1;
128
+ return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
129
+ }
130
+
131
+ /* Signals every process group a Stop must reach: the shell's own group and
132
+ * the terminal's current foreground job group. */
133
+ static void signal_terminal(pid_t shell, int master, int signo) {
134
+ kill(-shell, signo);
135
+ if (master >= 0) {
136
+ pid_t foreground = tcgetpgrp(master);
137
+ if (foreground > 1 && foreground != shell) kill(-foreground, signo);
138
+ }
139
+ }
140
+
141
+ /* Cleanup reaches every process of the shell's session - background jobs
142
+ * that ignore SIGHUP/SIGTERM included - on Stop AND on the shell's own exit.
143
+ * The session id outlives the shell's exit (a tty enumeration does not: the
144
+ * kernel detaches the terminal when the session leader exits), and the
145
+ * unreaped shell keeps the id from being reused. Each member is recorded as
146
+ * pid + start time and is signalled only after a fresh lookup proves that
147
+ * identity; a reused PID never matches, and a failed lookup is never treated
148
+ * as an exit. Work that left the session (setsid, launchd, cron) is outside
149
+ * the guarantee (:native-remote-terminal/termination). */
150
+ #define MAX_MEMBERS 512
151
+ typedef struct {
152
+ pid_t pid;
153
+ struct timeval started;
154
+ } member_t;
155
+ static member_t members[MAX_MEMBERS];
156
+ static size_t member_count = 0;
157
+ /* An enumeration failed or overflowed: absence can no longer be confirmed. */
158
+ static int membership_incomplete = 0;
159
+
160
+ typedef enum { MEMBER_ALIVE, MEMBER_GONE, MEMBER_UNKNOWN } member_state_t;
161
+
162
+ #if defined(LINZUMI_PTY_TEST_FAULTS)
163
+ /* Test-only builds (scripts/test_terminal_pty_helper_darwin.py): force the
164
+ * per-pid identity lookup to fail, or stop just before the shell is reaped so
165
+ * the test can observe it is still held. The shipped asset has no hooks. */
166
+ static int test_fault(const char *name) {
167
+ const char *fault = getenv("LINZUMI_PTY_TEST_FAULT");
168
+ return fault != NULL && strcmp(fault, name) == 0;
169
+ }
170
+ #define fault_pid_lookup() test_fault("pid_lookup")
171
+ #define fault_pause_before_reap() test_fault("pause_before_reap")
172
+ #define fault_pause_after_fork() test_fault("pause_after_fork")
173
+ #else
174
+ #define fault_pid_lookup() 0
175
+ #define fault_pause_before_reap() 0
176
+ #define fault_pause_after_fork() 0
177
+ #endif
178
+
179
+ static void add_member(pid_t pid, struct timeval started) {
180
+ for (size_t i = 0; i < member_count; i++) {
181
+ if (members[i].pid == pid && members[i].started.tv_sec == started.tv_sec &&
182
+ members[i].started.tv_usec == started.tv_usec)
183
+ return;
184
+ }
185
+ if (member_count >= MAX_MEMBERS) {
186
+ membership_incomplete = 1;
187
+ return;
188
+ }
189
+ members[member_count].pid = pid;
190
+ members[member_count].started = started;
191
+ member_count++;
192
+ }
193
+
194
+ #if defined(__APPLE__)
195
+ /* Exiting (P_WEXIT, set before the exit event fires) or a zombie: the
196
+ * process is gone; only its parent's reap is outstanding. */
197
+ static int process_exited(const struct kinfo_proc *info) {
198
+ return info->kp_proc.p_stat == SZOMB || (info->kp_proc.p_flag & P_WEXIT) != 0;
199
+ }
200
+
201
+ /* Tri-state identity check: a lookup failure is UNKNOWN, never GONE. */
202
+ static member_state_t probe_member(const member_t *member) {
203
+ int mib[4] = {CTL_KERN, KERN_PROC, KERN_PROC_PID, (int)member->pid};
204
+ struct kinfo_proc info;
205
+ size_t size = sizeof(info);
206
+ if (fault_pid_lookup() || sysctl(mib, 4, &info, &size, NULL, 0) != 0) return MEMBER_UNKNOWN;
207
+ if (size == 0) return MEMBER_GONE; /* no such pid */
208
+ if (size != sizeof(info)) return MEMBER_UNKNOWN;
209
+ if (process_exited(&info)) return MEMBER_GONE;
210
+ if (info.kp_proc.p_starttime.tv_sec != member->started.tv_sec ||
211
+ info.kp_proc.p_starttime.tv_usec != member->started.tv_usec)
212
+ return MEMBER_GONE; /* the pid was reused: the recorded process is gone */
213
+ return MEMBER_ALIVE;
214
+ }
215
+
216
+ /* Records every live process of session `sid`. Called while the session
217
+ * leader is unreaped, so `sid` is still this terminal's session. */
218
+ static void collect_session_members(pid_t sid) {
219
+ int mib[3] = {CTL_KERN, KERN_PROC, KERN_PROC_ALL};
220
+ size_t size = 0;
221
+ if (sysctl(mib, 3, NULL, &size, NULL, 0) != 0 || size == 0) {
222
+ membership_incomplete = 1;
223
+ return;
224
+ }
225
+ size += size / 4 + sizeof(struct kinfo_proc) * 16;
226
+ struct kinfo_proc *procs = malloc(size);
227
+ if (procs == NULL) {
228
+ membership_incomplete = 1;
229
+ return;
230
+ }
231
+ if (sysctl(mib, 3, procs, &size, NULL, 0) != 0) {
232
+ membership_incomplete = 1;
233
+ free(procs);
234
+ return;
235
+ }
236
+ size_t count = size / sizeof(struct kinfo_proc);
237
+ for (size_t i = 0; i < count; i++) {
238
+ pid_t pid = procs[i].kp_proc.p_pid;
239
+ if (pid <= 1 || pid == getpid() || process_exited(&procs[i])) continue;
240
+ pid_t session = getsid(pid);
241
+ if (session < 0) {
242
+ if (errno != ESRCH) membership_incomplete = 1; /* ESRCH: exited meanwhile */
243
+ continue;
244
+ }
245
+ if (session == sid) add_member(pid, procs[i].kp_proc.p_starttime);
246
+ }
247
+ free(procs);
248
+ }
249
+
250
+ /* Blocks until every pid has exited (kqueue NOTE_EXIT) or `timeout_ms`
251
+ * passes. A pid already gone fails registration and is not waited on. */
252
+ static void await_exits(const pid_t *pids, size_t count, int timeout_ms) {
253
+ int queue = kqueue();
254
+ if (queue < 0) return;
255
+ size_t watching = 0;
256
+ for (size_t i = 0; i < count; i++) {
257
+ struct kevent change;
258
+ EV_SET(&change, pids[i], EVFILT_PROC, EV_ADD | EV_ONESHOT, NOTE_EXIT, 0, NULL);
259
+ if (kevent(queue, &change, 1, NULL, 0, NULL) == 0) watching++;
260
+ }
261
+ long long deadline = monotonic_ms() + timeout_ms;
262
+ while (watching > 0) {
263
+ long long left = deadline - monotonic_ms();
264
+ if (left <= 0) break;
265
+ struct timespec wait = {(time_t)(left / 1000), (long)((left % 1000) * 1000000LL)};
266
+ struct kevent fired[64];
267
+ int got = kevent(queue, NULL, 0, fired, 64, &wait);
268
+ if (got < 0 && errno == EINTR) continue;
269
+ if (got <= 0) break;
270
+ watching = (size_t)got >= watching ? 0 : watching - (size_t)got;
271
+ }
272
+ close(queue);
273
+ }
274
+ #else
275
+ static void collect_session_members(pid_t sid) {
276
+ (void)sid;
277
+ membership_incomplete = 1; /* no process enumeration on this host */
278
+ }
279
+ #endif
280
+
281
+ /* Signals every recorded member whose identity is freshly proven. */
282
+ static void signal_members(int signo) {
283
+ #if defined(__APPLE__)
284
+ for (size_t i = 0; i < member_count; i++)
285
+ if (probe_member(&members[i]) == MEMBER_ALIVE) kill(members[i].pid, signo);
286
+ #else
287
+ (void)signo;
288
+ #endif
289
+ }
290
+
291
+ /* Recorded members still alive, into `out`; *unknown is set when any
292
+ * member's identity could not be looked up. */
293
+ static size_t alive_members(pid_t *out, size_t max, int *unknown) {
294
+ size_t alive = 0;
295
+ #if defined(__APPLE__)
296
+ for (size_t i = 0; i < member_count; i++) {
297
+ member_state_t state = probe_member(&members[i]);
298
+ if (state == MEMBER_UNKNOWN) *unknown = 1;
299
+ else if (state == MEMBER_ALIVE && alive < max) out[alive++] = members[i].pid;
300
+ }
301
+ #else
302
+ (void)out;
303
+ (void)max;
304
+ *unknown = 1;
305
+ #endif
306
+ return alive;
307
+ }
308
+
309
+ static volatile sig_atomic_t stop_signal = 0;
310
+
311
+ static void on_stop_signal(int signo) {
312
+ (void)signo;
313
+ stop_signal = 1;
314
+ }
315
+
316
+ int main(int argc, char **argv) {
317
+ if (argc != 6) return fail("usage", 64);
318
+ unsigned short cols = 0, rows = 0;
319
+ if (parse_dimension(argv[1], 20, 400, &cols) != 0 ||
320
+ parse_dimension(argv[2], 5, 200, &rows) != 0)
321
+ return fail("invalid_dimensions", 64);
322
+ const char *cwd = argv[3];
323
+ const char *shell = argv[4];
324
+ const char *argv0 = argv[5];
325
+ struct stat cwd_stat;
326
+ if (cwd[0] != '/' || stat(cwd, &cwd_stat) != 0 || !S_ISDIR(cwd_stat.st_mode))
327
+ return fail("invalid_cwd", 65);
328
+ if (shell[0] != '/' || access(shell, X_OK) != 0)
329
+ return fail("invalid_shell", 66);
330
+ if (argv0[0] == '\0') return fail("invalid_argv0", 64);
331
+
332
+ /* Everything the child needs is prepared before fork. */
333
+ if (setenv("TERM", "xterm-256color", 1) != 0 ||
334
+ setenv("COLORTERM", "truecolor", 1) != 0)
335
+ return fail("environment", 70);
336
+ char *child_argv[2] = {(char *)argv0, NULL};
337
+ signal(SIGPIPE, SIG_IGN);
338
+
339
+ /* Stop signals are blocked from before the shell exists until the
340
+ * parent's handlers are installed: a Stop that races startup is held
341
+ * pending, never taken by the default disposition (which would skip the
342
+ * group cleanup and the attested exit line). */
343
+ sigset_t stop_signals, prior_mask;
344
+ sigemptyset(&stop_signals);
345
+ sigaddset(&stop_signals, SIGTERM);
346
+ sigaddset(&stop_signals, SIGHUP);
347
+ sigaddset(&stop_signals, SIGINT);
348
+ if (sigprocmask(SIG_BLOCK, &stop_signals, &prior_mask) != 0)
349
+ return fail("signal_mask", 71);
350
+
351
+ struct winsize size;
352
+ memset(&size, 0, sizeof(size));
353
+ size.ws_col = cols;
354
+ size.ws_row = rows;
355
+ int master = -1;
356
+ pid_t child = forkpty(&master, NULL, NULL, &size);
357
+ if (child < 0) return fail("forkpty", 71);
358
+ if (child == 0) {
359
+ /* POST-FORK CHILD: async-signal-safe calls only. The user's shell gets
360
+ * the ordinary dispositions: SIGPIPE terminates writers again and the
361
+ * stop signals are no longer blocked (both survive execve otherwise). */
362
+ struct sigaction default_action;
363
+ memset(&default_action, 0, sizeof(default_action));
364
+ default_action.sa_handler = SIG_DFL;
365
+ sigemptyset(&default_action.sa_mask);
366
+ sigaction(SIGPIPE, &default_action, NULL);
367
+ sigprocmask(SIG_SETMASK, &prior_mask, NULL);
368
+ if (chdir(cwd) != 0) _exit(126);
369
+ execve(shell, child_argv, environ);
370
+ _exit(127);
371
+ }
372
+
373
+ if (fault_pause_after_fork()) {
374
+ /* Holds the window between fork and handler installation open so the
375
+ * test can deliver a Stop inside it. */
376
+ status_line("LINZUMI_PTY_TEST_FORKED\n");
377
+ raise(SIGSTOP);
378
+ }
379
+ {
380
+ struct sigaction action;
381
+ memset(&action, 0, sizeof(action));
382
+ action.sa_handler = on_stop_signal;
383
+ sigemptyset(&action.sa_mask);
384
+ sigaction(SIGTERM, &action, NULL);
385
+ sigaction(SIGHUP, &action, NULL);
386
+ sigaction(SIGINT, &action, NULL);
387
+ }
388
+ /* Handlers are ready: a Stop held pending since before the fork is now
389
+ * delivered to on_stop_signal and handled by the main loop. */
390
+ sigprocmask(SIG_SETMASK, &prior_mask, NULL);
391
+ {
392
+ char line[64];
393
+ snprintf(line, sizeof(line), "LINZUMI_PTY_STARTED pid=%d\n", (int)child);
394
+ status_line(line);
395
+ }
396
+ if (set_nonblocking(master) != 0 || set_nonblocking(STDIN_FILENO) != 0) {
397
+ kill(child, SIGKILL);
398
+ return fail("nonblocking", 71);
399
+ }
400
+ if (set_nonblocking(STDOUT_FILENO) != 0) {
401
+ kill(child, SIGKILL);
402
+ return fail("nonblocking", 71);
403
+ }
404
+ static unsigned char frame[FRAME_HEADER + MAX_FRAME_PAYLOAD];
405
+ static unsigned char pending[MAX_PENDING_INPUT];
406
+ static unsigned char out[MAX_PENDING_OUTPUT];
407
+ size_t frame_used = 0;
408
+ size_t pending_used = 0;
409
+ size_t out_used = 0;
410
+ unsigned long long discarded = 0; /* post-stop bytes Commander could not take */
411
+ int stdin_open = 1;
412
+ int stdout_open = 1;
413
+ int master_open = 1;
414
+ int stopping = 0;
415
+ long long kill_deadline = 0; /* stop: SIGKILL the group at this instant */
416
+ long long finish_deadline = 0; /* shell exited: final drain ends here */
417
+ int group_killed = 0;
418
+ int child_status = 0;
419
+ /* The shell exited but is deliberately NOT reaped until cleanup ends. */
420
+ int child_exited = 0;
421
+
422
+ #define BEGIN_STOP() \
423
+ do { \
424
+ if (!stopping) { \
425
+ stopping = 1; \
426
+ pending_used = 0; /* fenced: never reaches PTY */ \
427
+ kill_deadline = monotonic_ms() + STOP_GRACE_MS; \
428
+ collect_session_members(child); \
429
+ signal_terminal(child, master, SIGHUP); \
430
+ signal_terminal(child, master, SIGTERM); \
431
+ } \
432
+ } while (0)
433
+
434
+ for (;;) {
435
+ long long now = monotonic_ms();
436
+ if (stop_signal) BEGIN_STOP();
437
+ if (!child_exited) {
438
+ siginfo_t exited;
439
+ memset(&exited, 0, sizeof(exited));
440
+ if (waitid(P_PID, (id_t)child, &exited, WEXITED | WNOHANG | WNOWAIT) == 0 &&
441
+ exited.si_pid == child) {
442
+ child_exited = 1;
443
+ finish_deadline = now + FINAL_DRAIN_MS;
444
+ if (!stopping) {
445
+ /* The shell's own exit ends the terminal too: its background jobs
446
+ * are asked to leave now and killed at the drain deadline. */
447
+ kill_deadline = finish_deadline;
448
+ collect_session_members(child);
449
+ signal_members(SIGHUP);
450
+ signal_members(SIGTERM);
451
+ }
452
+ }
453
+ }
454
+ /* Deadlines run in every phase, including after the shell exited. */
455
+ if ((stopping || child_exited) && !group_killed && kill_deadline > 0 && now >= kill_deadline) {
456
+ signal_terminal(child, master_open ? master : -1, SIGKILL);
457
+ if (!child_exited) kill(child, SIGKILL);
458
+ collect_session_members(child);
459
+ signal_members(SIGKILL);
460
+ group_killed = 1;
461
+ }
462
+ if (child_exited) {
463
+ int drained = !master_open && out_used == 0;
464
+ if (drained || !stdout_open || now >= finish_deadline) break;
465
+ }
466
+
467
+ struct pollfd fds[3];
468
+ nfds_t count = 0;
469
+ int master_index = -1, stdin_index = -1, stdout_index = -1;
470
+ short master_events = 0;
471
+ /* Once stopping, the PTY is drained unconditionally: a dying session
472
+ * leader cannot finish closing its terminal while the output queue is
473
+ * full, so stop would otherwise wait on Commander reading stdout. */
474
+ if (master_open && (stopping || out_used + OUTPUT_CHUNK <= sizeof(out))) master_events |= POLLIN;
475
+ /* Input accepted before Stop but not yet written is dropped at Stop
476
+ * (authority is fenced); nothing reaches the PTY afterwards. */
477
+ if (master_open && !stopping && pending_used > 0) master_events |= POLLOUT;
478
+ if (master_events) {
479
+ fds[count].fd = master;
480
+ fds[count].events = master_events;
481
+ fds[count].revents = 0;
482
+ master_index = (int)count++;
483
+ }
484
+ /* Read stdin only when a whole maximal frame's input would fit, so a
485
+ * consumed frame is never discarded - but always watch it: a hangup
486
+ * (Commander gone) is reported even with no read interest. */
487
+ int stdin_readable_room =
488
+ pending_used + MAX_FRAME_PAYLOAD <= sizeof(pending) && frame_used < sizeof(frame);
489
+ if (stdin_open) {
490
+ fds[count].fd = STDIN_FILENO;
491
+ fds[count].events = stdin_readable_room ? POLLIN : 0;
492
+ fds[count].revents = 0;
493
+ stdin_index = (int)count++;
494
+ }
495
+ if (stdout_open && out_used > 0) {
496
+ fds[count].fd = STDOUT_FILENO;
497
+ fds[count].events = POLLOUT;
498
+ fds[count].revents = 0;
499
+ stdout_index = (int)count++;
500
+ }
501
+ int ready = poll(fds, count, 50);
502
+ if (ready < 0 && errno != EINTR) break;
503
+ if (ready <= 0) continue;
504
+
505
+ if (master_index >= 0) {
506
+ short revents = fds[master_index].revents;
507
+ if ((revents & (POLLIN | POLLHUP | POLLERR)) && (master_events & POLLIN)) {
508
+ static unsigned char discard[OUTPUT_CHUNK];
509
+ int room = out_used + OUTPUT_CHUNK <= sizeof(out);
510
+ ssize_t got = read(master, room ? out + out_used : discard, OUTPUT_CHUNK);
511
+ if (got > 0) {
512
+ if (room) out_used += (size_t)got;
513
+ else discarded += (unsigned long long)got; /* reported in the EXIT line */
514
+ } else if (got == 0 || (errno != EINTR && errno != EAGAIN && errno != EWOULDBLOCK))
515
+ master_open = 0; /* EIO: every slave fd closed */
516
+ } else if (revents & (POLLHUP | POLLERR)) {
517
+ master_open = 0;
518
+ }
519
+ if (master_open && !stopping && (revents & POLLOUT) && pending_used > 0) {
520
+ ssize_t wrote = write(master, pending, pending_used);
521
+ if (wrote > 0) {
522
+ memmove(pending, pending + wrote, pending_used - (size_t)wrote);
523
+ pending_used -= (size_t)wrote;
524
+ }
525
+ }
526
+ }
527
+
528
+ if (stdout_index >= 0 && (fds[stdout_index].revents & (POLLOUT | POLLERR | POLLHUP))) {
529
+ ssize_t wrote = write(STDOUT_FILENO, out, out_used);
530
+ if (wrote > 0) {
531
+ memmove(out, out + wrote, out_used - (size_t)wrote);
532
+ out_used -= (size_t)wrote;
533
+ } else if (wrote < 0 && errno != EINTR && errno != EAGAIN && errno != EWOULDBLOCK) {
534
+ stdout_open = 0; /* Commander is gone: nothing can consume output */
535
+ discarded += (unsigned long long)out_used;
536
+ out_used = 0;
537
+ BEGIN_STOP();
538
+ }
539
+ }
540
+
541
+ if (stdin_index >= 0 && !stdin_readable_room &&
542
+ (fds[stdin_index].revents & (POLLHUP | POLLERR))) {
543
+ /* No room to read, and the owner hung up: nothing can ever drain the
544
+ * pending input; the session is ownerless, so stop it. */
545
+ stdin_open = 0;
546
+ BEGIN_STOP();
547
+ } else if (stdin_index >= 0 && (fds[stdin_index].revents & (POLLIN | POLLHUP | POLLERR))) {
548
+ ssize_t got = read(STDIN_FILENO, frame + frame_used, sizeof(frame) - frame_used);
549
+ if (got == 0) {
550
+ stdin_open = 0;
551
+ BEGIN_STOP();
552
+ } else if (got < 0) {
553
+ if (errno != EINTR && errno != EAGAIN && errno != EWOULDBLOCK) {
554
+ stdin_open = 0;
555
+ BEGIN_STOP();
556
+ }
557
+ } else {
558
+ frame_used += (size_t)got;
559
+ }
560
+ }
561
+
562
+ /* Consume every complete frame that can be applied without loss. */
563
+ for (;;) {
564
+ if (frame_used < FRAME_HEADER) break;
565
+ uint32_t length = ((uint32_t)frame[1] << 24) | ((uint32_t)frame[2] << 16) |
566
+ ((uint32_t)frame[3] << 8) | (uint32_t)frame[4];
567
+ if (length > MAX_FRAME_PAYLOAD) {
568
+ status_line("LINZUMI_PTY_ERROR frame_too_large\n");
569
+ stdin_open = 0;
570
+ frame_used = 0;
571
+ BEGIN_STOP();
572
+ break;
573
+ }
574
+ if (frame_used < FRAME_HEADER + length) break;
575
+ unsigned char type = frame[0];
576
+ unsigned char *payload = frame + FRAME_HEADER;
577
+ if (type == 'i') {
578
+ if (pending_used + length > sizeof(pending)) break; /* wait; never drop */
579
+ if (!stopping && master_open) {
580
+ memcpy(pending + pending_used, payload, length);
581
+ pending_used += length;
582
+ }
583
+ } else if (type == 'r' && length == 4 && master_open) {
584
+ struct winsize next;
585
+ memset(&next, 0, sizeof(next));
586
+ next.ws_col = clamp_u16(((unsigned)payload[0] << 8) | payload[1], 20, 400);
587
+ next.ws_row = clamp_u16(((unsigned)payload[2] << 8) | payload[3], 5, 200);
588
+ ioctl(master, TIOCSWINSZ, &next);
589
+ } else if (type == 's') {
590
+ BEGIN_STOP();
591
+ }
592
+ size_t consumed = FRAME_HEADER + length;
593
+ memmove(frame, frame + consumed, frame_used - consumed);
594
+ frame_used -= consumed;
595
+ }
596
+ }
597
+
598
+ if (!child_exited) {
599
+ kill(-child, SIGKILL);
600
+ kill(child, SIGKILL);
601
+ /* Wait for the exit WITHOUT reaping: the pid stays reserved. */
602
+ siginfo_t exited;
603
+ while (waitid(P_PID, (id_t)child, &exited, WEXITED | WNOWAIT) != 0 && errno == EINTR) {
604
+ }
605
+ }
606
+ if (master >= 0) close(master);
607
+ /* Output still buffered when the helper ends was never delivered. */
608
+ discarded += (unsigned long long)out_used;
609
+ /* Absence is confirmed only when every process of the shell's session is
610
+ * gone and every enumeration and identity lookup succeeded. The shell is
611
+ * still unreaped, so its group/session id cannot name anything else here.
612
+ * Exits are awaited on kernel exit events, bounded by KILL_CONFIRM_MS. */
613
+ int group_present = 0;
614
+ #if defined(__APPLE__)
615
+ {
616
+ static pid_t live[MAX_MEMBERS];
617
+ int unknown = 0;
618
+ size_t alive = 0;
619
+ /* Bounded rounds: each re-enumerates the session, so a member forked
620
+ * while the previous round was killing is recorded and killed too. */
621
+ for (int round = 0; round <= CLEANUP_ROUNDS; round++) {
622
+ collect_session_members(child);
623
+ unknown = 0;
624
+ alive = alive_members(live, MAX_MEMBERS, &unknown);
625
+ if (alive == 0 || round == CLEANUP_ROUNDS) break;
626
+ kill(-child, SIGKILL);
627
+ signal_members(SIGKILL);
628
+ await_exits(live, alive, KILL_CONFIRM_MS);
629
+ }
630
+ if (alive > 0) group_present = 1;
631
+ if (unknown || membership_incomplete) group_present = 2;
632
+ }
633
+ #else
634
+ /* No process enumeration on this host: absence is never confirmed. */
635
+ group_present = 2;
636
+ #endif
637
+ if (fault_pause_before_reap()) {
638
+ status_line("LINZUMI_PTY_TEST_REAP_PENDING\n");
639
+ raise(SIGSTOP);
640
+ }
641
+ /* Only now - after the last signal - release the shell's pid. */
642
+ while (waitpid(child, &child_status, 0) < 0 && errno == EINTR) {
643
+ }
644
+ char line[128];
645
+ if (WIFSIGNALED(child_status)) {
646
+ snprintf(line, sizeof(line), "LINZUMI_PTY_EXIT signal=%d group=%s discarded=%llu\n",
647
+ WTERMSIG(child_status), group_present == 2 ? "unknown" : (group_present ? "present" : "absent"), discarded);
648
+ } else {
649
+ snprintf(line, sizeof(line), "LINZUMI_PTY_EXIT code=%d group=%s discarded=%llu\n",
650
+ WIFEXITED(child_status) ? WEXITSTATUS(child_status) : 0,
651
+ group_present == 2 ? "unknown" : (group_present ? "present" : "absent"), discarded);
652
+ }
653
+ status_line(line);
654
+ return 0;
655
+ }