691 lines
26 KiB
Rust
691 lines
26 KiB
Rust
// m6_8_multi_repl_acceptance.rs --- T M6.8 acceptance gates.
|
|
|
|
//! Acceptance gates for T M6.8 (Multi-REPL support).
|
|
//!
|
|
//! Spec acceptance bullets:
|
|
//!
|
|
//! 1. Three REPL buffers running concurrently render and respond
|
|
//! independently → [`m6_8_three_repls_render_independently`],
|
|
//! [`m6_8_three_repls_respond_independently`],
|
|
//! [`m6_8_close_one_does_not_affect_others`].
|
|
//! 2. Process supervisor handles multiple PTY children without
|
|
//! resource leaks → [`m6_8_supervisor_reaps_all_children_across_cycles`].
|
|
//!
|
|
//! Plus structural isolation tests that aren't in the bullets but
|
|
//! catch the most likely shape of multi-REPL regression (accidental
|
|
//! module-level state):
|
|
//!
|
|
//! - [`m6_8_repls_have_independent_parser_state`][] — ANSI parser state
|
|
//! per-handle. One REPL entering alt-screen does not flip another's
|
|
//! `_alt_screen` flag.
|
|
//! - [`m6_8_repls_have_independent_scrollback_state`][] — M6.7 `_blocks`
|
|
//! array per-handle. A submit on one REPL does not extend another's
|
|
//! block list.
|
|
//! - [`m6_8_buffer_scoped_bindings_route_to_active_buffer`][] — keymap
|
|
//! dispatch resolves `pmacs.repl.submit-current` against the active
|
|
//! buffer. Switching active buffer (via `pmacs.window.switch_buffer`,
|
|
//! the production path) re-targets the binding without touching any
|
|
//! handle state directly.
|
|
//! - [`m6_8_after_tick_hook_drains_all_handles_per_tick`][] — a single
|
|
//! `tick_processes` call drains pending events for every registered
|
|
//! handle, not just the first/last. Catches the failure mode where
|
|
//! REPL B feels sluggish under load when other REPLs are also
|
|
//! active.
|
|
//!
|
|
//! # Test process choice
|
|
//!
|
|
//! Lua REPL throughout when a standalone `lua` or `luajit` executable
|
|
//! is present. The embedded Lua build dependency does not guarantee a
|
|
//! shell binary on CI images. Lua's REPL is deterministic in a way
|
|
//! bash/zsh/fish are not (prompt content varies; some shells reorder
|
|
//! echo and prompt under raw mode). The contract under test is
|
|
//! multi-REPL isolation, not shell behavior.
|
|
//!
|
|
//! # Termination semantics for the resource-leak test
|
|
//!
|
|
//! After `Handle:close()` followed by tick draining: the supervisor
|
|
//! sends SIGTERM, the process exits, `_on_exit` fires, and (per the
|
|
//! M6.9 audit fix) the package calls `pmacs.process.forget` to
|
|
//! release the supervisor's record. The post-drain status is
|
|
//! therefore **nil** (forgotten), not `terminated`.
|
|
//!
|
|
//! Pre-M6.9, the package did not call forget, so the supervisor
|
|
//! retained terminated process records forever — a real leak that
|
|
//! compounded across spawn-close cycles. The leak test catches the
|
|
//! regression directly: spawn-three / close-three repeated K times,
|
|
//! verify `pmacs.process.list()` has no leftover REPL entries at the
|
|
//! end. Status nil after close-and-drain is the *expected* clean-
|
|
//! shutdown outcome, not a supervisor bug.
|
|
//!
|
|
//! What would still indicate a supervisor bug: status nil *before*
|
|
//! the exit event fired (premature forgetting). The close-isolation
|
|
//! test runs its status query after the survivor REPLs have echoed,
|
|
//! by which time the closed REPL's exit has been processed; status
|
|
//! nil at that point is graceful cleanup, terminal status is also
|
|
//! acceptable (timing-dependent).
|
|
|
|
use pmacs::editor::EditorState;
|
|
use std::path::{Path, PathBuf};
|
|
use std::time::{Duration, Instant};
|
|
|
|
#[path = "support/mod.rs"]
|
|
mod support;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test harness
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Locate lua or luajit on the PATH. Both spawn a usable interactive
|
|
/// REPL via `-i`; the M6.8 tests don't care which. `PMACS_TEST_LUA`
|
|
/// or `PMACS_TEST_LUAJIT` overrides.
|
|
fn locate_lua() -> Option<PathBuf> {
|
|
for name in ["lua", "luajit"] {
|
|
let env_var = format!("PMACS_TEST_{}", name.to_uppercase());
|
|
if let Ok(path) = std::env::var(&env_var) {
|
|
let p = PathBuf::from(path);
|
|
if p.is_file() {
|
|
return Some(p);
|
|
}
|
|
}
|
|
if let Ok(out) = std::process::Command::new("which").arg(name).output()
|
|
&& out.status.success()
|
|
&& let Ok(path) = String::from_utf8(out.stdout)
|
|
{
|
|
let path = path.trim();
|
|
if !path.is_empty() {
|
|
return Some(PathBuf::from(path));
|
|
}
|
|
}
|
|
}
|
|
support::skip_or_fail_overridable("lua/luajit", "PMACS_REQUIRE_LUA", "PMACS_TEST_LUA");
|
|
None
|
|
}
|
|
|
|
/// Run setup, drive `tick_processes` until the predicate becomes
|
|
/// truthy, fail the test on timeout. Same shape as the M6.5 harness;
|
|
/// duplicated rather than shared because cross-test-binary sharing
|
|
/// would need a fixture crate.
|
|
fn run_with_pump(
|
|
editor: &mut EditorState,
|
|
setup_chunk: &str,
|
|
predicate_chunk: &str,
|
|
timeout_ms: u64,
|
|
) {
|
|
editor
|
|
.lua_host
|
|
.eval(Some("@m6_8_setup"), setup_chunk)
|
|
.expect("setup chunk runs");
|
|
let deadline = Instant::now() + Duration::from_millis(timeout_ms);
|
|
loop {
|
|
editor.tick_processes();
|
|
let truthy: bool = editor
|
|
.lua_host
|
|
.lua()
|
|
.load(predicate_chunk)
|
|
.eval()
|
|
.expect("predicate chunk runs");
|
|
if truthy {
|
|
return;
|
|
}
|
|
assert!(
|
|
Instant::now() < deadline,
|
|
"pump predicate did not become true within {timeout_ms}ms; \
|
|
chunk:\n{predicate_chunk}"
|
|
);
|
|
std::thread::sleep(Duration::from_millis(5));
|
|
}
|
|
}
|
|
|
|
/// Spawn three lua REPLs and wait for all three to reach `running`.
|
|
/// Stores them as `_G.h1`, `_G.h2`, `_G.h3`. Returns when all three
|
|
/// processes have exec'd; subsequent operations can write to stdin
|
|
/// without racing the spawn.
|
|
fn spawn_three_and_wait_running(editor: &mut EditorState, lua: &Path) {
|
|
let setup = format!(
|
|
r#"
|
|
_G.h1 = pmacs.repl.spawn {{ argv = {{ "{lua}", "-i" }} }}
|
|
_G.h2 = pmacs.repl.spawn {{ argv = {{ "{lua}", "-i" }} }}
|
|
_G.h3 = pmacs.repl.spawn {{ argv = {{ "{lua}", "-i" }} }}
|
|
"#,
|
|
lua = lua.display(),
|
|
);
|
|
run_with_pump(
|
|
editor,
|
|
&setup,
|
|
r#"
|
|
local function running(h)
|
|
local s = pmacs.process.status(h._proc_id)
|
|
return s and s.kind == "running"
|
|
end
|
|
return running(_G.h1) and running(_G.h2) and running(_G.h3)
|
|
"#,
|
|
5000,
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Spec bullet 1: render and respond independently
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Three REPLs spawned concurrently each have their own buffer; bytes
|
|
/// written to each end up in their own buffer's history, with no
|
|
/// cross-contamination. Verified at the byte level (each buffer has
|
|
/// the right unique marker, and only that marker).
|
|
#[test]
|
|
fn m6_8_three_repls_render_independently() {
|
|
let Some(lua) = locate_lua() else {
|
|
return;
|
|
};
|
|
let mut editor = EditorState::new();
|
|
spawn_three_and_wait_running(&mut editor, &lua);
|
|
|
|
// Write a unique marker through each REPL: `io.write("MARK_<n>\n")`
|
|
// is a safe lua expression that produces deterministic output.
|
|
// After the markers are echoed back, each buffer's history must
|
|
// contain its own marker and not the others'.
|
|
run_with_pump(
|
|
&mut editor,
|
|
r#"
|
|
local function send(h, marker)
|
|
pmacs.process.write_stdin(h._proc_id, "io.write('" .. marker .. "\\n')\n")
|
|
end
|
|
send(_G.h1, "MARK_ALPHA")
|
|
send(_G.h2, "MARK_BETA")
|
|
send(_G.h3, "MARK_GAMMA")
|
|
"#,
|
|
r#"
|
|
local function has(h, m)
|
|
return h:buffer_id():slice(0, h:history_end()):find(m, 1, true) ~= nil
|
|
end
|
|
return has(_G.h1, "MARK_ALPHA")
|
|
and has(_G.h2, "MARK_BETA")
|
|
and has(_G.h3, "MARK_GAMMA")
|
|
"#,
|
|
5000,
|
|
);
|
|
|
|
// Cross-contamination check: each buffer must contain only its own
|
|
// marker, not its siblings'.
|
|
let _: () = editor
|
|
.lua_host
|
|
.lua()
|
|
.load(
|
|
r#"
|
|
local function has(h, m)
|
|
return h:buffer_id():slice(0, h:history_end()):find(m, 1, true) ~= nil
|
|
end
|
|
assert(not has(_G.h1, "MARK_BETA"), "h1 leaked beta")
|
|
assert(not has(_G.h1, "MARK_GAMMA"), "h1 leaked gamma")
|
|
assert(not has(_G.h2, "MARK_ALPHA"), "h2 leaked alpha")
|
|
assert(not has(_G.h2, "MARK_GAMMA"), "h2 leaked gamma")
|
|
assert(not has(_G.h3, "MARK_ALPHA"), "h3 leaked alpha")
|
|
assert(not has(_G.h3, "MARK_BETA"), "h3 leaked beta")
|
|
_G.h1:close(); _G.h2:close(); _G.h3:close()
|
|
"#,
|
|
)
|
|
.exec()
|
|
.expect("cross-contamination check");
|
|
}
|
|
|
|
/// Buffer-scoped RET binding routes input to the active buffer's
|
|
/// REPL: when h1's buffer is active, submit goes to h1; switch active
|
|
/// to h2's buffer and the same command targets h2.
|
|
///
|
|
/// Active-buffer mechanism: `pmacs.window.switch_buffer(buf)` is the
|
|
/// production code path (used by the package itself in `repl.spawn`
|
|
/// at line 184; verified to call `core.switch_active_buffer` in
|
|
/// `lua_bindings.rs::install_window_module`). The test exercises that
|
|
/// path; it does not synthesize active-buffer state by reaching into
|
|
/// internal tables.
|
|
#[test]
|
|
fn m6_8_three_repls_respond_independently() {
|
|
let Some(lua) = locate_lua() else {
|
|
return;
|
|
};
|
|
let mut editor = EditorState::new();
|
|
spawn_three_and_wait_running(&mut editor, &lua);
|
|
|
|
// Type into h1, switch to h2, type into h2, etc. Each typed marker
|
|
// ends up in the corresponding REPL's buffer; no marker ends up in
|
|
// a sibling's buffer.
|
|
run_with_pump(
|
|
&mut editor,
|
|
r#"
|
|
local function type_and_submit(h, line)
|
|
pmacs.window.switch_buffer(h:buffer_id())
|
|
local buf = h:buffer_id()
|
|
buf:insert(buf:len(), line)
|
|
pmacs.command.invoke("pmacs.repl.submit-current")
|
|
end
|
|
type_and_submit(_G.h1, "io.write('ROUTE_1\\n')")
|
|
type_and_submit(_G.h2, "io.write('ROUTE_2\\n')")
|
|
type_and_submit(_G.h3, "io.write('ROUTE_3\\n')")
|
|
"#,
|
|
r#"
|
|
local function has(h, m)
|
|
return h:buffer_id():slice(0, h:history_end()):find(m, 1, true) ~= nil
|
|
end
|
|
return has(_G.h1, "ROUTE_1")
|
|
and has(_G.h2, "ROUTE_2")
|
|
and has(_G.h3, "ROUTE_3")
|
|
"#,
|
|
5000,
|
|
);
|
|
|
|
// Cross-routing check: ROUTE_1 lives only in h1's history, etc.
|
|
let _: () = editor
|
|
.lua_host
|
|
.lua()
|
|
.load(
|
|
r#"
|
|
local function has(h, m)
|
|
return h:buffer_id():slice(0, h:history_end()):find(m, 1, true) ~= nil
|
|
end
|
|
assert(not has(_G.h1, "ROUTE_2"))
|
|
assert(not has(_G.h1, "ROUTE_3"))
|
|
assert(not has(_G.h2, "ROUTE_1"))
|
|
assert(not has(_G.h2, "ROUTE_3"))
|
|
assert(not has(_G.h3, "ROUTE_1"))
|
|
assert(not has(_G.h3, "ROUTE_2"))
|
|
_G.h1:close(); _G.h2:close(); _G.h3:close()
|
|
"#,
|
|
)
|
|
.exec()
|
|
.expect("cross-routing check");
|
|
}
|
|
|
|
/// Closing one REPL leaves the other two functional. Verifies that
|
|
/// the closed REPL's pump-removal does not de-register the after-tick
|
|
/// hook globally and that the supervisor's iteration over remaining
|
|
/// children is intact. The closed REPL's child receives SIGTERM and
|
|
/// reaches a terminal state; the survivors keep echoing.
|
|
#[test]
|
|
fn m6_8_close_one_does_not_affect_others() {
|
|
let Some(lua) = locate_lua() else {
|
|
return;
|
|
};
|
|
let mut editor = EditorState::new();
|
|
spawn_three_and_wait_running(&mut editor, &lua);
|
|
|
|
// Capture h2's proc_id before close; Handle:close clears the
|
|
// handle's _proc_id field per the M6.5 contract, but the
|
|
// supervisor still tracks the process and we want to query its
|
|
// post-close state.
|
|
run_with_pump(
|
|
&mut editor,
|
|
r#"
|
|
_G.h2_proc_id = _G.h2._proc_id
|
|
_G.h2:close()
|
|
-- h1 and h3 should still respond. Send unique markers post-close.
|
|
pmacs.process.write_stdin(_G.h1._proc_id, "io.write('SURVIVOR_1\\n')\n")
|
|
pmacs.process.write_stdin(_G.h3._proc_id, "io.write('SURVIVOR_3\\n')\n")
|
|
"#,
|
|
r#"
|
|
local function has(h, m)
|
|
return h:buffer_id():slice(0, h:history_end()):find(m, 1, true) ~= nil
|
|
end
|
|
return has(_G.h1, "SURVIVOR_1") and has(_G.h3, "SURVIVOR_3")
|
|
"#,
|
|
5000,
|
|
);
|
|
|
|
let _: () = editor
|
|
.lua_host
|
|
.lua()
|
|
.load(
|
|
r#"
|
|
-- h2's process should be in a terminal or forgotten state.
|
|
-- Post-M6.9, _on_exit calls pmacs.process.forget; once the
|
|
-- exit event fires, status flips to nil. Either outcome
|
|
-- is graceful cleanup — what would indicate a bug is a
|
|
-- non-terminal kind (running/starting/exiting hung).
|
|
local s2 = pmacs.process.status(_G.h2_proc_id)
|
|
if s2 ~= nil then
|
|
assert(s2.kind == "terminated" or s2.kind == "exiting",
|
|
"h2 should be terminal/exiting/forgotten; got "
|
|
.. tostring(s2.kind))
|
|
end
|
|
_G.h1:close(); _G.h3:close()
|
|
"#,
|
|
)
|
|
.exec()
|
|
.expect("close-one isolation check");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Spec bullet 2: resource leak
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Repeated spawn-three / close-three cycles. The leak detector:
|
|
/// `pmacs.process.list()` size must not grow across cycles. Pre-M6.9,
|
|
/// the supervisor retained terminated process records forever (no
|
|
/// auto-forget), so the list size grew by 3 per cycle; the M6.9 audit
|
|
/// fix added `pmacs.process.forget` to `_on_exit` so terminated
|
|
/// processes are released as their exit events fire.
|
|
///
|
|
/// K=10 cycles. A leak that accumulates one record per spawn-close
|
|
/// would show 30 leftover entries at the end; the gate asserts zero.
|
|
#[test]
|
|
fn m6_8_supervisor_reaps_all_children_across_cycles() {
|
|
const CYCLES: usize = 10;
|
|
let Some(lua) = locate_lua() else {
|
|
return;
|
|
};
|
|
let mut editor = EditorState::new();
|
|
|
|
// Baseline: list size before any spawning. The post-cycle list
|
|
// size must equal this — no REPL processes left behind.
|
|
let baseline_list_size: i64 = editor
|
|
.lua_host
|
|
.lua()
|
|
.load("return #pmacs.process.list()")
|
|
.eval()
|
|
.expect("baseline list size");
|
|
|
|
for cycle in 0..CYCLES {
|
|
spawn_three_and_wait_running(&mut editor, &lua);
|
|
// Capture proc_ids before close; Handle:close clears the
|
|
// handle's _proc_id field per the M6.5 contract.
|
|
editor
|
|
.lua_host
|
|
.eval(
|
|
Some("@m6_8_close"),
|
|
r"
|
|
_G.pid1 = _G.h1._proc_id
|
|
_G.pid2 = _G.h2._proc_id
|
|
_G.pid3 = _G.h3._proc_id
|
|
_G.h1:close(); _G.h2:close(); _G.h3:close()
|
|
",
|
|
)
|
|
.expect("close all three");
|
|
|
|
// Pump until all three are forgotten (status nil) or terminal.
|
|
// Either outcome indicates the supervisor has finished cleanup:
|
|
// post-M6.9 the package calls forget in _on_exit, so status
|
|
// typically transitions terminated → nil. Pre-M6.9 it stayed
|
|
// terminated; the broader assertion accepts both transitions
|
|
// so the test isn't fragile to ordering of the forget call
|
|
// vs the predicate evaluation.
|
|
run_with_pump(
|
|
&mut editor,
|
|
"",
|
|
r#"
|
|
local function done(pid)
|
|
local s = pmacs.process.status(pid)
|
|
if s == nil then return true end
|
|
return s.kind == "terminated"
|
|
end
|
|
return done(_G.pid1) and done(_G.pid2) and done(_G.pid3)
|
|
"#,
|
|
5000,
|
|
);
|
|
|
|
// For terminated outcomes, verify the kind is exited or
|
|
// signaled (reject crashed: lua under SIGTERM should never
|
|
// crash). Skip the check for nil (already forgotten —
|
|
// exit-kind information is no longer available, but the
|
|
// _on_exit path that called forget already received an
|
|
// exited/signaled event).
|
|
let _: () = editor
|
|
.lua_host
|
|
.lua()
|
|
.load(
|
|
r#"
|
|
local function check(pid, label)
|
|
local s = pmacs.process.status(pid)
|
|
if s == nil then return end -- already forgotten
|
|
assert(s.kind == "terminated",
|
|
label .. ": non-terminal kind " .. tostring(s.kind))
|
|
assert(s.outcome == "exited" or s.outcome == "signaled",
|
|
label .. ": unexpected outcome " .. tostring(s.outcome))
|
|
end
|
|
check(_G.pid1, "h1"); check(_G.pid2, "h2"); check(_G.pid3, "h3")
|
|
"#,
|
|
)
|
|
.exec()
|
|
.unwrap_or_else(|e| panic!("cycle {cycle}: termination kind check: {e}"));
|
|
}
|
|
|
|
// Leak detector: after all cycles, the supervisor's process list
|
|
// must have no REPL leftovers. Pre-M6.9 this would be CYCLES * 3
|
|
// = 30 entries; the M6.9 audit fix brings it to 0.
|
|
let final_list_size: i64 = editor
|
|
.lua_host
|
|
.lua()
|
|
.load("return #pmacs.process.list()")
|
|
.eval()
|
|
.expect("final list size");
|
|
assert_eq!(
|
|
final_list_size,
|
|
baseline_list_size,
|
|
"supervisor leaked terminated process records: \
|
|
baseline={baseline_list_size}, final={final_list_size}, \
|
|
expected 0 leftover REPL entries (was {} = {CYCLES} cycles * 3 REPLs pre-M6.9)",
|
|
CYCLES * 3
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Structural isolation: parser, scrollback, bindings, drain
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Each handle carries its own ANSI parser. Toggling alt-screen mode
|
|
/// on h1 (synthetic feed) does not flip h2's `_alt_screen` flag.
|
|
/// Catches the failure mode where parser state was accidentally
|
|
/// module-level. No process needed; pure synthetic test against
|
|
/// `pmacs.repl.create` (which the spawn path also calls).
|
|
#[test]
|
|
fn m6_8_repls_have_independent_parser_state() {
|
|
let mut editor = EditorState::new();
|
|
editor
|
|
.lua_host
|
|
.eval(
|
|
Some("@m6_8_parser_iso"),
|
|
r#"
|
|
local h1 = pmacs.repl.create({ name = "*p1*" })
|
|
local h2 = pmacs.repl.create({ name = "*p2*" })
|
|
h1:append_output("\27[?1049h") -- alt-screen enter on h1 only
|
|
assert(h1:alt_screen_active(), "h1 should be in alt-screen")
|
|
assert(not h2:alt_screen_active(), "h2 must not be in alt-screen")
|
|
h1:append_output("\27[?1049l")
|
|
assert(not h1:alt_screen_active())
|
|
assert(not h2:alt_screen_active())
|
|
"#,
|
|
)
|
|
.expect("parser-isolation chunk");
|
|
}
|
|
|
|
/// Each handle carries its own scrollback `_blocks` array. A submit
|
|
/// on h1 extends its block list; h2's block list stays untouched.
|
|
/// Catches the failure mode where the M6.7 block index was
|
|
/// accidentally shared.
|
|
#[test]
|
|
fn m6_8_repls_have_independent_scrollback_state() {
|
|
let mut editor = EditorState::new();
|
|
editor
|
|
.lua_host
|
|
.eval(
|
|
Some("@m6_8_blocks_iso"),
|
|
r#"
|
|
local h1 = pmacs.repl.create({ name = "*b1*" })
|
|
local h2 = pmacs.repl.create({ name = "*b2*" })
|
|
h1:append_output("output for h1\n")
|
|
h1:set_prompt("$ ")
|
|
h1:submit() -- opens block 2 on h1
|
|
assert(#h1._blocks == 2, "h1 should have 2 blocks; got " .. #h1._blocks)
|
|
assert(#h2._blocks == 1,
|
|
"h2 should still have 1 block; got " .. #h2._blocks)
|
|
"#,
|
|
)
|
|
.expect("scrollback-isolation chunk");
|
|
}
|
|
|
|
/// `pmacs.repl.submit-current` resolves against the active buffer.
|
|
/// This test makes h1's buffer active, types, submits → h1's stdin
|
|
/// receives it. Switches active to h2, types, submits → h2's stdin
|
|
/// receives it (and h1's doesn't). The active-buffer mechanism is
|
|
/// the production `pmacs.window.switch_buffer`, not a back-door
|
|
/// table assignment.
|
|
#[test]
|
|
fn m6_8_buffer_scoped_bindings_route_to_active_buffer() {
|
|
let Some(lua) = locate_lua() else {
|
|
return;
|
|
};
|
|
let mut editor = EditorState::new();
|
|
let setup = format!(
|
|
r#"
|
|
_G.h1 = pmacs.repl.spawn {{ argv = {{ "{lua}", "-i" }} }}
|
|
_G.h2 = pmacs.repl.spawn {{ argv = {{ "{lua}", "-i" }} }}
|
|
"#,
|
|
lua = lua.display(),
|
|
);
|
|
run_with_pump(
|
|
&mut editor,
|
|
&setup,
|
|
r#"
|
|
local function running(h)
|
|
local s = pmacs.process.status(h._proc_id)
|
|
return s and s.kind == "running"
|
|
end
|
|
return running(_G.h1) and running(_G.h2)
|
|
"#,
|
|
5000,
|
|
);
|
|
|
|
// With h1 active, type and submit; "BIND_H1" should appear in h1.
|
|
// With h2 active, type and submit; "BIND_H2" should appear in h2.
|
|
run_with_pump(
|
|
&mut editor,
|
|
r#"
|
|
pmacs.window.switch_buffer(_G.h1:buffer_id())
|
|
local b1 = _G.h1:buffer_id()
|
|
b1:insert(b1:len(), "io.write('BIND_H1\\n')")
|
|
pmacs.command.invoke("pmacs.repl.submit-current")
|
|
|
|
pmacs.window.switch_buffer(_G.h2:buffer_id())
|
|
local b2 = _G.h2:buffer_id()
|
|
b2:insert(b2:len(), "io.write('BIND_H2\\n')")
|
|
pmacs.command.invoke("pmacs.repl.submit-current")
|
|
"#,
|
|
r#"
|
|
local function has(h, m)
|
|
return h:buffer_id():slice(0, h:history_end()):find(m, 1, true) ~= nil
|
|
end
|
|
return has(_G.h1, "BIND_H1") and has(_G.h2, "BIND_H2")
|
|
"#,
|
|
5000,
|
|
);
|
|
|
|
let _: () = editor
|
|
.lua_host
|
|
.lua()
|
|
.load(
|
|
r#"
|
|
local function has(h, m)
|
|
return h:buffer_id():slice(0, h:history_end()):find(m, 1, true) ~= nil
|
|
end
|
|
assert(not has(_G.h1, "BIND_H2"), "binding leaked from h2 to h1")
|
|
assert(not has(_G.h2, "BIND_H1"), "binding leaked from h1 to h2")
|
|
_G.h1:close(); _G.h2:close()
|
|
"#,
|
|
)
|
|
.exec()
|
|
.expect("binding-routing isolation");
|
|
}
|
|
|
|
/// A single tick of `tick_processes` drains pending events for every
|
|
/// registered handle. The shape of the test:
|
|
///
|
|
/// 1. Spawn three; wait for all running.
|
|
/// 2. Write a unique marker to each REPL's stdin (synchronous).
|
|
/// 3. Sleep long enough for each process to echo back into its kernel
|
|
/// pipe and for the supervisor's reader thread to pick those bytes
|
|
/// off into the bounded channel.
|
|
/// 4. Drive **one** `tick_processes` call.
|
|
/// 5. Assert all three buffers grew by at least the marker's length
|
|
/// on that tick.
|
|
///
|
|
/// This catches the failure mode where the after-tick callback drains
|
|
/// REPL A but not B in the same tick because of an iteration bug or
|
|
/// short-circuit, which would manifest as REPL B feeling sluggish
|
|
/// when other REPLs are also active.
|
|
#[test]
|
|
fn m6_8_after_tick_hook_drains_all_handles_per_tick() {
|
|
let Some(lua) = locate_lua() else {
|
|
return;
|
|
};
|
|
let mut editor = EditorState::new();
|
|
spawn_three_and_wait_running(&mut editor, &lua);
|
|
|
|
// Capture pre-tick history_end on each handle.
|
|
let pre_lengths: Vec<i64> = editor
|
|
.lua_host
|
|
.lua()
|
|
.load(
|
|
r"
|
|
return { _G.h1:history_end(), _G.h2:history_end(), _G.h3:history_end() }
|
|
",
|
|
)
|
|
.eval::<mlua::Table>()
|
|
.expect("pre-tick lengths")
|
|
.sequence_values::<i64>()
|
|
.map(|r| r.expect("seq value"))
|
|
.collect();
|
|
assert_eq!(pre_lengths.len(), 3, "expected 3 pre-tick lengths");
|
|
|
|
// Write markers to all three stdins, synchronously and in the same
|
|
// Lua chunk so all three writes happen before any tick fires.
|
|
editor
|
|
.lua_host
|
|
.eval(
|
|
Some("@m6_8_drain_writes"),
|
|
r#"
|
|
pmacs.process.write_stdin(_G.h1._proc_id, "io.write('TICK_M1\\n')\n")
|
|
pmacs.process.write_stdin(_G.h2._proc_id, "io.write('TICK_M2\\n')\n")
|
|
pmacs.process.write_stdin(_G.h3._proc_id, "io.write('TICK_M3\\n')\n")
|
|
"#,
|
|
)
|
|
.expect("drain writes");
|
|
|
|
// Sleep long enough for each lua REPL to echo + emit + the
|
|
// supervisor's reader thread to receive into the bounded channel.
|
|
// 500 ms is generous on local hardware (lua's interactive loop
|
|
// responds in single-digit ms typically); CI is slower but well
|
|
// under this bound.
|
|
std::thread::sleep(Duration::from_millis(500));
|
|
|
|
// ONE tick.
|
|
editor.tick_processes();
|
|
|
|
// Post-tick: every buffer's history_end must have grown.
|
|
let post_lengths: Vec<i64> = editor
|
|
.lua_host
|
|
.lua()
|
|
.load(
|
|
r"
|
|
return { _G.h1:history_end(), _G.h2:history_end(), _G.h3:history_end() }
|
|
",
|
|
)
|
|
.eval::<mlua::Table>()
|
|
.expect("post-tick lengths")
|
|
.sequence_values::<i64>()
|
|
.map(|r| r.expect("seq value"))
|
|
.collect();
|
|
for i in 0..3 {
|
|
assert!(
|
|
post_lengths[i] > pre_lengths[i],
|
|
"handle {} did not grow in single tick: pre={}, post={}",
|
|
i + 1,
|
|
pre_lengths[i],
|
|
post_lengths[i]
|
|
);
|
|
}
|
|
|
|
let _: () = editor
|
|
.lua_host
|
|
.lua()
|
|
.load("_G.h1:close(); _G.h2:close(); _G.h3:close()")
|
|
.exec()
|
|
.expect("teardown");
|
|
}
|