From d88d4fe0e174b1ef32fc37a6acb5939ad6754611 Mon Sep 17 00:00:00 2001 From: Darren Schroeder <343840+fdncred@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:15:25 -0700 Subject: [PATCH] Faster startup: chained fixups on Apple silicon, a cheaper banner, and async prompt pushes that survive (#19236) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Three independent changes make the REPL ready to type into sooner. With the default config, launching `nu` until the first prompt is drawn drops from 15.7 ms to 13.1 ms (−17%). `$nu.startup-time` is measured the same way as before. Measurements: | default config, from `~` | main | this PR | | --- | --- | --- | | launch to first prompt | 15.70 ms | 13.10 ms | | `$nu.startup-time` | 8.03 ms | 7.36 ms | | instructions retired by the first prompt | 149.3M | 119.8M | 1. Change the linker on Apple silicon to `"cargo:rustc-link-arg-bins=-Wl,-fixup_chains"` 2. Make `banner` cheaper (all architectures) 3. Keep prompt pushes that land before the prompt is rebuilt (all architectures) ## User-facing changes (Release notes) - Improved startup time. On Apple silicon Macs, `nu` is now linked with chained fixups, so less work happens before Nushell's own code starts and each `nu` process uses about 3 MiB less memory. The startup banner no longer computes the welcome message when it only prints the startup time. - `date to-timezone` (all architectures) now accepts IANA time zone names in any letter case, for example `date now | date to-timezone america/new_york`. Before, some lowercase names were rejected as invalid. - Background jobs that update the prompt with `commandline set-prompt` (all architectures), started from a `pre_prompt` or `env_change` hook or from a prompt closure such as `$env.PROMPT_COMMAND`, no longer lose that update when they finish before the prompt is drawn. A `commandline set-prompt` called directly inside one of those hooks or prompt closures now also shows on that prompt, in place of the value the prompt variables give that segment. Before, the prompt evaluation that follows the hooks always replaced it. ## Additional notes --- Cargo.lock | 10 - Cargo.toml | 1 - .../src/commands/commandline/set_prompt.rs | 40 ++- crates/nu-cli/src/prompt_update.rs | 7 +- crates/nu-cli/src/repl.rs | 13 +- .../tests/commands/commandline_set_prompt.rs | 55 ++++ crates/nu-cli/tests/commands/mod.rs | 1 + crates/nu-command/Cargo.toml | 1 - crates/nu-command/src/date/parser.rs | 13 +- .../nu-command/src/experimental/job_spawn.rs | 5 + .../tests/commands/date/to_timezone.rs | 20 ++ crates/nu-protocol/src/engine/engine_state.rs | 2 + crates/nu-protocol/src/engine/jobs.rs | 13 +- crates/nu-protocol/src/engine/prompt_state.rs | 240 ++++++++++++++---- crates/nu-std/std/prelude/mod.nu | 28 +- crates/nu-std/tests/test_prelude.nu | 20 ++ scripts/build.rs | 16 ++ 17 files changed, 376 insertions(+), 109 deletions(-) create mode 100644 crates/nu-cli/tests/commands/commandline_set_prompt.rs diff --git a/Cargo.lock b/Cargo.lock index 539479edfd..caba5ede70 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5023,7 +5023,6 @@ dependencies = [ "sysinfo", "tabled", "tempfile", - "titlecase", "toml", "toml_edit", "trash", @@ -9448,15 +9447,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" -[[package]] -name = "titlecase" -version = "3.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb567088a91d59b492520c8149e2be5ce10d5deb2d9a383f3378df3259679d40" -dependencies = [ - "regex", -] - [[package]] name = "tokio" version = "1.53.1" diff --git a/Cargo.toml b/Cargo.toml index 09f54829f8..9a155099e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -271,7 +271,6 @@ sysinfo = "0.39.6" tabled = { version = "0.22", default-features = false } tempfile = "3.27" thiserror = "2.0.20" -titlecase = "3.6" tokio = { version = "1.53.1" } toml = "1.1.4" toml_edit = "0.25.13" diff --git a/crates/nu-cli/src/commands/commandline/set_prompt.rs b/crates/nu-cli/src/commands/commandline/set_prompt.rs index c71fba356c..55fccf7e6d 100644 --- a/crates/nu-cli/src/commands/commandline/set_prompt.rs +++ b/crates/nu-cli/src/commands/commandline/set_prompt.rs @@ -1,5 +1,5 @@ use nu_engine::command_prelude::*; -use nu_protocol::engine::PromptSegment; +use nu_protocol::engine::PromptContents; #[derive(Clone)] pub struct CommandlineSetPrompt; @@ -66,7 +66,11 @@ segments that have finished computing. The line and cursor are preserved. `--indicator` sets only the default/emacs indicator. Use `--vi-insert` and `--vi-normal` to set the vi mode indicators independently. -The pushed prompt lasts only until the next prompt is drawn. +The pushed prompt lasts only until the next prompt is drawn. The exception is a +push from code that runs while the next prompt is built (a `pre_prompt` or +`env_change` hook, or a prompt closure such as `$env.PROMPT_COMMAND`), or from a +job that code starts: it shows on that prompt even if it lands before the prompt +is drawn. meant for REPL sessions only"# } @@ -110,26 +114,18 @@ meant for REPL sessions only"# || vi_normal.is_some() || multiline.is_some() { - engine_state.prompt_state.apply(|contents| { - if let Some(content) = left { - contents.apply_segment_override(PromptSegment::Left, content); - } - if let Some(content) = right { - contents.apply_segment_override(PromptSegment::Right, content); - } - if let Some(content) = indicator { - contents.apply_segment_override(PromptSegment::Indicator, content); - } - if let Some(content) = vi_insert { - contents.apply_segment_override(PromptSegment::ViInsert, content); - } - if let Some(content) = vi_normal { - contents.apply_segment_override(PromptSegment::ViNormal, content); - } - if let Some(content) = multiline { - contents.apply_segment_override(PromptSegment::Multiline, content); - } - }); + let pushed = PromptContents { + left: left.map(Into::into), + right: right.map(Into::into), + indicator: indicator.map(Into::into), + vi_insert: vi_insert.map(Into::into), + vi_normal: vi_normal.map(Into::into), + multiline: multiline.map(Into::into), + ..Default::default() + }; + engine_state + .prompt_state + .apply(pushed, engine_state.current_job.root); } Ok(Value::nothing(head).into_pipeline_data()) diff --git a/crates/nu-cli/src/prompt_update.rs b/crates/nu-cli/src/prompt_update.rs index eb62c89ddb..f284f236b4 100644 --- a/crates/nu-cli/src/prompt_update.rs +++ b/crates/nu-cli/src/prompt_update.rs @@ -92,7 +92,8 @@ fn get_prompt_string( /// Re-evaluate `$env.PROMPT_COMMAND` and friends and install the result as the /// prompt's per-cycle baseline. This overwrites anything a background job pushed -/// during the previous cycle, resetting the prompt for the next line. +/// during the previous cycle, resetting the prompt for the next line; the pushes +/// this cycle's `PromptState::begin_update` kept stay on top. pub fn update_prompt(config: &Config, engine_state: &EngineState, stack: &mut Stack) { let new_contents = build_prompt_contents(config, engine_state, stack); @@ -181,7 +182,7 @@ mod tests { #[test] fn transient_prompt_override_still_wins_over_the_live_baseline() { - use nu_protocol::engine::PromptSegment; + use nu_protocol::{JobId, engine::PromptSegment}; let config = Config::default(); let engine_state = EngineState::new(); @@ -196,7 +197,7 @@ mod tests { // The configured TRANSIENT_PROMPT_INDICATOR beats a later live change. engine_state .prompt_state - .set(PromptSegment::Indicator, "live> "); + .set(PromptSegment::Indicator, "live> ", JobId::new(1)); assert_eq!( transient_prompt.render_prompt_indicator(reedline::PromptEditMode::Emacs), diff --git a/crates/nu-cli/src/repl.rs b/crates/nu-cli/src/repl.rs index d19e88e018..5cf38bcf92 100644 --- a/crates/nu-cli/src/repl.rs +++ b/crates/nu-cli/src/repl.rs @@ -634,6 +634,15 @@ fn loop_iteration(ctx: LoopContext) -> (bool, Stack, Reedline) { start_time = Instant::now(); + // Keep the pushes made for this prompt even if they land before `update_prompt` installs the + // new baseline; `PromptState::begin_update` says which pushes count. + let first_job = engine_state + .jobs + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .next_id(); + engine_state.prompt_state.begin_update(first_job); + // Juhan said to do this :) let mut repl = engine_state.repl_state.lock().expect("repl state mutex"); repl.cursor_pos = line_editor.current_insertion_point(); @@ -812,8 +821,8 @@ fn loop_iteration(ctx: LoopContext) -> (bool, Stack, Reedline) { start_time = Instant::now(); let config = &engine_state.get_config().clone(); - // Re-evaluate the prompt into the shared `prompt_state`; this also resets any - // segment a background job pushed during the previous cycle. + // Re-evaluate the prompt into the shared `prompt_state`, replacing the segments pushed for + // the previous prompt but not the pushes `begin_update` above kept. prompt_update::update_prompt( config, engine_state, diff --git a/crates/nu-cli/tests/commands/commandline_set_prompt.rs b/crates/nu-cli/tests/commands/commandline_set_prompt.rs new file mode 100644 index 0000000000..aca318958b --- /dev/null +++ b/crates/nu-cli/tests/commands/commandline_set_prompt.rs @@ -0,0 +1,55 @@ +use nu_cli::update_prompt; +use nu_test_support::prelude::*; + +// Each test opens the window the REPL opens before its hooks run (`PromptState::begin_update`), +// runs what a hook would, then installs the prompt baseline the way the REPL does. Jobs report +// back with `job send 0`, so the tests wait for them without sleeping. + +#[test] +fn push_from_a_job_started_for_the_prompt_survives_the_baseline() -> Result { + let mut tester = test().env("PROMPT_COMMAND", "baseline> "); + let first_job = tester + .engine_state + .jobs + .lock() + .expect("jobs lock") + .next_id(); + tester.engine_state.prompt_state.begin_update(first_job); + + let () = tester.run( + "job spawn { commandline set-prompt 'pushed> '; 'done' | job send 0 }; job recv --timeout 1min | ignore", + )?; + let config = tester.engine_state.get_config().clone(); + update_prompt(&config, &tester.engine_state, &mut tester.stack); + + let left = tester.engine_state.prompt_state.contents().left; + assert_eq!(left.as_deref(), Some("pushed> ")); + Ok(()) +} + +#[test] +fn push_from_a_job_spawned_by_an_earlier_job_does_not_survive_the_baseline() -> Result { + let mut tester = test().env("PROMPT_COMMAND", "baseline> "); + // A job left over from an earlier prompt: it spawns the job that pushes once told to. + let earlier: i64 = tester.run( + "job spawn { job recv | ignore; job spawn { commandline set-prompt 'stale> '; 'done' | job send 0 } }", + )?; + let first_job = tester + .engine_state + .jobs + .lock() + .expect("jobs lock") + .next_id(); + tester.engine_state.prompt_state.begin_update(first_job); + + let () = tester.run_with_data( + "let earlier = $in; 'go' | job send $earlier; job recv --timeout 1min | ignore", + earlier, + )?; + let config = tester.engine_state.get_config().clone(); + update_prompt(&config, &tester.engine_state, &mut tester.stack); + + let left = tester.engine_state.prompt_state.contents().left; + assert_eq!(left.as_deref(), Some("baseline> ")); + Ok(()) +} diff --git a/crates/nu-cli/tests/commands/mod.rs b/crates/nu-cli/tests/commands/mod.rs index af1ab58d97..2c1cd1e579 100644 --- a/crates/nu-cli/tests/commands/mod.rs +++ b/crates/nu-cli/tests/commands/mod.rs @@ -1,3 +1,4 @@ +mod commandline_set_prompt; mod keybindings_list; mod nu_highlight; diff --git a/crates/nu-command/Cargo.toml b/crates/nu-command/Cargo.toml index 69d783590e..01b5cfa42e 100644 --- a/crates/nu-command/Cargo.toml +++ b/crates/nu-command/Cargo.toml @@ -123,7 +123,6 @@ sha2 = { workspace = true } strum = { workspace = true } sysinfo = { workspace = true } tabled = { workspace = true, features = ["ansi"], default-features = false } -titlecase = { workspace = true } toml = { workspace = true, features = ["preserve_order"] } toml_edit = { workspace = true } kdl = { workspace = true } diff --git a/crates/nu-command/src/date/parser.rs b/crates/nu-command/src/date/parser.rs index ad6b953396..a109dd3cf6 100644 --- a/crates/nu-command/src/date/parser.rs +++ b/crates/nu-command/src/date/parser.rs @@ -2,7 +2,6 @@ use chrono::{DateTime, FixedOffset, Local, Offset, TimeZone}; use chrono_tz::Tz; -use titlecase::titlecase; #[derive(Debug, Clone, PartialEq, Eq, Copy)] pub enum ParseErrorKind { @@ -38,16 +37,10 @@ pub fn datetime_in_timezone( } } +/// Look up an IANA time zone name, ignoring case (`utc`, `america/new_york`) and surrounding +/// whitespace, such as the newline that ends `/etc/timezone`. fn parse_timezone_internal(s: &str) -> Result { - if let Ok(tz) = s.parse() { - Ok(tz) - } else if let Ok(tz) = titlecase(s).parse() { - Ok(tz) - } else if let Ok(tz) = s.to_uppercase().parse() { - Ok(tz) - } else { - Err(ParseErrorKind::Invalid) - } + Tz::from_str_insensitive(s.trim()).map_err(|_| ParseErrorKind::Invalid) } fn timezone_offset_internal( diff --git a/crates/nu-command/src/experimental/job_spawn.rs b/crates/nu-command/src/experimental/job_spawn.rs index 00aafdce27..c40e9ef37f 100644 --- a/crates/nu-command/src/experimental/job_spawn.rs +++ b/crates/nu-command/src/experimental/job_spawn.rs @@ -85,6 +85,11 @@ impl Command for JobSpawn { job_state.current_job = CurrentJob { id, + root: if engine_state.is_background_job() { + engine_state.current_job.root + } else { + id + }, background_thread_job: Some(thread_job), mailbox: Arc::new(Mutex::new(Mailbox::new(recv))), }; diff --git a/crates/nu-command/tests/commands/date/to_timezone.rs b/crates/nu-command/tests/commands/date/to_timezone.rs index fa2ab64458..aad8c8a3fb 100644 --- a/crates/nu-command/tests/commands/date/to_timezone.rs +++ b/crates/nu-command/tests/commands/date/to_timezone.rs @@ -1,4 +1,5 @@ use nu_test_support::prelude::*; +use rstest::rstest; #[test] fn to_timezone_list_of_strings() -> Result { @@ -24,3 +25,22 @@ fn to_timezone_single_date_string() -> Result { r#""2020-10-10 10:00:00 +02:00" | date to-timezone "+0500" | into record | get timezone"#; test().run(code).expect_value_eq("+05:00") } + +#[rstest] +#[case::lowercase_utc("utc", "+0000")] +#[case::lowercase_area_location("america/new_york", "-0500")] +#[case::lowercase_alias("us/pacific", "-0800")] +#[case::mixed_case("eUROPE/lONDON", "+0000")] +#[case::trailing_newline("America/New_York\n", "-0500")] +#[case::surrounding_spaces(" europe/london ", "+0000")] +fn to_timezone_name_ignores_case_and_whitespace( + #[case] zone: &str, + #[case] offset: &str, +) -> Result { + test() + .run_with_data( + r#"let zone = $in; 2026-01-01T12:00:00Z | date to-timezone $zone | format date "%z""#, + zone, + ) + .expect_value_eq(offset) +} diff --git a/crates/nu-protocol/src/engine/engine_state.rs b/crates/nu-protocol/src/engine/engine_state.rs index f218444af3..0137b671aa 100644 --- a/crates/nu-protocol/src/engine/engine_state.rs +++ b/crates/nu-protocol/src/engine/engine_state.rs @@ -336,6 +336,7 @@ impl EngineState { jobs: Arc::new(Mutex::new(Jobs::default())), current_job: CurrentJob { id: JobId::new(0), + root: JobId::new(0), background_thread_job: None, mailbox: Arc::new(Mutex::new(Mailbox::new(recv))), }, @@ -1278,6 +1279,7 @@ impl EngineState { self.jobs = Default::default(); self.current_job = CurrentJob { id: JobId::new(0), + root: JobId::new(0), background_thread_job: None, mailbox: Arc::new(Mutex::new(Mailbox::new(recv))), }; diff --git a/crates/nu-protocol/src/engine/jobs.rs b/crates/nu-protocol/src/engine/jobs.rs index e206ddccda..96af35bcb2 100644 --- a/crates/nu-protocol/src/engine/jobs.rs +++ b/crates/nu-protocol/src/engine/jobs.rs @@ -43,6 +43,12 @@ impl Jobs { self.jobs.iter().map(|(k, v)| (*k, v)) } + /// The id the next job added to the table will get. Ids only grow, so a job with a lower id + /// was started before this call. + pub fn next_id(&self) -> JobId { + JobId::new(self.next_job_id) + } + /// Whether there are no tracked jobs (running or frozen). pub fn is_empty(&self) -> bool { self.jobs.is_empty() @@ -71,7 +77,7 @@ impl Jobs { } pub fn add_job(&mut self, job: Job) -> JobId { - let this_id = JobId::new(self.next_job_id); + let this_id = self.next_id(); self.assign_last_frozen_id_if_frozen(this_id, &job); @@ -262,6 +268,11 @@ impl FrozenJob { pub struct CurrentJob { pub id: JobId, + /// The job the main thread spawned that this one descends from: `id` itself when the main + /// thread spawned this job, the spawning job's `root` otherwise, and `JobId::ZERO` on the + /// main thread. + pub root: JobId, + // The background thread job associated with this thread. // If None, it indicates this thread is currently the main job pub background_thread_job: Option, diff --git a/crates/nu-protocol/src/engine/prompt_state.rs b/crates/nu-protocol/src/engine/prompt_state.rs index 4f0ae9d99b..fbeb029ff8 100644 --- a/crates/nu-protocol/src/engine/prompt_state.rs +++ b/crates/nu-protocol/src/engine/prompt_state.rs @@ -1,5 +1,7 @@ use std::sync::{Arc, Mutex, MutexGuard, RwLock, RwLockReadGuard, RwLockWriteGuard}; +use crate::JobId; + /// Wakes the active interactive line editor and asks it to repaint the prompt in /// place, without disturbing the line currently being edited. type Repainter = Arc; @@ -53,11 +55,12 @@ impl PromptContents { } /// Layer `overrides` on top of `self`, preferring the override where set - /// and falling back to `self` otherwise. + /// and falling back to `self` otherwise. `render_right_on_last_line` always + /// comes from `self`, so a push never changes it. /// - /// Used to render the transient prompt from the *live* baseline (so late - /// [`PromptState::set`] pushes still show up) with `TRANSIENT_PROMPT_*` - /// values layered on top. + /// This lays pushes over the live contents and over the new baseline, and + /// renders the transient prompt from the *live* baseline (so late pushes + /// still show up) with `TRANSIENT_PROMPT_*` values layered on top. pub fn overridden_by(&self, overrides: &PromptContents) -> PromptContents { PromptContents { left: overrides.left.clone().or_else(|| self.left.clone()), @@ -83,14 +86,44 @@ impl PromptContents { } } +/// The rendered prompt, plus the pushes kept while the REPL rebuilds it. +#[derive(Debug, Default)] +struct PromptData { + contents: PromptContents, + + /// The pushes kept since [`PromptState::begin_update`]. `None` outside the window between + /// that call and [`PromptState::set_contents`], which layers them over the new baseline. + /// Without it, a job that finishes before the REPL rebuilds the prompt would lose its push. + pending: Option, +} + +/// The segments pushed for the prompt the REPL is rebuilding. +#[derive(Debug)] +struct PendingPushes { + /// The id of the first job the main thread started for this prompt. A job whose root is + /// lower is left over from an earlier prompt, so its result may be out of date (computed for + /// another directory, say). + first_job: JobId, + + pushed: PromptContents, +} + +impl PendingPushes { + /// Whether a push belongs to the prompt being rebuilt (see [`PromptState::begin_update`]). + fn accepts(&self, root_job: JobId) -> bool { + root_job == JobId::ZERO || root_job >= self.first_job + } +} + /// Shared, thread-safe home for the interactive prompt's rendered content. #[derive(derive_more::Debug, Default)] pub struct PromptState { /// Upgraded to RwLock: Enables infinite concurrent reads, locking only for mutations. - contents: RwLock, + /// The pending pushes share the lock so a push can't slip between them and the contents. + data: RwLock, - /// Kept in its own lock: it is installed/cleared by the REPL and read by a - /// background job's `set`, never together with `contents`. + /// Kept in its own lock: it is installed/cleared by the REPL and read by + /// [`PromptState::apply`], never together with `data`. #[debug(skip)] repainter: Mutex>, } @@ -100,14 +133,14 @@ impl PromptState { Self::default() } - fn acquire_read_lock(&self) -> RwLockReadGuard<'_, PromptContents> { - self.contents + fn acquire_read_lock(&self) -> RwLockReadGuard<'_, PromptData> { + self.data .read() .unwrap_or_else(|poisoned_error| poisoned_error.into_inner()) } - fn acquire_write_lock(&self) -> RwLockWriteGuard<'_, PromptContents> { - self.contents + fn acquire_write_lock(&self) -> RwLockWriteGuard<'_, PromptData> { + self.data .write() .unwrap_or_else(|poisoned_error| poisoned_error.into_inner()) } @@ -123,15 +156,7 @@ impl PromptState { &self, action: impl FnOnce(&PromptContents) -> ReturnType, ) -> ReturnType { - action(&self.acquire_read_lock()) - } - - /// Run an action with exclusive, mutable access to the current contents. - fn modify_contents( - &self, - action: impl FnOnce(&mut PromptContents) -> ReturnType, - ) -> ReturnType { - action(&mut self.acquire_write_lock()) + action(&self.acquire_read_lock().contents) } /// A snapshot of the current contents. @@ -139,22 +164,59 @@ impl PromptState { self.with_contents(PromptContents::clone) } - /// Replace all prompt content (the baseline). - pub fn set_contents(&self, new_contents: PromptContents) { - self.modify_contents(|contents| *contents = new_contents); + /// Start rebuilding the prompt. Until [`Self::set_contents`], a push is also kept aside, so the + /// new baseline doesn't overwrite it, when it comes from the main thread or from a job whose + /// `CurrentJob::root` is at least `first_job`: a job the main thread started after this call, + /// or a job one of those spawned. A push from an older job still shows until the new baseline + /// replaces it. + /// + /// The REPL calls this before the hooks that run ahead of each prompt, and passes the id the + /// next spawned job will get. Until `set_contents`, everything the main thread runs prepares + /// this prompt: the `env_change` and `pre_prompt` hooks, `color_config` and `ENV_CONVERSIONS` + /// closures, and the prompt closures themselves. + pub fn begin_update(&self, first_job: JobId) { + self.acquire_write_lock().pending = Some(PendingPushes { + first_job, + pushed: PromptContents::default(), + }); } - /// Apply a batch of overrides under a single write lock, then request one - /// in-place repaint. Callers touching several segments at once should use this as to not flash-update - pub fn apply(&self, overrides: impl FnOnce(&mut PromptContents)) { - self.modify_contents(overrides); + /// Replace all prompt content (the baseline), then lay over it the pushes kept since + /// [`Self::begin_update`]. + pub fn set_contents(&self, new_contents: PromptContents) { + let mut data = self.acquire_write_lock(); + data.contents = match data.pending.take() { + Some(pending) => new_contents.overridden_by(&pending.pushed), + None => new_contents, + }; + } + + /// Replace the segments set in `pushed` under a single write lock, then request one in-place + /// repaint. Callers touching several segments at once should use this as to not flash-update. + /// + /// `root_job` is the pushing thread's `CurrentJob::root` (`JobId::ZERO` on the main thread); + /// it decides whether the push outlives the next [`Self::set_contents`] (see + /// [`Self::begin_update`]). Segments left `None` keep their current value, and + /// `render_right_on_last_line` is ignored. + pub fn apply(&self, pushed: PromptContents, root_job: JobId) { + let mut data = self.acquire_write_lock(); + data.contents = data.contents.overridden_by(&pushed); + if let Some(pending) = &mut data.pending + && pending.accepts(root_job) + { + pending.pushed = pending.pushed.overridden_by(&pushed); + } + // Release the lock before repainting: the line editor reads the prompt to redraw it. + drop(data); self.request_repaint(); } - /// Push an override for a single segment and request an in-place repaint. - pub fn set(&self, segment: PromptSegment, content: impl Into>) { - let content = content.into(); - self.apply(|contents| contents.apply_segment_override(segment, content)); + /// Push an override for a single segment and request an in-place repaint. `root_job` is as + /// in [`Self::apply`]. + pub fn set(&self, segment: PromptSegment, content: impl Into>, root_job: JobId) { + let mut pushed = PromptContents::default(); + pushed.apply_segment_override(segment, content); + self.apply(pushed, root_job); } /// Install or remove the line editor's repainter mechanism. @@ -179,6 +241,9 @@ mod tests { use super::*; use std::sync::atomic::{AtomicUsize, Ordering}; + /// A background job id, for tests where the exact id doesn't matter. + const JOB: JobId = JobId::new(1); + /// A `PromptState` wired to a repainter that counts how often it fires. fn setup_state_with_counter() -> (Arc, Arc) { let state = Arc::new(PromptState::new()); @@ -195,7 +260,7 @@ mod tests { #[test] fn set_writes_only_the_targeted_segment() { let state = PromptState::new(); - state.set(PromptSegment::Left, "LeftSegment"); + state.set(PromptSegment::Left, "LeftSegment", JOB); let contents = state.contents(); assert_eq!(contents.left.as_deref(), Some("LeftSegment")); @@ -205,12 +270,12 @@ mod tests { #[test] fn indicator_vi_insert_vi_normal_and_multiline_are_independent() { let state = PromptState::new(); - state.apply(|contents| { - contents.apply_segment_override(PromptSegment::Indicator, "Indicator"); - contents.apply_segment_override(PromptSegment::ViInsert, "ViInsert"); - contents.apply_segment_override(PromptSegment::ViNormal, "ViNormal"); - contents.apply_segment_override(PromptSegment::Multiline, "Multiline"); - }); + let mut pushed = PromptContents::default(); + pushed.apply_segment_override(PromptSegment::Indicator, "Indicator"); + pushed.apply_segment_override(PromptSegment::ViInsert, "ViInsert"); + pushed.apply_segment_override(PromptSegment::ViNormal, "ViNormal"); + pushed.apply_segment_override(PromptSegment::Multiline, "Multiline"); + state.apply(pushed, JOB); let contents = state.contents(); assert_eq!(contents.indicator.as_deref(), Some("Indicator")); @@ -222,8 +287,8 @@ mod tests { #[test] fn each_set_triggers_exactly_one_repaint() { let (state, repainter_count) = setup_state_with_counter(); - state.set(PromptSegment::Left, "Alpha"); - state.set(PromptSegment::Right, "Beta"); + state.set(PromptSegment::Left, "Alpha", JOB); + state.set(PromptSegment::Right, "Beta", JOB); assert_eq!(repainter_count.load(Ordering::Relaxed), 2); } @@ -231,11 +296,11 @@ mod tests { #[test] fn apply_batches_multiple_segments_into_one_repaint() { let (state, repainter_count) = setup_state_with_counter(); - state.apply(|contents| { - contents.apply_segment_override(PromptSegment::Left, "Alpha"); - contents.apply_segment_override(PromptSegment::Right, "Beta"); - contents.apply_segment_override(PromptSegment::Indicator, "Gamma"); - }); + let mut pushed = PromptContents::default(); + pushed.apply_segment_override(PromptSegment::Left, "Alpha"); + pushed.apply_segment_override(PromptSegment::Right, "Beta"); + pushed.apply_segment_override(PromptSegment::Indicator, "Gamma"); + state.apply(pushed, JOB); let contents = state.contents(); assert_eq!(contents.left.as_deref(), Some("Alpha")); @@ -248,7 +313,7 @@ mod tests { #[test] fn set_contents_overwrites_a_pushed_override() { let state = PromptState::new(); - state.set(PromptSegment::Left, "Pushed"); + state.set(PromptSegment::Left, "Pushed", JOB); state.set_contents(PromptContents { left: Some("Baseline".into()), @@ -258,11 +323,92 @@ mod tests { assert_eq!(state.contents().left.as_deref(), Some("Baseline")); } + #[test] + fn push_during_update_survives_set_contents() { + let state = PromptState::new(); + state.begin_update(JOB); + state.set(PromptSegment::Right, "Pushed", JOB); + + state.set_contents(PromptContents { + left: Some("BaselineLeft".into()), + right: Some("BaselineRight".into()), + ..Default::default() + }); + + let contents = state.contents(); + assert_eq!(contents.left.as_deref(), Some("BaselineLeft")); + assert_eq!(contents.right.as_deref(), Some("Pushed")); + } + + #[test] + fn begin_update_drops_pushes_from_the_previous_prompt() { + let state = PromptState::new(); + // A window the REPL never finished, as when a hook panics before `set_contents`. + state.begin_update(JOB); + state.set(PromptSegment::Left, "Previous", JOB); + state.begin_update(JOB); + state.set(PromptSegment::Right, "Current", JOB); + + state.set_contents(PromptContents { + left: Some("BaselineLeft".into()), + right: Some("BaselineRight".into()), + ..Default::default() + }); + + let contents = state.contents(); + assert_eq!(contents.left.as_deref(), Some("BaselineLeft")); + assert_eq!(contents.right.as_deref(), Some("Current")); + } + + #[test] + fn set_contents_ends_the_update() { + let state = PromptState::new(); + state.begin_update(JOB); + state.set_contents(PromptContents::default()); + state.set(PromptSegment::Left, "AfterUpdate", JOB); + + state.set_contents(PromptContents { + left: Some("NextBaseline".into()), + ..Default::default() + }); + + assert_eq!(state.contents().left.as_deref(), Some("NextBaseline")); + } + + #[test] + fn push_from_an_older_job_is_shown_but_not_kept() { + let state = PromptState::new(); + state.begin_update(JobId::new(2)); + state.set(PromptSegment::Left, "Stale", JobId::new(1)); + assert_eq!(state.contents().left.as_deref(), Some("Stale")); + + state.set_contents(PromptContents { + left: Some("Baseline".into()), + ..Default::default() + }); + + assert_eq!(state.contents().left.as_deref(), Some("Baseline")); + } + + #[test] + fn push_from_the_main_thread_during_update_survives_set_contents() { + let state = PromptState::new(); + state.begin_update(JobId::new(2)); + state.set(PromptSegment::Left, "FromHook", JobId::ZERO); + + state.set_contents(PromptContents { + left: Some("Baseline".into()), + ..Default::default() + }); + + assert_eq!(state.contents().left.as_deref(), Some("FromHook")); + } + #[test] fn detaching_repainter_stops_repaints() { let (state, repainter_count) = setup_state_with_counter(); state.set_repainter(None); - state.set(PromptSegment::Left, "Delta"); + state.set(PromptSegment::Left, "Delta", JOB); assert_eq!(repainter_count.load(Ordering::Relaxed), 0); } diff --git a/crates/nu-std/std/prelude/mod.nu b/crates/nu-std/std/prelude/mod.nu index c3ce1d1e08..f4650e580d 100644 --- a/crates/nu-std/std/prelude/mod.nu +++ b/crates/nu-std/std/prelude/mod.nu @@ -9,12 +9,19 @@ export def banner [ ] { let foreground = $env.config.color_config?.banner_foreground? | default "attr_normal" let highlight1 = $env.config.color_config?.banner_highlight1? | default "green" -let highlight2 = $env.config.color_config?.banner_highlight2? | default "purple" -let dt = (datetime-diff (date now) 2019-05-10T09:59:12-07:00) -let ver = (version) let startup_time = $"(ansi $highlight1)(ansi attr_bold)Startup Time: (ansi reset)(ansi $foreground)($nu.startup-time)(ansi reset)" -let welcome = $"(ansi $highlight1) __ ,(ansi reset) +# The REPL prints the welcome message before the startup hooks run and the startup time right +# before the first prompt, once it is final. `--short` skips the welcome message, whose `version` +# and `datetime-diff` calls cost far more than the startup time line. +let banner_msg = if $short { + $"($startup_time)(char eol)" +} else { + let highlight2 = $env.config.color_config?.banner_highlight2? | default "purple" + let dt = (datetime-diff (date now) 2019-05-10T09:59:12-07:00) + let ver = (version) + + let welcome = $"(ansi $highlight1) __ ,(ansi reset) (ansi $highlight1) .--\(\)°'.' (ansi reset)(ansi $foreground)Welcome to (ansi $highlight1)Nushell(ansi reset)(ansi $foreground),(ansi reset) (ansi $highlight1)'|, . ,' (ansi reset)(ansi $foreground)based on the (ansi $highlight1)nu(ansi reset)(ansi $foreground) language,(ansi reset) (ansi $highlight1) !_-\(_\\ (ansi reset)(ansi $foreground)where all data is structured! @@ -30,14 +37,11 @@ let welcome = $"(ansi $highlight1) __ ,(ansi reset) (ansi $foreground)(pretty-print-duration $dt) " -# The REPL prints the welcome message before the startup hooks run and the startup time right -# before the first prompt, once it is final. -let banner_msg = if $short { - $"($startup_time)(char eol)" -} else if $no_startup_time { - $welcome -} else { - $"($welcome)(char eol)($startup_time)(ansi reset)(char eol)" + if $no_startup_time { + $welcome + } else { + $"($welcome)(char eol)($startup_time)(ansi reset)(char eol)" + } } match (config use-colors) { diff --git a/crates/nu-std/tests/test_prelude.nu b/crates/nu-std/tests/test_prelude.nu index 3d5d04aecb..d0e9741112 100644 --- a/crates/nu-std/tests/test_prelude.nu +++ b/crates/nu-std/tests/test_prelude.nu @@ -6,3 +6,23 @@ def banner [] { use std/prelude assert ((prelude banner | lines | length) == 16) } + +@test +def banner_short_is_only_the_startup_time [] { + use std/prelude + let short = prelude banner --short | ansi strip | lines + assert equal ($short | length) 1 + assert str contains $short.0 "Startup Time:" +} + +@test +def banner_short_skips_the_welcome_message [] { + use std/prelude + # `debug profile` lists the calls made. The full banner checks that it reports these two. + let full = debug profile { prelude banner } --max-depth 10 | get source + assert ("version" in $full) + assert ("datetime-diff" in $full) + let short = debug profile { prelude banner --short } --max-depth 10 | get source + assert ("version" not-in $short) + assert ("datetime-diff" not-in $short) +} diff --git a/scripts/build.rs b/scripts/build.rs index 1469b50418..46437342ef 100644 --- a/scripts/build.rs +++ b/scripts/build.rs @@ -1,5 +1,6 @@ fn main() { let target_os = std::env::var("CARGO_CFG_TARGET_OS").expect("set by cargo"); + let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").expect("set by cargo"); println!("cargo:rerun-if-changed=scripts/build.rs"); println!( "cargo:rustc-env=NU_FEATURES={}", @@ -13,6 +14,21 @@ fn main() { res.compile() .expect("Failed to run the Windows resource compiler (rc.exe)"); } else { + if target_os == "macos" + && target_arch == "aarch64" + && std::env::var_os("RUSTC_LINKER").is_none() + { + // Link with chained fixups. At Rust's default deployment target (macOS 11) the linker + // writes classic rebase opcodes instead, and dyld rewrites every pointer in the + // binary's constant data before `main` runs. Since macOS 13 the kernel applies chained + // fixups page by page as pages are first touched, so startup only pays for the pages + // it uses. macOS 11 is the first release that loads them. That is why this is limited + // to aarch64: every arm64 Mac runs macOS 11 or later, but x86_64's default deployment + // target (10.12) predates them, and the binary would fail to load on 10.12-10.15. + // Cargo sets `RUSTC_LINKER` only when the build configures its own linker, and some, + // such as `zig cc` (cargo-zigbuild), reject the flag, so only Apple's default gets it. + println!("cargo:rustc-link-arg-bins=-Wl,-fixup_chains"); + } // Tango uses dynamic linking, to allow us to dynamically change between two bench suit at runtime. // This is currently not supported on non nightly rust, on windows. println!("cargo:rustc-link-arg-benches=-rdynamic");