diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index 406a3d6..1a9795a 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -7589,6 +7589,15 @@ pub fn install_async( // cannot see UTF-8 validity from Lua 5.1, so the byte-level half is // enforced here — the one point where Rust sees the name — with a // message that names both. + // + // And it names the surfaces a JOB reaches, which are `*workers*` and + // the modeline activity indicator. The sibling refusal in + // `required_purpose` deliberately names a different one + // (`pmacs.process.list`), because a spawned process reaches neither + // of these in Stage 1. The two must not converge on one sentence: + // whichever wording won would be wrong on the other side, and a + // diagnostic that misdescribes the system sends the reader looking + // in the wrong place. { let rt = runtime.clone(); async_mod.set( @@ -7597,7 +7606,9 @@ pub fn install_async( let Ok(text) = name.to_str() else { return Err(mlua::Error::external( "pmacs.workers.dispatch: handler name must be valid UTF-8 — it is \ - displayed to the user as part of every job's purpose.", + composed into every job's purpose, which is displayed to the user \ + in *workers* and in the modeline, and arbitrary bytes have no \ + display form there.", )); }; rt.push_dispatch_name(&*text); @@ -8795,6 +8806,16 @@ fn parse_restart(name: &str) -> mlua::Result { /// neither the field nor the rule — so the conversion failure is mapped /// onto this function's own message instead. /// +/// That message names **`pmacs.process.list`**, which is the whole of +/// where a process's purpose surfaces in Stage 1. It deliberately does +/// *not* name `*workers*` or the modeline indicator: both are **job** +/// surfaces, a spawned process appears in neither, and joining the two +/// planes is Stage 2's work (framing §3, Q#W-4). A diagnostic that +/// named them would send the reader looking for their process somewhere +/// it will never appear — worse than a terse one. The job-side twin of +/// this refusal, on `_push_dispatch_name`, names those two surfaces for +/// the matching reason: a job really does reach them. +/// /// The read is **raw**, matching the posture `stdin` and `group` already /// document in [`lua_to_spec`]: a spec table is plain data, so a /// metatable cannot smuggle a purpose in through `__index`. @@ -8805,8 +8826,8 @@ fn required_purpose(table: &Table) -> mlua::Result { Err(_) => { return Err(mlua::Error::external( "pmacs.process.spawn: purpose must be valid UTF-8 — it is displayed \ - to the user in *workers* and in the modeline, and arbitrary bytes \ - have no display form there.", + to the user in pmacs.process.list, and arbitrary bytes have no \ + display form there.", )); } }, diff --git a/tests/worker_identity_acceptance.rs b/tests/worker_identity_acceptance.rs index 54fc332..f5dcef7 100644 --- a/tests/worker_identity_acceptance.rs +++ b/tests/worker_identity_acceptance.rs @@ -290,8 +290,11 @@ fn every_entry_shape_records_what_its_work_is() { /// error before this lane's own diagnostic was ever constructed. The /// refusal is not the interesting part — it refuses either way, and /// nothing spawns either way — the MESSAGE is, which is why the -/// assertion is on content. Retyping this read as a bare `?` breaks -/// the row rather than silently degrading the error; +/// assertion is on content, and why the expected text now runs as far +/// as the **surface** the message names. Retyping this read as a bare +/// `?` breaks the row rather than silently degrading the error, and +/// naming the wrong surface breaks it too — see +/// `the_two_utf8_refusals_each_name_the_surface_their_own_text_reaches`; /// * **metatable-provided**, which is the `stdin`/`group` raw-read /// posture: a spec table is plain data, so a purpose cannot be /// smuggled in through `__index`. @@ -325,7 +328,8 @@ fn spawning_without_a_real_purpose_is_refused_and_starts_nothing() { "invalid UTF-8", r#"{ label = "x", purpose = "run " .. string.char(255), command = "/bin/sh", args = { "-c", "sleep 5" } }"#, - "purpose must be valid UTF-8", + "purpose must be valid UTF-8 — it is displayed to the user in \ + pmacs.process.list", ), ( "metatable-provided", @@ -534,6 +538,71 @@ fn a_handler_name_that_is_not_valid_utf8_is_refused_before_the_handler_runs() { pump(&mut state); } +/// **A diagnostic that names the wrong surface is worse than a terse +/// one, and the two UTF-8 refusals do not name the same surface.** +/// +/// Both messages tell the caller *why* their bytes are refused: the text +/// gets displayed, and arbitrary bytes have no display form. But the two +/// values reach **different** places, and Stage 1 makes that difference +/// deliberately: +/// +/// * a **job**'s purpose — which a handler name is composed into — is +/// rendered by `*workers*` and by the modeline activity indicator; +/// * a **process**'s purpose is exposed through `pmacs.process.list` +/// and nothing else. Processes are kept out of `*workers*` and out of +/// the indicator until Stage 2's unified view (framing §3, Q#W-4). +/// +/// So the process-side message must not send a caller to `*workers*` to +/// look for a process that will never be listed there, and the job-side +/// message must not send them to an accessor that enumerates no jobs. +/// **Both directions are asserted, positive and negative**, because a +/// later edit that "unified the wording" would otherwise reintroduce +/// exactly one wrong sentence in exactly one of the two places and pass +/// every other test in this file. +#[test] +fn the_two_utf8_refusals_each_name_the_surface_their_own_text_reaches() { + let mut state = editor(); + + let (spawned, process_err): (bool, String) = eval( + &state, + r#"local ok, err = pcall(pmacs.process.spawn, { + label = "x", purpose = "run " .. string.char(255), + command = "/bin/sh", args = { "-c", "sleep 5" } }) + return ok, tostring(err)"#, + ); + assert!(!spawned, "precondition: the spawn must refuse"); + assert!( + process_err.contains("pmacs.process.list"), + "a process purpose reaches pmacs.process.list, and the refusal must \ + say so; got {process_err:?}" + ); + assert!( + !process_err.contains("*workers*") && !process_err.contains("modeline"), + "and it must NOT name the job surfaces a process never reaches; \ + got {process_err:?}" + ); + + let (dispatched, job_err): (bool, String) = eval( + &state, + "pmacs.workers.register('bad' .. string.char(255), function() end) + local ok, err = pcall(pmacs.workers.dispatch, 'bad' .. string.char(255)) + return ok, tostring(err)", + ); + assert!(!dispatched, "precondition: the dispatch must refuse"); + assert!( + job_err.contains("*workers*") && job_err.contains("modeline"), + "a handler name reaches both job surfaces, and the refusal must name \ + them; got {job_err:?}" + ); + assert!( + !job_err.contains("pmacs.process.list"), + "and it must NOT name the process accessor, which enumerates no jobs; \ + got {job_err:?}" + ); + + pump(&mut state); +} + /// **Rule 7 + the defect itself.** A job dispatched through /// `pmacs.workers.dispatch("name", …)` reports `"name"`. ///