@tishlang/tish-lint 3.10.3 → 3.10.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/tish-lint CHANGED
Binary file
@@ -865,6 +865,51 @@ impl PropMap {
865
865
  None
866
866
  }
867
867
 
868
+ /// `insert` minus the hidden-class bookkeeping: never calls `shape::transition` and leaves
869
+ /// `self.shape` untouched. Only for construction paths that END at [`crate::shape::DICT_SHAPE`]
870
+ /// (see [`PropMap::from_unordered`]) — a map built through this and left with a real shape id
871
+ /// would lie to the inline caches.
872
+ fn insert_untracked(&mut self, key: Arc<str>, val: Value) -> Option<Value> {
873
+ if let Some(m) = &mut self.map {
874
+ return m.insert(key, val);
875
+ }
876
+ if let Some(slot) = self.inline.iter_mut().find(|(k, _)| k.as_ref() == key.as_ref()) {
877
+ return Some(core::mem::replace(&mut slot.1, val));
878
+ }
879
+ if self.inline.len() >= PROPMAP_INLINE {
880
+ let mut m: IndexMap<Arc<str>, Value, RandomState> =
881
+ IndexMap::with_capacity_and_hasher(self.inline.len() + 1, RandomState::default());
882
+ for (k, v) in self.inline.drain(..) {
883
+ m.insert(k, v);
884
+ }
885
+ m.insert(key, val);
886
+ self.map = Some(Box::new(m));
887
+ return None;
888
+ }
889
+ self.inline.push((key, val));
890
+ None
891
+ }
892
+
893
+ /// Build a PropMap from entries whose ORDER IS NOT MEANINGFUL (a hashed map's iteration
894
+ /// order, e.g. `ObjectMap`/`AHashMap`), as a dictionary-mode object: no shape transitions
895
+ /// are interned and the result carries [`crate::shape::DICT_SHAPE`].
896
+ ///
897
+ /// This is a LEAK guard, not just an optimization: `shape::transition` interns a permanent
898
+ /// `ShapeNode` (plus an `Arc` clone of the key) per distinct `(parent, key)` edge, with no
899
+ /// eviction. A hashed source iterates in a DIFFERENT order per instance, so walking
900
+ /// transitions for it mints a fresh permanent chain nearly every call — a builtin returning
901
+ /// such an object from a hot path leaked ~2.3KB per call, unboundedly (multi-GB/hour in a
902
+ /// file watcher calling `stat()` in a loop). Random-order objects can never share a stable
903
+ /// hidden class anyway, so the caches lose nothing: DICT_SHAPE readers take the slow path.
904
+ pub fn from_unordered<I: IntoIterator<Item = (Arc<str>, Value)>>(entries: I) -> Self {
905
+ let mut pm = PropMap::default();
906
+ for (k, v) in entries {
907
+ pm.insert_untracked(k, v);
908
+ }
909
+ pm.shape = crate::shape::DICT_SHAPE;
910
+ pm
911
+ }
912
+
868
913
  pub fn remove(&mut self, key: &str) -> Option<Value> {
869
914
  let removed = match &mut self.map {
870
915
  // shift_remove preserves insertion order (vs swap_remove).
@@ -1055,8 +1100,11 @@ pub struct ObjectData {
1055
1100
  impl ObjectData {
1056
1101
  #[inline]
1057
1102
  pub fn from_strings<I: IntoIterator<Item = (Arc<str>, Value)>>(strings: I) -> Self {
1103
+ // Callers hand us `ObjectMap` (AHashMap) contents — iteration order is random per map
1104
+ // instance, so these become dictionary-mode objects (`DICT_SHAPE`) rather than minting
1105
+ // permanent shape-transition chains per call. See `PropMap::from_unordered`.
1058
1106
  Self {
1059
- strings: strings.into_iter().collect(),
1107
+ strings: PropMap::from_unordered(strings),
1060
1108
  symbols: None,
1061
1109
  frozen: false,
1062
1110
  }
@@ -2019,6 +2067,37 @@ mod number_to_string_tests {
2019
2067
  }
2020
2068
  }
2021
2069
 
2070
+ #[cfg(test)]
2071
+ mod dict_shape_leak_tests {
2072
+ use super::*;
2073
+
2074
+ /// Objects built from hashed maps (random iteration order) must be dictionary-mode:
2075
+ /// DICT_SHAPE, no per-order shape-transition chains interned in the global registry.
2076
+ /// Regression test for the unbounded shape-registry leak (~2.3KB per builtin object
2077
+ /// built via `Value::object(ObjectMap)` — multi-GB/hour through fs stat() polling).
2078
+ #[test]
2079
+ fn from_strings_is_dict_shape() {
2080
+ let mut m = ObjectMap::default();
2081
+ for i in 0..20 {
2082
+ m.insert(format!("key{i}").into(), Value::Number(i as f64));
2083
+ }
2084
+ let v = Value::object(m);
2085
+ let Value::Object(od) = &v else { panic!("expected object") };
2086
+ assert_eq!(od.borrow().strings.shape(), crate::shape::DICT_SHAPE);
2087
+ // Reads still work through the dictionary path.
2088
+ assert!(matches!(object_get(&v, &Value::String("key7".into())), Some(Value::Number(n)) if n == 7.0));
2089
+ }
2090
+
2091
+ /// from_unordered preserves values and handles the inline→map promotion at >8 keys.
2092
+ #[test]
2093
+ fn from_unordered_promotes_and_reads() {
2094
+ let pm = PropMap::from_unordered((0..12).map(|i| (Arc::<str>::from(format!("k{i}")), Value::Number(i as f64))));
2095
+ assert_eq!(pm.len(), 12);
2096
+ assert!(matches!(pm.get("k11"), Some(Value::Number(n)) if *n == 11.0));
2097
+ assert_eq!(pm.shape(), crate::shape::DICT_SHAPE);
2098
+ }
2099
+ }
2100
+
2022
2101
  #[cfg(test)]
2023
2102
  mod cycle_coercion_tests_381 {
2024
2103
  //! #381: string-coercing a cyclic array/object must terminate (was a silent thread hang),
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "tishlang_lint"
3
- version = "3.10.3"
3
+ version = "3.10.4"
4
4
  edition = "2021"
5
5
  description = "AST-based linter for Tish"
6
6
  license-file = { workspace = true }
@@ -74,31 +74,40 @@ fn ms_since_epoch(t: std::io::Result<SystemTime>) -> f64 {
74
74
  }
75
75
 
76
76
  /// Build a Node-like `Stats` object from metadata (predicate methods + size + times + mode).
77
+ ///
78
+ /// FIXED key order via `object_from_pairs`: building through an `ObjectMap` (AHashMap) iterated
79
+ /// in random per-instance order made every call mint a fresh permanent chain in the global shape
80
+ /// registry (~2.3KB leaked per `stat()` call, unbounded — a file watcher stat-walking a workspace
81
+ /// leaked multi-GB/hour). Fixed-order construction dedupes to one cached shape chain, and skips
82
+ /// the intermediate hashmap entirely.
77
83
  fn stats_object(md: &std::fs::Metadata) -> Value {
78
- let mut m = ObjectMap::default();
79
84
  let is_file = md.is_file();
80
85
  let is_dir = md.is_dir();
81
86
  let is_symlink = md.file_type().is_symlink();
82
- m.insert("isFile".into(), Value::native(move |_| Value::Bool(is_file)));
83
- m.insert("isDirectory".into(), Value::native(move |_| Value::Bool(is_dir)));
84
- m.insert("isSymbolicLink".into(), Value::native(move |_| Value::Bool(is_symlink)));
85
- m.insert("isBlockDevice".into(), Value::native(|_| Value::Bool(false)));
86
- m.insert("isCharacterDevice".into(), Value::native(|_| Value::Bool(false)));
87
- m.insert("isFIFO".into(), Value::native(|_| Value::Bool(false)));
88
- m.insert("isSocket".into(), Value::native(|_| Value::Bool(false)));
89
- m.insert("size".into(), Value::Number(md.len() as f64));
90
- m.insert("mtimeMs".into(), Value::Number(ms_since_epoch(md.modified())));
91
- m.insert("atimeMs".into(), Value::Number(ms_since_epoch(md.accessed())));
92
- m.insert("birthtimeMs".into(), Value::Number(ms_since_epoch(md.created())));
93
87
  #[cfg(unix)]
94
- {
88
+ let (mode, uid, gid, ino) = {
95
89
  use std::os::unix::fs::MetadataExt;
96
- m.insert("mode".into(), Value::Number(md.mode() as f64));
97
- m.insert("uid".into(), Value::Number(md.uid() as f64));
98
- m.insert("gid".into(), Value::Number(md.gid() as f64));
99
- m.insert("ino".into(), Value::Number(md.ino() as f64));
100
- }
101
- Value::object(m)
90
+ (md.mode() as f64, md.uid() as f64, md.gid() as f64, md.ino() as f64)
91
+ };
92
+ #[cfg(not(unix))]
93
+ let (mode, uid, gid, ino) = (0.0_f64, 0.0_f64, 0.0_f64, 0.0_f64);
94
+ Value::object_from_pairs([
95
+ ("isFile".into(), Value::native(move |_| Value::Bool(is_file))),
96
+ ("isDirectory".into(), Value::native(move |_| Value::Bool(is_dir))),
97
+ ("isSymbolicLink".into(), Value::native(move |_| Value::Bool(is_symlink))),
98
+ ("isBlockDevice".into(), Value::native(|_| Value::Bool(false))),
99
+ ("isCharacterDevice".into(), Value::native(|_| Value::Bool(false))),
100
+ ("isFIFO".into(), Value::native(|_| Value::Bool(false))),
101
+ ("isSocket".into(), Value::native(|_| Value::Bool(false))),
102
+ ("size".into(), Value::Number(md.len() as f64)),
103
+ ("mtimeMs".into(), Value::Number(ms_since_epoch(md.modified()))),
104
+ ("atimeMs".into(), Value::Number(ms_since_epoch(md.accessed()))),
105
+ ("birthtimeMs".into(), Value::Number(ms_since_epoch(md.created()))),
106
+ ("mode".into(), Value::Number(mode)),
107
+ ("uid".into(), Value::Number(uid)),
108
+ ("gid".into(), Value::Number(gid)),
109
+ ("ino".into(), Value::Number(ino)),
110
+ ])
102
111
  }
103
112
 
104
113
  // ── cores ─────────────────────────────────────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tishlang/tish-lint",
3
- "version": "3.10.3",
3
+ "version": "3.10.4",
4
4
  "description": "Tish code linter",
5
5
  "license": "PIF",
6
6
  "repository": {
Binary file
Binary file
Binary file
Binary file
Binary file