Skip to content

Commit 9a94c85

Browse files
authored
feat(zds+kcl): scaffolding to add feature flag for KCL around WASM instance lifecycle (#12453)
1 parent d0be263 commit 9a94c85

14 files changed

Lines changed: 655 additions & 28 deletions

File tree

rust/kcl-lib/src/lib.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ mod lsp;
7171
mod modules;
7272
mod parsing;
7373
mod project;
74+
mod runtime_flags;
7475
mod settings;
7576
#[cfg(test)]
7677
mod simulation_tests;
@@ -141,6 +142,10 @@ pub use parsing::ast::types::NodePath;
141142
pub use parsing::ast::types::NodePathExt;
142143
pub use parsing::ast::types::Step as NodePathStep;
143144
pub use project::ProjectManager;
145+
pub use runtime_flags::KclRuntimeFlags;
146+
pub use runtime_flags::RuntimeFlag;
147+
pub use runtime_flags::kcl_runtime_flags;
148+
pub use runtime_flags::set_kcl_runtime_flags;
144149
pub use settings::types::Configuration;
145150
pub use settings::types::project::ProjectConfiguration;
146151
#[cfg(not(target_arch = "wasm32"))]

rust/kcl-lib/src/parsing/token/mod.rs

Lines changed: 122 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,10 @@ use winnow::{self};
1919

2020
use crate::CompilationIssue;
2121
use crate::ModuleId;
22+
use crate::RuntimeFlag;
2223
use crate::SourceRange;
2324
use crate::errors::KclError;
25+
use crate::kcl_runtime_flags;
2426
use crate::parsing::ast::types::ItemVisibility;
2527
use crate::parsing::ast::types::VariableKind;
2628

@@ -601,8 +603,8 @@ pub(crate) const KCL_LEXER_ENV_VAR: &str = "KCL_LEXER";
601603
/// `KCL_LEXER` environment variable, so a process can pick either lexer without a
602604
/// rebuild.
603605
///
604-
/// Precedence: test override > `KCL_LEXER` > [`LexerMode::DEFAULT`]. This mirrors
605-
/// the existing `KCL_MEMORY_IMPL` selector in `execution::memory`.
606+
/// Precedence: runtime flags > test override > `KCL_LEXER` >
607+
/// [`LexerMode::DEFAULT`].
606608
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
607609
pub(crate) enum LexerMode {
608610
Old,
@@ -615,14 +617,9 @@ impl LexerMode {
615617

616618
/// Resolve the active lexer mode (see precedence on [`LexerMode`]).
617619
pub(crate) fn resolve() -> Self {
618-
#[cfg(test)]
619-
if let Some(mode) = Self::test_override() {
620-
return mode;
621-
}
622-
623-
match env::var(KCL_LEXER_ENV_VAR) {
624-
Ok(value) => Self::parse(&value),
625-
Err(env::VarError::NotPresent) => Self::DEFAULT,
620+
let env_value = match env::var(KCL_LEXER_ENV_VAR) {
621+
Ok(value) => Some(value),
622+
Err(env::VarError::NotPresent) => None,
626623
Err(env::VarError::NotUnicode(value)) => {
627624
// Invalid-unicode env var: warn and fall back rather than crash.
628625
Self::warn_once(|| {
@@ -631,9 +628,39 @@ impl LexerMode {
631628
value.to_string_lossy()
632629
)
633630
});
634-
Self::Old
631+
None
635632
}
633+
};
634+
635+
Self::resolve_from_sources(
636+
kcl_runtime_flags().use_new_lexer_parser,
637+
Self::test_override_for_resolve(),
638+
env_value.as_deref(),
639+
)
640+
}
641+
642+
fn resolve_from_sources(runtime_flag: RuntimeFlag, test_override: Option<Self>, env_value: Option<&str>) -> Self {
643+
match runtime_flag {
644+
RuntimeFlag::On => return Self::New,
645+
RuntimeFlag::Off => return Self::Old,
646+
RuntimeFlag::Unset => {}
647+
}
648+
649+
if let Some(mode) = test_override {
650+
return mode;
636651
}
652+
653+
env_value.map(Self::parse).unwrap_or(Self::DEFAULT)
654+
}
655+
656+
#[cfg(test)]
657+
fn test_override_for_resolve() -> Option<Self> {
658+
Self::test_override()
659+
}
660+
661+
#[cfg(not(test))]
662+
fn test_override_for_resolve() -> Option<Self> {
663+
None
637664
}
638665

639666
fn parse(value: &str) -> Self {
@@ -759,10 +786,23 @@ fn lex_legacy(s: &str, module_id: ModuleId) -> Result<TokenStream, KclError> {
759786
mod lexer_mode_tests {
760787
use super::LexerMode;
761788
use super::lex;
789+
use crate::KclRuntimeFlags;
762790
use crate::ModuleId;
791+
use crate::RuntimeFlag;
792+
793+
fn set_runtime_lexer_flag(flag: RuntimeFlag) {
794+
crate::set_kcl_runtime_flags(KclRuntimeFlags {
795+
use_new_lexer_parser: flag,
796+
});
797+
}
798+
799+
fn reset_runtime_lexer_flags() {
800+
crate::set_kcl_runtime_flags(KclRuntimeFlags::DEFAULT);
801+
}
763802

764803
#[test]
765804
fn default_mode_is_old() {
805+
reset_runtime_lexer_flags();
766806
assert_eq!(LexerMode::DEFAULT, LexerMode::Old);
767807
}
768808

@@ -780,6 +820,7 @@ mod lexer_mode_tests {
780820

781821
#[test]
782822
fn override_guard_sets_and_restores_mode() {
823+
reset_runtime_lexer_flags();
783824
// Reserved for dispatch/integration tests; relies on the process-global
784825
// atomic, which is race-free under nextest's process isolation.
785826
{
@@ -790,6 +831,75 @@ mod lexer_mode_tests {
790831
assert_eq!(LexerMode::resolve(), LexerMode::Old);
791832
}
792833

834+
#[test]
835+
fn runtime_flags_default_to_unset() {
836+
reset_runtime_lexer_flags();
837+
assert_eq!(
838+
crate::kcl_runtime_flags(),
839+
KclRuntimeFlags {
840+
use_new_lexer_parser: RuntimeFlag::Unset,
841+
}
842+
);
843+
}
844+
845+
#[test]
846+
fn runtime_flag_on_selects_new_lexer() {
847+
reset_runtime_lexer_flags();
848+
set_runtime_lexer_flag(RuntimeFlag::On);
849+
assert_eq!(LexerMode::resolve(), LexerMode::New);
850+
}
851+
852+
#[test]
853+
fn runtime_flag_off_selects_old_lexer() {
854+
reset_runtime_lexer_flags();
855+
set_runtime_lexer_flag(RuntimeFlag::Off);
856+
assert_eq!(LexerMode::resolve(), LexerMode::Old);
857+
}
858+
859+
#[test]
860+
fn runtime_flag_takes_priority_over_test_override_and_env() {
861+
assert_eq!(
862+
LexerMode::resolve_from_sources(RuntimeFlag::Off, Some(LexerMode::New), Some("new")),
863+
LexerMode::Old
864+
);
865+
assert_eq!(
866+
LexerMode::resolve_from_sources(RuntimeFlag::On, Some(LexerMode::Old), Some("old")),
867+
LexerMode::New
868+
);
869+
}
870+
871+
#[test]
872+
fn unset_runtime_flag_allows_env_to_select_lexer() {
873+
assert_eq!(
874+
LexerMode::resolve_from_sources(RuntimeFlag::Unset, None, Some("new")),
875+
LexerMode::New
876+
);
877+
assert_eq!(
878+
LexerMode::resolve_from_sources(RuntimeFlag::Unset, None, Some("old")),
879+
LexerMode::Old
880+
);
881+
}
882+
883+
#[test]
884+
fn unset_runtime_flag_and_missing_env_selects_default_lexer() {
885+
assert_eq!(
886+
LexerMode::resolve_from_sources(RuntimeFlag::Unset, None, None),
887+
LexerMode::DEFAULT
888+
);
889+
}
890+
891+
#[test]
892+
fn test_override_takes_priority_over_env() {
893+
assert_eq!(
894+
LexerMode::resolve_from_sources(RuntimeFlag::Unset, Some(LexerMode::Old), Some("new")),
895+
LexerMode::Old
896+
);
897+
assert_eq!(
898+
LexerMode::resolve_from_sources(RuntimeFlag::Unset, Some(LexerMode::New), Some("old")),
899+
LexerMode::New
900+
);
901+
}
902+
793903
/// Exercises the `New` arm of `lex` in default CI: no `KCL_LEXER` env var is
794904
/// set; the new lexer is selected via the process-global test override (which
795905
/// is race-free under nextest's process-per-test isolation).
@@ -801,6 +911,7 @@ mod lexer_mode_tests {
801911
/// not merely that some lexer ran.
802912
#[test]
803913
fn lex_dispatches_to_new_lexer() {
914+
reset_runtime_lexer_flags();
804915
let _guard = LexerMode::override_for_test(LexerMode::New);
805916
assert_eq!(LexerMode::resolve(), LexerMode::New);
806917

rust/kcl-lib/src/runtime_flags.rs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
use std::sync::RwLock;
2+
3+
use serde::Deserialize;
4+
use serde::Serialize;
5+
6+
/// Runtime representation for feature flags that can be set by the TS app.
7+
///
8+
/// TS currently provides a two-state feature answer: `true` means the feature is
9+
/// on, while `false` covers both explicit off/default behavior and a missing
10+
/// feature entry. Rust keeps a third state so code that was not initialized
11+
/// through the TS/wasm path can still fall back to Rust-side defaults, such as
12+
/// env-based configuration.
13+
#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
14+
pub enum RuntimeFlag {
15+
/// No TS/wasm runtime flag has been installed; fall back to Rust defaults.
16+
#[default]
17+
Unset,
18+
/// TS observed the feature as on; use the new feature behavior.
19+
On,
20+
/// TS observed the feature as false or missing; use default behavior.
21+
Off,
22+
}
23+
24+
/// Maps 1-1 to the KCL related flags added to the Admin portal and TS.
25+
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
26+
pub struct KclRuntimeFlags {
27+
pub use_new_lexer_parser: RuntimeFlag,
28+
}
29+
30+
impl KclRuntimeFlags {
31+
pub const DEFAULT: Self = Self {
32+
use_new_lexer_parser: RuntimeFlag::Unset,
33+
};
34+
}
35+
36+
impl Default for KclRuntimeFlags {
37+
fn default() -> Self {
38+
Self::DEFAULT
39+
}
40+
}
41+
42+
static KCL_RUNTIME_FLAGS: RwLock<KclRuntimeFlags> = RwLock::new(KclRuntimeFlags::DEFAULT);
43+
44+
pub fn set_kcl_runtime_flags(flags: KclRuntimeFlags) {
45+
match KCL_RUNTIME_FLAGS.write() {
46+
Ok(mut guard) => *guard = flags,
47+
Err(poisoned) => {
48+
let mut guard = poisoned.into_inner();
49+
*guard = flags;
50+
}
51+
}
52+
}
53+
54+
pub fn kcl_runtime_flags() -> KclRuntimeFlags {
55+
match KCL_RUNTIME_FLAGS.read() {
56+
Ok(guard) => *guard,
57+
Err(poisoned) => *poisoned.into_inner(),
58+
}
59+
}

rust/kcl-wasm-lib/src/wasm.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
//! Wasm bindings for `kcl`.
22
33
use gloo_utils::format::JsValueSerdeExt;
4+
use kcl_lib::KclRuntimeFlags;
45
use kcl_lib::Program;
56
use kcl_lib::SourceRange;
67
use kcl_lib::exec::NumericType;
@@ -48,6 +49,15 @@ pub fn parse_wasm(kcl_program_source: &str) -> Result<JsValue, String> {
4849
JsValue::from_serde(&(program, errs)).map_err(|e| e.to_string())
4950
}
5051

52+
#[wasm_bindgen]
53+
pub fn set_kcl_runtime_flags(flags_json: &str) -> Result<(), String> {
54+
console_error_panic_hook::set_once();
55+
56+
let flags: KclRuntimeFlags = serde_json::from_str(flags_json).map_err(|e| e.to_string())?;
57+
kcl_lib::set_kcl_runtime_flags(flags);
58+
Ok(())
59+
}
60+
5161
// wasm_bindgen wrapper for recast
5262
// test for this function and by extension the recaster are done in javascript land src/lang/recast.test.ts
5363
#[wasm_bindgen]

src/lang/wasmUtils.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import type { ModuleType } from '@src/lib/wasm_lib_wrapper'
2+
import { beforeEach, describe, expect, it, vi } from 'vitest'
3+
4+
const mocks = vi.hoisted(() => ({
5+
getModule: vi.fn(),
6+
init: vi.fn(),
7+
notifyActiveWasmInstance: vi.fn(),
8+
reloadModule: vi.fn(),
9+
}))
10+
11+
vi.mock('@src/lib/wasm_lib_wrapper', () => ({
12+
getModule: mocks.getModule,
13+
init: mocks.init,
14+
reloadModule: mocks.reloadModule,
15+
}))
16+
17+
vi.mock('@src/lib/wasmLifecycle', () => ({
18+
notifyActiveWasmInstance: mocks.notifyActiveWasmInstance,
19+
}))
20+
21+
const wasmModule = {
22+
default: vi.fn(),
23+
import_file_extensions: vi.fn(),
24+
} as unknown as ModuleType & {
25+
default: ReturnType<typeof vi.fn>
26+
}
27+
28+
describe('wasm utils', () => {
29+
beforeEach(() => {
30+
vi.clearAllMocks()
31+
mocks.getModule.mockReturnValue(wasmModule)
32+
wasmModule.default.mockResolvedValue(undefined)
33+
globalThis.fetch = vi.fn().mockResolvedValue({
34+
arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(8)),
35+
})
36+
})
37+
38+
it('notifies listeners after browser wasm initialization', async () => {
39+
const { initialiseWasm } = await import('@src/lang/wasmUtils')
40+
41+
await expect(initialiseWasm()).resolves.toBe(wasmModule)
42+
43+
expect(mocks.reloadModule).toHaveBeenCalled()
44+
expect(mocks.init).toHaveBeenCalled()
45+
expect(mocks.notifyActiveWasmInstance).toHaveBeenCalledWith(wasmModule)
46+
})
47+
48+
it('starts wasm from a buffer and notifies listeners', async () => {
49+
const { startWasmFromBuffer } = await import('@src/lang/wasmUtils')
50+
const buffer = new ArrayBuffer(8)
51+
52+
await expect(startWasmFromBuffer(buffer)).resolves.toBe(wasmModule)
53+
54+
expect(mocks.reloadModule).toHaveBeenCalled()
55+
expect(mocks.init).toHaveBeenCalledWith({ module_or_path: buffer })
56+
expect(mocks.notifyActiveWasmInstance).toHaveBeenCalledWith(wasmModule)
57+
})
58+
59+
it('restarts wasm and notifies listeners', async () => {
60+
const { restartWasmModule } = await import('@src/lang/wasmUtils')
61+
62+
await expect(restartWasmModule()).resolves.toBe(wasmModule)
63+
64+
expect(mocks.reloadModule).toHaveBeenCalled()
65+
expect(wasmModule.default).toHaveBeenCalled()
66+
expect(mocks.notifyActiveWasmInstance).toHaveBeenCalledWith(wasmModule)
67+
})
68+
})

0 commit comments

Comments
 (0)