From 31352692c86c2aff981abb02e0e73109f9b539bc Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 22:08:18 +0200 Subject: [PATCH] fix(process): name the surface a process purpose actually reaches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `required_purpose`'s invalid-UTF-8 refusal told the caller that their process purpose "is displayed to the user in *workers* and in the modeline". Neither is a process surface. Stage 1 deliberately keeps processes out of `*workers*` — which lists async JOBS — and out of the statusline activity indicator; a process's purpose is exposed through `pmacs.process.list` and nowhere else, and joining the two planes is Stage 2's work (framing §3, Q#W-4). The refusal is correct and stays: a purpose that cannot be displayed anywhere should still be refused, and nothing spawns either way. What was wrong is the reason given to the user, which pointed them at two places their process will never appear. A diagnostic that misdescribes the system is worse than a terse one, because it sends the reader looking in the wrong place. The job-side twin diverges rather than converging. `_push_dispatch_name` refuses a non-UTF-8 handler name for the same reason, and there `*workers*` and the modeline are the RIGHT answer — the name is composed into every job's purpose and a job renders in both. It said only "as part of every job's purpose", which names no surface at all, so it now names the two it reaches. The two messages must not collapse into one sentence: whichever wording won would be wrong on the other side. Verification. `the_two_utf8_refusals_each_name_the_surface_their_own_text_reaches` asserts both directions, positive AND negative — the process message contains `pmacs.process.list` and NOT `*workers*`/`modeline`, the job message contains both of those and NOT `pmacs.process.list`. The negative halves are the anti-collapse guard; without them a later "unify the wording" edit reintroduces exactly one wrong sentence and passes every other test in the file. The existing row-table assertion in `spawning_without_a_real_purpose_is_refused_and_starts_nothing` now runs as far as the surface name too, so the same edit breaks two tests. Three mutation checks, each red on its own claim: restoring the old process wording fails both content assertions; collapsing the job message onto the process wording fails only the new test (which is the point — the old job test asserted the prefix alone and could not see it); restoring the job message's original vague wording fails it too. The doc comments were fixed with the literals. `required_purpose`'s rustdoc now states which surface its message names and why it names neither of the other two, and the `_push_dispatch_name` comment states the converse. A corrected string whose doc comment still argues the other way is one refactor from reverting itself. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- src/lua_bindings/mod.rs | 27 +++++++++-- tests/worker_identity_acceptance.rs | 75 +++++++++++++++++++++++++++-- 2 files changed, 96 insertions(+), 6 deletions(-) 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"`. ///