pmacs/builtin/runtime/repl.lua

896 lines
32 KiB
Lua

-- builtin/runtime/repl.lua --- M6.4 REPL package skeleton.
--
-- A buffer with three regions (history / prompt / input) and an
-- intercept that enforces read-only / truncate-to-input policy.
-- Spec: §sec:repl-view.
--
-- # Region tracking: marks
--
-- The history/prompt boundaries are backed by core buffer marks.
-- `_history_end` and `_prompt_end` remain as compatibility mirrors
-- for tests and package introspection, but the authoritative positions
-- are `_history_end_mark` and `_prompt_end_mark`. This matters for
-- process prompts: user edits in the input region must not accidentally
-- move the prompt boundary, while package output inserted before the
-- prompt must move both boundaries with the rope.
--
-- # Self-write bypass
--
-- The intercept enforces region read-only. The package's own writes
-- (append_output, set_prompt, submit) need to land bytes inside the
-- read-only history/prompt regions. We toggle a `_self_write` flag
-- around package-driven calls; the intercept reads it and waives the
-- policy when set. The flag is reset under pcall to survive a rope
-- error mid-write.
--
-- # Submit does not append to history
--
-- handle:submit() pops the input region's text and returns it. It
-- does NOT append the popped text to history. The shell process is
-- in raw mode and echoes user input back; the parser appends the
-- echo to history. M6.5 wires submit's return value to
-- pmacs.process.write. Doing it twice (once on submit, once on
-- echo) would double the input and force M6.5 to detect-and-suppress
-- the echo, which is the messier path. M6.4 leaves this clean for
-- M6.5 to wire.
--
-- # Process integration (M6.5)
--
-- pmacs.repl.spawn { argv, env, cwd, rows, cols, name } extends create
-- by also spawning a child via the process supervisor in raw PTY mode
-- (per spec: shell-line-editor handles its own echo, which surfaces
-- through the parser to history). The handle gains a _proc_id field
-- and is registered on a per-frame pump driven by the
-- `process.after-tick` hook. The pump drains events_take(_proc_id)
-- and routes stdout/stderr through append_output and exit events
-- through _on_exit.
--
-- Stderr handling is defensive: in PTY mode the kernel TTY layer
-- merges stderr into stdout, so stderr events should not appear at
-- all. Routing them uniformly to append_output is a safety net
-- against future regressions or pipe-mode use; seeing one in PTY
-- mode signals an upstream bug.
--
-- # Scrollback management (M6.7)
--
-- History accumulates as bytes flow in from the process. Two retention
-- knobs (lines and bytes) bound the history region; truncation drops
-- complete command-output blocks from the oldest end whenever either
-- invariant is violated. Spec: §sec:repl-perf.
--
-- Block tracking: Handle:_blocks is an array of `{ start_byte, lines }`
-- entries. The first block (start_byte = 0) covers all bytes received
-- before the first submit; this pre-first-submit block is degenerate
-- but real, so a process that produces a long preamble before its
-- first prompt still has a valid truncation boundary. Each user
-- submission opens a new block (when the current block has bytes;
-- empty submissions don't create zero-length blocks, preserving the
-- strictly-increasing start_byte invariant).
--
-- Truncation runs at tick boundaries (process.after-tick), not on
-- each byte append: per-byte overhead would regress the M6.6
-- 100 MB/s ingest gate. A `_dirty_since_last_tick` flag is set in
-- _emit_history and cleared by the truncation pass; idle handles
-- skip the check entirely. Worst-case lag from "limit exceeded" to
-- "truncation runs" is one tick (~16 ms), which is below user-
-- visible thresholds.
--
-- Single-pass truncation: when both line and byte invariants can be
-- violated, removing oldest-block-at-a-time and rechecking after each
-- removal is order-independent and always terminates. The
-- alternative (satisfy lines-first, then bytes, or vice versa) gives
-- different results when blocks have wildly different sizes; the
-- single-pass loop is the deterministic shape.
pmacs.repl = {}
local repl = pmacs.repl
-- Scrollback retention knobs. Mutable at runtime; the truncation pass
-- reads them on every check, so changing these takes effect on the
-- next tick. Defaults track §sec:repl-perf: 10000 lines is the
-- navigation/search benchmark size, and 16 MiB is the byte-pressure
-- backstop sized to keep RSS well below the M6.6 200 MB ceiling
-- across multiple concurrent REPLs.
repl.config = {
scrollback_lines = 10000,
scrollback_bytes = 16 * 1024 * 1024,
}
-- { [raw_proc_id_int] = handle }. Keyed on raw integer because Lua
-- raw-table-key equality does not consult ProcessIdLua's __eq. The
-- after-tick hook walks this map; spawn inserts; close removes.
local proc_pump = {}
local Handle = {}
Handle.__index = Handle
-- Toggle the self-write bypass around a closure. pcall is used so
-- the flag resets even when the wrapped call errors, otherwise a
-- single failed write would leave the bypass on for every subsequent
-- user edit.
local function with_self_write(h, fn)
h._self_write = true
local ok, err = pcall(fn)
h._self_write = false
if not ok then error(err) end
end
local function new_handle(buffer_id)
return setmetatable({
_buf = buffer_id,
_parser = pmacs.ansi.parser(),
_history_end_mark = pmacs.buffer.mark_create(buffer_id, 0, { gravity = "left" }),
_prompt_end_mark = pmacs.buffer.mark_create(buffer_id, 0, { gravity = "left" }),
_history_end = 0,
_prompt_end = 0,
-- Latest SetStyle observed. M6.4 doesn't render this anywhere
-- (rendering will arrive with the M6.4-spec-style-channel work);
-- we capture it so the post-alt-screen-exit running style stays
-- consistent for M6.5.
_current_style = nil,
_alt_screen = false,
_title = nil,
_output_pos = 0,
_capture = "history",
_style_overlay = nil,
_self_write = false,
_intercept_handle = nil,
-- Scrollback block index (M6.7). The first block is degenerate
-- but real: it covers any bytes received before the first user
-- submit. Subsequent blocks open in Handle:submit. The active
-- (last) block is never removed by truncation. Each entry only
-- carries `start_byte`; line counts are computed lazily inside
-- the truncation pass, never on the per-byte _emit_history path
-- (gsub allocation per byte regressed M6.6's 100 MB/s ingest
-- gate by ~40%).
_blocks = { { start_byte = 0 } },
_dirty_since_last_tick = false,
}, Handle)
end
local function sync_marks(h)
h._history_end = h._history_end_mark:get()
h._prompt_end = h._prompt_end_mark:get()
end
local function history_end(h)
local pos = h._history_end_mark:get()
h._history_end = pos
return pos
end
local function prompt_end(h)
local pos = h._prompt_end_mark:get()
h._prompt_end = pos
return pos
end
local function set_history_end(h, pos)
h._history_end_mark:set(pos)
h._history_end = pos
end
local function set_prompt_end(h, pos)
h._prompt_end_mark:set(pos)
h._prompt_end = pos
end
-- ---------------------------------------------------------------------
-- Construction / teardown
-- ---------------------------------------------------------------------
function repl.create(opts)
opts = opts or {}
local name = opts.name or "*repl*"
local buf = pmacs.buffer.create(name)
local h = new_handle(buf)
h._intercept_handle = pmacs.buffer.add_intercept(buf, function(op)
return repl._intercept(h, op)
end)
if pmacs.buffer.add_style_overlay then
h._style_overlay = pmacs.buffer.add_style_overlay(buf)
end
return h
end
-- Validate argv: must be a non-empty array of strings. Returns the
-- argv unchanged on success. Errors point at the corrected call shape
-- per the project's error-message-points-at-the-workaround posture.
local function validate_argv(argv)
if type(argv) ~= "table" then
error("pmacs.repl.spawn: opts.argv must be an array of strings " ..
"(e.g. argv = { \"bash\", \"-i\" })")
end
if #argv < 1 then
error("pmacs.repl.spawn: opts.argv must have at least one element " ..
"(the command); got an empty array")
end
for i, v in ipairs(argv) do
if type(v) ~= "string" then
error("pmacs.repl.spawn: opts.argv[" .. i .. "] must be a string; got "
.. type(v))
end
end
return argv
end
-- Last path component, or the whole string if there is no slash. Used
-- to derive the exit-marker name from argv[0]; users invoke `bash`,
-- not `/usr/bin/bash`. Empty input yields the empty string.
local function basename(s)
s = s or ""
-- Greedy `.*/` strips through the last slash, leaving the basename.
return (s:gsub("^.*/", ""))
end
local function copy_env(env)
local out = {}
if env then
for k, v in pairs(env) do out[k] = v end
end
return out
end
local function shell_prompt_marker_env(argv, base_env)
local shell = basename(argv[1])
if shell ~= "bash" and shell ~= "zsh" then
return base_env
end
local env = copy_env(base_env)
env.PS1 = "\27]133;A\7$ \27]133;B\7"
return env
end
function repl.spawn(opts)
opts = opts or {}
local argv = validate_argv(opts.argv)
local rows = opts.rows or 24
local cols = opts.cols or 80
local name = opts.name or ("*" .. basename(argv[1]) .. "*")
local h = repl.create { name = name }
h._argv = argv
h._display_name = basename(argv[1])
if h._display_name == "" then h._display_name = "process" end
-- Slice argv into command + args for the supervisor's spawn shape.
local args = {}
for i = 2, #argv do args[i - 1] = argv[i] end
local spec = {
label = name,
command = argv[1],
args = args,
pty = { rows = rows, cols = cols, mode = "raw" },
ansi = true,
}
if opts.cwd then spec.cwd = opts.cwd end
local env = opts.env
if opts.prompt_markers ~= false then
env = shell_prompt_marker_env(argv, env)
end
if env then spec.env = env end
local proc_id = pmacs.process.spawn(spec)
h._proc_id = proc_id
proc_pump[proc_id:raw()] = h
-- Make the REPL buffer the active window's current buffer. Without
-- this, the buffer-scoped RET / C-c / C-d bindings never fire (the
-- user's keys still target the previous active buffer) and the
-- buffer-lookup commands no-op. Mirrors the convention of
-- `pmacs.workers.show()` (see commands/default.lua:532).
if pmacs.window and pmacs.window.switch_buffer then
pcall(pmacs.window.switch_buffer, h._buf)
end
if pmacs.buffer.attach_style_overlay and h._style_overlay then
pcall(pmacs.buffer.attach_style_overlay, h._buf, h._style_overlay)
end
-- Buffer-scoped bindings. RET submits the input region to the
-- process; C-c sends SIGINT; C-d closes stdin (when input empty)
-- or deletes the character after cursor (otherwise). Each command
-- looks the handle up by buffer (linear scan over proc_pump; N is
-- typically 1-3, so list-walk dominates a hash-map allocation).
pmacs.keymap.bind {
scope = "buffer", buffer = h._buf, sequence = "RET",
command = "pmacs.repl.submit-current",
}
pmacs.keymap.bind {
scope = "buffer", buffer = h._buf, sequence = "C-c",
command = "pmacs.repl.send-sigint-current",
}
pmacs.keymap.bind {
scope = "buffer", buffer = h._buf, sequence = "C-d",
command = "pmacs.repl.send-eof-current",
}
return h
end
-- Close: send SIGTERM and let the after-tick hook drive the rest of
-- the teardown via _on_exit (which removes the handle from
-- proc_pump, clears _proc_id, and calls pmacs.process.forget).
--
-- M6.9 audit shape: close() does NOT pre-empt _on_exit's cleanup.
-- Pre-M6.9 close() eagerly cleared _proc_id and proc_pump, which
-- meant the after-tick hook stopped routing events to the closed
-- handle and the supervisor's eventual exit event was never
-- observed by the package — the supervisor retained terminated
-- process records forever (a real leak across spawn-close cycles).
-- Post-M6.9: close() sets _closing so bound commands no-op
-- immediately; the handle stays registered until _on_exit observes
-- the exit and calls forget.
function Handle:close()
if self._intercept_handle then
pmacs.buffer.remove_intercept(self._intercept_handle)
self._intercept_handle = nil
end
if self._proc_id and not self._exited then
self._closing = true
-- Best-effort terminate. If the child already exited (events
-- drained the exit event since last tick), terminate raises;
-- pcall ignores that. _on_exit will fire on the next tick that
-- processes the exit event.
pcall(function() pmacs.process.terminate(self._proc_id) end)
end
end
-- ---------------------------------------------------------------------
-- Read-only queries
-- ---------------------------------------------------------------------
function Handle:buffer_id()
return self._buf
end
function Handle:history_end()
return history_end(self)
end
function Handle:prompt_end()
return prompt_end(self)
end
function Handle:title()
return self._title
end
function Handle:input_text()
return self._buf:slice(prompt_end(self), self._buf:len())
end
function Handle:alt_screen_active()
return self._alt_screen
end
function Handle:style_spans()
if not self._style_overlay then return {} end
return self._style_overlay:spans()
end
-- ---------------------------------------------------------------------
-- Package-driven writes
-- ---------------------------------------------------------------------
-- Feed raw bytes (synthetic in M6.4, PTY in M6.5) through the ANSI
-- parser; apply each event to the buffer. Text events land at
-- history_end (extending history and pushing prompt/input forward).
-- Style events update the running style. Alt-screen markers toggle
-- suppression at the parser level (so Text events between markers
-- never reach us).
function Handle:append_output(bytes)
self:append_events(self._parser:feed(bytes))
end
function Handle:append_events(events)
for _, ev in ipairs(events) do
local kind = ev.kind
if kind == "text" then
if self._capture == "prompt" then
self:_emit_prompt(ev.text)
else
self:_emit_history(ev.text)
end
elseif kind == "set_style" then
self._current_style = ev.style
elseif kind == "alt_screen_enter" then
self._alt_screen = true
elseif kind == "alt_screen_exit" then
self._alt_screen = false
elseif kind == "set_title" then
self._title = ev.title
elseif kind == "prompt_start" then
self:_begin_prompt_capture()
elseif kind == "prompt_end" then
self:_end_prompt_capture()
elseif kind == "command_start" or kind == "output_start" then
self:_begin_command_output()
elseif kind == "carriage_return" then
self._output_pos = self:_current_line_start()
elseif kind == "backspace" then
local line_start = self:_current_line_start()
if self._output_pos > line_start then
self._output_pos = self._output_pos - 1
end
elseif kind == "erase_to_eol" then
self:_delete_history_range(self._output_pos, self:_current_line_end())
elseif kind == "erase_line" then
local line_start = self:_current_line_start()
local line_end = self:_current_line_end()
self:_delete_history_range(line_start, line_end)
self._output_pos = line_start
-- bracketed_paste_* markers are delimiters only; process-emitted
-- contents are ordinary text events between them.
end
end
end
-- Replace the current prompt region's text. The history region is
-- untouched; the input region is preserved (it sits past prompt_end).
function Handle:set_prompt(text)
text = text or ""
local h_end = history_end(self)
local p_end = prompt_end(self)
with_self_write(self, function()
self._buf:replace(h_end, p_end, text)
end)
set_prompt_end(self, history_end(self) + #text)
sync_marks(self)
end
-- Pop the input region's text. Returns the popped string. Does NOT
-- append to history --- M6.5 echoes via the process round-trip.
--
-- M6.7: opens a new scrollback block at the current history boundary,
-- but only if the active block has accumulated bytes. Empty
-- submissions (submit-with-no-output-since-last-submit) leave the
-- block list unchanged, preserving the strictly-increasing
-- start_byte invariant.
function Handle:submit()
local text = self:input_text()
local p_end = prompt_end(self)
with_self_write(self, function()
self._buf:delete(p_end, self._buf:len())
end)
local last = self._blocks[#self._blocks]
local h_end = history_end(self)
if h_end > last.start_byte then
self._blocks[#self._blocks + 1] = { start_byte = h_end }
end
return text
end
-- ---------------------------------------------------------------------
-- Internal: history extension
-- ---------------------------------------------------------------------
function Handle:_emit_history(text)
if #text == 0 then return end
local h_end = history_end(self)
local pos = self._output_pos or h_end
if pos > h_end then pos = h_end end
local overwrite_len = math.min(#text, h_end - pos)
local insert_len = #text - overwrite_len
with_self_write(self, function()
if overwrite_len > 0 then
self._buf:replace(pos, pos + overwrite_len, text:sub(1, overwrite_len))
end
if insert_len > 0 then
self._buf:insert(pos + overwrite_len, text:sub(overwrite_len + 1))
end
end)
if insert_len > 0 then
self:_adjust_blocks_after_edit(pos + overwrite_len, 0, insert_len)
end
sync_marks(self)
set_history_end(self, h_end + insert_len)
if prompt_end(self) < history_end(self) then
set_prompt_end(self, history_end(self))
end
self._output_pos = pos + #text
self:_add_style_span(pos, pos + #text)
-- M6.7: mark the handle for the next tick's truncation check.
-- Per-byte work beyond this assignment regresses the M6.6 100 MB/s
-- ingest gate; line counting is deferred to _maybe_truncate.
self._dirty_since_last_tick = true
end
function Handle:_begin_prompt_capture()
self._capture = "prompt"
self:set_prompt("")
end
function Handle:_emit_prompt(text)
if #text == 0 then return end
local p_end = prompt_end(self)
with_self_write(self, function()
self._buf:insert(p_end, text)
end)
set_prompt_end(self, p_end + #text)
self:_add_style_span(p_end, p_end + #text)
end
function Handle:_end_prompt_capture()
self._capture = "history"
self._output_pos = history_end(self)
sync_marks(self)
end
function Handle:_begin_command_output()
self._capture = "history"
self:set_prompt("")
self._output_pos = history_end(self)
end
local function style_is_default(style)
if not style then return true end
return style.fg == "default"
and style.bg == "default"
and not style.bold
and not style.italic
and style.underline == "none"
and not style.reverse
end
function Handle:_add_style_span(start_pos, end_pos)
if not self._style_overlay then return end
if start_pos >= end_pos then return end
if style_is_default(self._current_style) then return end
self._style_overlay:add(start_pos, end_pos, self._current_style)
end
function Handle:_adjust_blocks_after_edit(start_pos, old_len, new_len)
local delta = new_len - old_len
if delta == 0 then return end
for i = 1, #self._blocks do
local b = self._blocks[i]
if b.start_byte > start_pos then
b.start_byte = b.start_byte + delta
if b.start_byte < start_pos then b.start_byte = start_pos end
end
end
end
function Handle:_current_line_start()
local h_end = history_end(self)
local pos = self._output_pos or h_end
if pos > h_end then pos = h_end end
local prefix = self._buf:slice(0, pos)
local start = 0
local search = 1
while true do
local idx = prefix:find("\n", search, true)
if not idx then return start end
start = idx
search = idx + 1
end
end
function Handle:_current_line_end()
local h_end = history_end(self)
local pos = self._output_pos or h_end
if pos > h_end then pos = h_end end
local suffix = self._buf:slice(pos, h_end)
local idx = suffix:find("\n", 1, true)
if idx then return pos + idx - 1 end
return h_end
end
function Handle:_delete_history_range(start_pos, end_pos)
if end_pos <= start_pos then return end
with_self_write(self, function()
self._buf:delete(start_pos, end_pos)
end)
local removed = end_pos - start_pos
sync_marks(self)
if self._output_pos > end_pos then
self._output_pos = self._output_pos - removed
elseif self._output_pos > start_pos then
self._output_pos = start_pos
end
self:_adjust_blocks_after_edit(start_pos, removed, 0)
self._dirty_since_last_tick = true
end
-- ---------------------------------------------------------------------
-- Scrollback truncation (M6.7)
-- ---------------------------------------------------------------------
-- Count newlines in `s` using string.find with the plain-text flag.
-- LuaJIT JIT-compiles this loop to memchr-equivalent speed, so it's
-- fast enough to call once per truncation pass on a multi-MB slice.
-- Not called on the hot _emit_history path.
local function count_newlines(s)
local n = 0
local i = 0
while true do
i = s:find("\n", i + 1, true)
if not i then return n end
n = n + 1
end
end
-- Lazy total-lines query. Computed by scanning the rope's history
-- region; allocates one Lua string of size _history_end. Cheap
-- enough at per-tick cadence for retention sizes in the spec range
-- (16 MiB / 10000 lines), and skipped entirely by the byte-only
-- shortcut in within_limits.
local function history_lines(h)
return count_newlines(h._buf:slice(0, history_end(h)))
end
-- Both invariants in one predicate, with a fast path that avoids the
-- expensive line count. Bound: every line is at least one byte
-- (the newline itself), so `_history_end <= scrollback_lines`
-- proves `lines <= scrollback_lines` without scanning. Same goes
-- for the byte invariant. Only when both quick checks fail do we
-- pay for the line scan.
local function within_limits(h)
local cfg = repl.config
local h_end = history_end(h)
if h_end > cfg.scrollback_bytes then return false end
if h_end <= cfg.scrollback_lines then return true end
return history_lines(h) <= cfg.scrollback_lines
end
-- Remove the oldest scrollback block. Adjusts every position-bearing
-- field by the removed length so positions stay consistent: history
-- and prompt boundaries shift down, surviving block start_bytes
-- shift down. The buffer:delete uses the self-write bypass so the
-- read-only-history intercept doesn't veto. Caller must guarantee
-- #_blocks >= 2.
local function drop_oldest_block(h)
local first = h._blocks[1]
local second = h._blocks[2]
local removed_bytes = second.start_byte - first.start_byte
with_self_write(h, function()
h._buf:delete(first.start_byte, second.start_byte)
end)
sync_marks(h)
h._output_pos = math.max(0, (h._output_pos or history_end(h)) - removed_bytes)
table.remove(h._blocks, 1)
for i = 1, #h._blocks do
h._blocks[i].start_byte = h._blocks[i].start_byte - removed_bytes
end
end
-- Single-pass truncation. Removing oldest blocks one at a time,
-- rechecking both invariants after each removal, is order-
-- independent: the loop terminates when both hold or only the
-- active block remains. Splitting into "satisfy lines first, then
-- bytes" (or vice versa) gives different results when blocks have
-- wildly different sizes; the single-pass loop is the deterministic
-- shape and the one we want.
--
-- Fast path: with only the active block, there is nothing to drop
-- (the spec rule "removes complete command-output blocks" excludes
-- the in-progress one). Skipping the within_limits scan here keeps
-- the M6.6 stress test (no submits, one block forever) at zero
-- per-tick overhead.
function Handle:_maybe_truncate()
if #self._blocks <= 1 then return end
while not within_limits(self) and #self._blocks > 1 do
drop_oldest_block(self)
end
end
-- ---------------------------------------------------------------------
-- Intercept policy
-- ---------------------------------------------------------------------
-- Called for every apply_edit on the REPL's buffer. Returns nil
-- (pass-through), a transformed op table (truncate to input), or
-- raises (reject). Self-writes (the package's own append/set_prompt
-- /submit) bypass the policy via the _self_write flag.
function repl._intercept(h, op)
if h._self_write then
return nil
end
local prompt_end = prompt_end(h)
if op.kind == "insert" then
if op.pos < prompt_end then
error("REPL: history/prompt region is read-only (insert at "
.. op.pos .. "; input region begins at " .. prompt_end .. ")")
end
return nil
elseif op.kind == "delete" then
if op["end"] <= prompt_end then
error("REPL: history/prompt region is read-only (delete ["
.. op.start .. "," .. op["end"] .. "); input begins at "
.. prompt_end .. ")")
end
if op.start < prompt_end then
-- Truncate the range to the input region. Bytes are not
-- carried by Delete ops, so this is lossless.
return { kind = "delete", start = prompt_end, ["end"] = op["end"] }
end
return nil
elseif op.kind == "replace" then
if op["end"] <= prompt_end then
error("REPL: history/prompt region is read-only (replace ["
.. op.start .. "," .. op["end"] .. "); input begins at "
.. prompt_end .. ")")
end
if op.start < prompt_end then
-- Truncate the range; bytes pass through unchanged (per
-- LuaInterceptView's M6.4 byte-immutability rule). The user's
-- intended bytes still land at prompt_end onward; the
-- prompt-region portion of the original range is no longer
-- replaced. Spec: §sec:repl-view "edits that span the input
-- region boundary are truncated to the input region."
return { kind = "replace", start = prompt_end, ["end"] = op["end"] }
end
return nil
end
end
-- ---------------------------------------------------------------------
-- Per-frame event pump (T M6.5)
-- ---------------------------------------------------------------------
-- Drain a single handle's pending supervisor events, routing each to
-- the appropriate handle method. Stdout/stderr land in append_output
-- (which feeds the parser). Exit events flag the handle and clean up
-- the registry entry. "started" / "restarting" are informational and
-- ignored by M6.5.
local function drain_handle(h)
if not h._proc_id then return end
local events = pmacs.process.events_take(h._proc_id)
for _, ev in ipairs(events) do
local kind = ev.kind
if kind == "stdout" or kind == "stderr" then
-- Defensive: in PTY mode the kernel TTY layer merges stderr
-- into stdout, so stderr events should not appear here. If
-- they do (regression / pipe-mode use), routing them through
-- append_output preserves user output rather than dropping it.
h:append_output(ev.bytes)
elseif kind == "ansi" then
h:append_events(ev.events)
elseif kind == "exited" or kind == "signaled" or kind == "crashed" then
h:_on_exit(ev)
end
end
end
-- Single subscription installed at module load. Walks the pump
-- registry and drains each handle. The hook fires every frame
-- (T M6.5 contract); an empty registry is a fast no-op.
--
-- M6.7: after draining (which may have appended bytes via
-- _emit_history → _dirty_since_last_tick = true), check truncation
-- on dirty handles. Per-tick is the right cadence: per-byte would
-- regress the M6.6 100 MB/s gate, and the worst-case lag of one
-- tick (~16 ms) is below user-visible thresholds.
pmacs.hook.add("process.after-tick", function()
for _, h in pairs(proc_pump) do
drain_handle(h)
if h._dirty_since_last_tick then
h._dirty_since_last_tick = false
h:_maybe_truncate()
end
end
end)
-- Format the exit marker emitted into history when the child
-- terminates. Uses basename(argv[0]) (stored as _display_name) so
-- /usr/bin/bash displays as `bash`, falling back to `process` for
-- empty argv[0]. Always leads with `\n` so that processes which
-- exited mid-line (no trailing newline) don't run on into the
-- marker. Symbolic signal names (SIGINT, SIGTERM, ...) rather than
-- numbers, since numbers vary by platform.
local function format_exit_marker(name, ev)
if ev.kind == "exited" then
return string.format("\n[%s exited with code %d]\n", name, ev.code or 0)
elseif ev.kind == "signaled" then
return string.format("\n[%s killed by %s]\n", name, ev.signal or "signal")
elseif ev.kind == "crashed" then
return string.format("\n[%s crashed: %s]\n", name, ev.error or "unknown")
else
return string.format("\n[%s exited]\n", name)
end
end
-- Emit the exit marker into history and finalize teardown.
-- Self-write bypass is required because the marker lands inside the
-- read-only history region. After this fires, bound commands no-op
-- (via the _exited check) and the supervisor no longer tracks the
-- process (forget releases its record).
--
-- M6.9 audit shape: _on_exit is the single point of teardown. It
-- removes the handle from proc_pump (so the after-tick hook stops
-- iterating it), clears _proc_id, and calls pmacs.process.forget so
-- the supervisor releases its terminated-process record. Pre-M6.9
-- close() eagerly cleared proc_pump and _proc_id, which prevented
-- _on_exit from firing and caused supervisor records to leak across
-- spawn-close cycles.
function Handle:_on_exit(ev)
if self._exited then return end
self._exited = true
local name = self._display_name or "process"
local marker = format_exit_marker(name, ev)
-- _emit_history wraps the buffer write in with_self_write so the
-- intercept lets the bytes through.
self:_emit_history(marker)
if self._proc_id then
proc_pump[self._proc_id:raw()] = nil
-- Forget releases the supervisor's record. pcall in case it has
-- already been forgotten by the user (e.g., manual cleanup).
pcall(pmacs.process.forget, self._proc_id)
self._proc_id = nil
end
end
-- ---------------------------------------------------------------------
-- Buffer-bound commands (T M6.5)
-- ---------------------------------------------------------------------
-- Find the spawned handle that owns `buf`. Linear scan over the pump
-- registry (N is typically 1-3 active REPLs). BufferIdLua's __eq
-- compares wrapped IDs, so two userdata wrapping the same buffer
-- compare equal here.
local function handle_for_buffer(buf)
if buf == nil then return nil end
for _, h in pairs(proc_pump) do
if h._buf == buf then return h end
end
return nil
end
-- Submit the input region to the process. After-tick later routes
-- the shell's echo (or the process's plain bytes-back, for cat-style
-- programs) into history via append_output. We append "\n" so the
-- recipient sees a complete line; raw-mode shells with line editors
-- treat that as the line-end signal.
pmacs.command.define {
name = "pmacs.repl.submit-current",
description = "Submit the REPL input region to the spawned process.",
fn = function()
local h = handle_for_buffer(pmacs.window.buffer())
if not h then return end
if h._exited or h._closing then return end
local text = h:submit()
pmacs.process.write_stdin(h._proc_id, text .. "\n")
end,
}
-- C-c: deliver SIGINT to the foreground process group. Raw-mode
-- shells (which manage their own signal handling) typically catch
-- this, abort the in-progress line, and print a fresh prompt.
pmacs.command.define {
name = "pmacs.repl.send-sigint-current",
description = "Send SIGINT to the spawned REPL process.",
fn = function()
local h = handle_for_buffer(pmacs.window.buffer())
if not h then return end
if h._exited or h._closing then return end
pmacs.process.signal(h._proc_id, "INT")
end,
}
-- C-d: spec-literal "close stdin on empty prompt", paired with
-- delete-char-forward when the input region is non-empty so users
-- never see C-d as broken. Empty case writes \x04 (EOT); raw-mode
-- shells with a line editor interpret that as end-of-input. Non-empty
-- case deletes through the REPL buffer at the cursor when it is inside
-- the input region, falling back to the input start if the editor
-- cursor is stale/outside the region.
pmacs.command.define {
name = "pmacs.repl.send-eof-current",
description = "Close stdin on empty input region; delete-char-forward otherwise.",
fn = function()
local h = handle_for_buffer(pmacs.window.buffer())
if not h then return end
if h._exited or h._closing then return end
if h:input_text() == "" then
pmacs.process.write_stdin(h._proc_id, "\x04")
else
local start = h:prompt_end()
local len = h._buf:len()
local pos = pmacs.editor.cursor()
if pos < start or pos >= len then pos = start end
if pos < len then h._buf:delete(pos, pos + 1) end
end
end,
}