Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions crates/bashkit/src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1002,6 +1002,21 @@ impl Interpreter {
self.last_exit_code = state.last_exit_code;
self.aliases = state.aliases.clone();
self.traps = state.traps.clone();
// Recompute memory budget from restored state to prevent desync
let func_count = self.functions.len();
let func_bytes: usize = self
.functions
.values()
.map(|f| format!("{:?}", f.body).len())
.sum();
self.memory_budget = crate::limits::MemoryBudget::recompute_from_state(
&self.variables,
&self.arrays,
&self.assoc_arrays,
func_count,
func_bytes,
Self::is_internal_variable,
);
}

/// Get a reference to the current execution counters.
Expand Down Expand Up @@ -1377,6 +1392,7 @@ impl Interpreter {
let saved_exit = self.last_exit_code;
let saved_aliases = self.aliases.clone();
let saved_coproc = self.coproc_buffers.clone();
let saved_memory_budget = self.memory_budget.clone();

let mut result = self.execute_command_sequence(commands).await;

Expand Down Expand Up @@ -1420,6 +1436,7 @@ impl Interpreter {
self.last_exit_code = saved_exit;
self.aliases = saved_aliases;
self.coproc_buffers = saved_coproc;
self.memory_budget = saved_memory_budget;

// Consume Exit control flow at subshell boundary — exit only
// terminates the subshell, not the parent shell.
Expand Down Expand Up @@ -3982,6 +3999,7 @@ impl Interpreter {
let saved_exit = self.last_exit_code;
let saved_aliases = self.aliases.clone();
let saved_coproc = self.coproc_buffers.clone();
let saved_memory_budget = self.memory_budget.clone();

// Child only sees exported variables (env), not all shell variables.
// Reset last_exit_code so $? starts at 0 (matches real bash subprocess).
Expand Down Expand Up @@ -4024,6 +4042,7 @@ impl Interpreter {
self.last_exit_code = saved_exit;
self.aliases = saved_aliases;
self.coproc_buffers = saved_coproc;
self.memory_budget = saved_memory_budget;
self.bash_source_stack = saved_source_stack;
self.pipeline_stdin = prev_pipeline_stdin;

Expand Down Expand Up @@ -5968,6 +5987,7 @@ impl Interpreter {
let saved_assoc = self.assoc_arrays.clone();
let saved_aliases = self.aliases.clone();
let saved_cwd = self.cwd.clone();
let saved_memory_budget = self.memory_budget.clone();
let mut stdout = String::new();
for cmd in commands {
let cmd_result = self.execute_command(cmd).await?;
Expand Down Expand Up @@ -5999,6 +6019,7 @@ impl Interpreter {
self.assoc_arrays = saved_assoc;
self.aliases = saved_aliases;
self.cwd = saved_cwd;
self.memory_budget = saved_memory_budget;
self.counters.pop_function();
self.subst_generation += 1;
let trimmed = stdout.trim_end_matches('\n');
Expand Down
34 changes: 34 additions & 0 deletions crates/bashkit/src/limits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,40 @@ impl MemoryBudget {
self.function_count = self.function_count.saturating_sub(1);
self.function_body_bytes = self.function_body_bytes.saturating_sub(body_bytes);
}

/// Recompute budget from actual variable/array state.
///
/// Used after `restore_shell_state` where the budget was not serialized
/// alongside the snapshot. `is_internal` should return true for variable
/// names that are internal markers (not user-visible).
pub fn recompute_from_state<F>(
variables: &std::collections::HashMap<String, String>,
arrays: &std::collections::HashMap<String, std::collections::HashMap<usize, String>>,
assoc_arrays: &std::collections::HashMap<String, std::collections::HashMap<String, String>>,
function_count: usize,
function_body_bytes: usize,
is_internal: F,
) -> Self
where
F: Fn(&str) -> bool,
{
let mut budget = Self::default();
for (k, v) in variables {
if !is_internal(k) {
budget.variable_count += 1;
budget.variable_bytes += k.len() + v.len();
}
}
for arr in arrays.values() {
budget.array_entries += arr.len();
}
for arr in assoc_arrays.values() {
budget.array_entries += arr.len();
}
budget.function_count = function_count;
budget.function_body_bytes = function_body_bytes;
budget
}
}

#[cfg(test)]
Expand Down
43 changes: 43 additions & 0 deletions crates/bashkit/tests/spec_cases/bash/memory_budget_desync.test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Memory budget desync after subshell/command-substitution state restoration
# Regression tests for issue #993

### budget_accurate_after_command_substitutions
# Memory budget should not inflate after many command substitutions.
# After 100 command substitutions that create variables internally,
# the parent shell should still be able to create variables.
for i in $(seq 1 100); do
x=$(echo val)
done
# If budget were inflated, this would silently fail
testvar="works"
echo "$testvar"
### expect
works
### end

### budget_enforced_after_subshell
# Memory budget should remain accurate after subshell execution.
# Variables created in subshell should not affect parent budget.
(
for i in $(seq 1 50); do
eval "sub_v$i=$i"
done
)
# Parent should still be able to create variables
parentvar="ok"
echo "$parentvar"
### expect
ok
### end

### subshell_vars_do_not_leak_budget
# Creating and destroying variables in subshells should not
# accumulate phantom budget entries.
for i in $(seq 1 200); do
(eval "tmp_$i=value")
done
result="success"
echo "$result"
### expect
success
### end
Loading