Skip to content

Feasibility: real SCons engine for extra_scripts beyond the native flag-mutation shim #553

Closed

Description

@zackees

Goal

Investigate whether fbuild can offer real SCons execution as a platformio.ini backwards-compatibility mode for the dynamic extra_scripts behaviors that the current native shim deliberately rejects — and document the engine required and the architectural cost.

Framing note: The original request assumed "fbuild does not have this feature." That is only partly true. fbuild already ships a constrained SCons-compatibility layer (a Python MockEnv subprocess that resolves flag/path/lib mutations) — see crates/fbuild-build/src/script_runtime.rs and crates/fbuild-build/src/script_runtime_harness.py. This issue is about the remaining gap: scripts that need a real SCons engine, not a mock. The earlier journey is closed under #34 → #38 → #39 → #41 → #43; this issue picks up where #43's "explicit non-goals" left off.

Current state (what already works)

fbuild's native path resolves a narrow subset of extra_scripts without SCons:

  1. PlatformIOConfig::get_extra_scripts() reads pre:/post: entries (crates/fbuild-config/src/ini_parser/mod.rs).
  2. resolve_extra_script_overlay() spawns a Python subprocess running script_runtime_harness.py, which executes the user's scripts against a mock SCons env/projenv (MockEnv) and serializes the resulting construction-variable scopes to JSON (crates/fbuild-build/src/script_runtime.rs:29).
  3. The Rust side translates those scopes (CPPDEFINES, CPPPATH, CCFLAGS, CFLAGS, CXXFLAGS, ASFLAGS, LINKFLAGS, LIBPATH, LIBS) into compile/link flag overlays and feeds them into the normal native pipeline.

Supported shims today: Append/AppendUnique/Prepend/Replace over the known scopes, plus read-only/no-op helpers Dump, BoardConfig, PioPlatform, Flatten, VerboseAction, Execute (no-op), IsCleanTarget, IsIntegrationDump, subst, GetProjectOption. Anything else hard-fails with "Recommendation: use --platformio."

Per the #43 real-world sample (25 GitHub repos), this covers 8/25 projects natively. The remaining misses that need genuine SCons semantics cluster as:

  • Effectful Execute(...) — scripts that actually run a tool to generate sources/headers/firmware images at build time (e.g. merge_firmware.py, generate_includes.py).
  • SConscript(...) — pulling in additional SCons build fragments.
  • AddBuildMiddleware / custom builders / custom targets — Marlin-class build stacks.
  • Construction-variable rewrites beyond flag scopes — PROGNAME, MKSPIFFSTOOL, custom env[...] tool substitutions.
  • env.get(...) / GetProjectConfig / GetBuildType / ParseFlagsExtended used to drive dynamic logic.

The actual open question

"Full SCons support" can mean three architecturally different things. The earlier issues (#39, #41) already established that PlatformIO's __idedata / pio project metadata export is insufficient to faithfully replay the build graph (no full link line, no library archive ordering, no action hooks). So the realistic options are:

Option A — Host a real SCons engine in-process/vendored

Vendor SCons (pure-Python, MIT license) plus PlatformIO's builder/ tools (the platform build scripts that define the actual compile/link/upload builders). Run the genuine construction-environment build far enough to either (a) emit a resolved build spec or (b) drive compilation itself.

  • Engine required: a Python interpreter at build time (already a soft dependency for the current shim) and the full PlatformIO Core + platform packages, because extra_scripts run inside PlatformIO's builder, not bare SCons. There is no "bare SCons" PlatformIO project — the SConstruct is PlatformIO's builder/main.py.
  • Implication: This is effectively becoming a PlatformIO host. At that point the marginal value over the existing --platformio passthrough is small, and we'd own a vendored copy of two large Python codebases plus their version skew across platform packages.
  • Cost: Very high. Cross-platform Python packaging in a Rust/PyPI-distributed tool, version-matrix maintenance against PlatformIO releases, and the philosophical reversal of "we don't run SCons ourselves."

Option B — Delegate the resolution phase to PlatformIO, then compile natively (the #41 "state-dump bridge", upgraded from mock to real)

Instead of MockEnv, invoke the real pio run with an injected final POST script that runs after all user scripts, serializes the fully-resolved env/projenv state (tool paths, all scopes, PIOBUILDFILES, linker script, flash-image steps) to JSON, and exits before compilation. fbuild then performs native compile/link/deploy from that spec.

  • Engine required: PlatformIO Core installed/available (same prerequisite as --platformio), invoked once per env to produce the dump. No vendoring.
  • Covers: dynamic flag computation, env.get, BoardConfig/PioPlatform for real, build_unflags, and Execute(...) side effects (since real PlatformIO actually runs them, generated sources land on disk before the dump).
  • Does NOT cleanly cover: custom builders/targets and middleware whose behavior is only known at action-execution time (Prototype a PlatformIO/SCons state-dump bridge for scripted-project compatibility #41 failure criteria), and anything that requires the actual SCons DAG to drive incremental rebuilds.
  • Cost: Medium. New "resolve via pio, build via fbuild" mode + the injected dump harness + a translation layer from PlatformIO's resolved state to fbuild's BuildContext. Risk: divergence between dumped state and what PlatformIO would actually execute. Loses fbuild's caching/speed advantage for the resolution step (one pio invocation per env).

Option C — Keep the MockEnv shim, widen it opportunistically (status quo trajectory)

Continue adding read-only/deterministic shims as real repos demand them, keep hard-failing on effectful behavior with the --platformio recommendation.

  • Engine required: none beyond the current Python subprocess.
  • Cost: Low and incremental, but structurally cannot cover effectful Execute, SConscript, or custom builders — those need a real engine. Ceiling is roughly the "simple flag-file" class of scripts.

Option D — Status quo fallback only

--platformio already delegates the entire build to real PlatformIO for anything unsupported. Cost: zero; UX cost: user loses fbuild's speed for those projects.

Feasibility assessment

  • Is real SCons support feasible? Technically yes via Option A, but it is architecturally self-defeating: hosting SCons means hosting PlatformIO, which is what --platformio already does. Not recommended.
  • Is there a feasible middle path? Option B is the only one that meaningfully extends coverage (especially generator scripts via real Execute side effects) without permanently owning a vendored Python build system. It was prototyped-on-paper in Prototype a PlatformIO/SCons state-dump bridge for scripted-project compatibility #41 but never built; Research whether PlatformIO __idedata / project metadata can support a scripted-project bridge #39 confirmed the metadata-export shortcut alone is insufficient, so Option B specifically requires the injected-dump variant, not pio project metadata.
  • What's the cheapest high-value next step? Likely Option C for a couple more deterministic shims, with Option B as the strategic investment if/when generator-script projects become a priority.

Proposed scope for THIS issue

This is a feasibility/architecture decision issue, not an implementation issue. Deliverables:

  • Confirm the engine analysis above against the current PlatformIO Core (builder/main.py, builder/tools/piointegration.py) — verify there is no bare-SCons entry point that avoids hosting PlatformIO.
  • Build a throwaway Option B spike: inject a real final-POST dump script into pio run, capture resolved env/projenv + tool paths + PIOBUILDFILES for one generator-script repo from Real-world platformio.ini sample reveals remaining native compatibility gaps #43 (e.g. UtilitechAS/amsreader-firmware, which uses Execute+VerboseAction) and one synthetic fixture.
  • Record whether the dump is rich enough to drive a native compile+link without a second pio invocation (the Prototype a PlatformIO/SCons state-dump bridge for scripted-project compatibility #41 success criterion).
  • Decide: pursue Option B, stay on Option C, or formally declare effectful SCons a permanent --platformio-only path and document it.

Recommendation (for discussion)

Decline Option A (becoming a SCons host duplicates --platformio). Treat Option B as the only feasible way to close the generator-script gap, gated behind a spike that proves the injected-dump is replay-sufficient. Until then, keep widening the deterministic MockEnv shim (Option C) and keep effectful scripts on the --platformio fallback (Option D).

References

Activity

  1. zackees commented on Jun 14, 2026

    @zackees
    MemberAuthor

    Investigation: emulation shim vs packed lite-SCons sidecar

    Following on from the original feasibility framing — there's a fifth option sitting between the current MockEnv (Option C) and full SCons vendoring (Option A) that's worth a focused look:

    Option E — Packed "Lite-SCons" sidecar. A standalone ~1.5K-LOC Python module bundled with fbuild that reimplements only the PlatformIO-relevant SCons surface. Runs in the same subprocess-isolation model as today's harness. Drops the per-script feature-request churn the MockEnv is heading toward.

    Surface-area math

    What the existing MockEnv handles vs what real scripts in the #43 sample reach for:

    Method / primitive Current shim Lite-SCons sidecar Why scripts use it
    Append / AppendUnique / Prepend / Replace ✅ flag scopes same trivial flag mutation
    env[KEY], env.get(key, default) ✅ partial full dynamic flag computation
    env.subst("$VAR/...") ✅ minimal full env interpolation + recursion path/flag templating
    Dump, BoardConfig, PioPlatform, IsCleanTarget, IsIntegrationDump, GetProjectOption(s), GetBuildType, Flatten, VerboseAction (no-op) ✅ same read-only introspection
    Action(cmd_or_callable, comment=None) ❌ new (~30 LOC) wrap a tool invocation
    Execute(action) (effectful) ❌ no-op only runs the Action; generated files materialize on disk merge_firmware.py, generate_includes.py
    AddPreAction(target, action) / AddPostAction(target, action) ❌ no-op recorded record into a per-target action list returned to fbuild post-link image merging, OTA packaging
    AddCustomTarget(name, deps, actions, …) ❌ record into a custom-target list returned to fbuild Marlin, OTA flows
    AddBuildMiddleware(callback, regex=None) ❌ record middleware; fbuild calls it per source pre-compile Marlin per-file flag tweaks
    SConscript(path) ❌ exec child script with shared env sub-script composition
    AddMethod(callable, name) ❌ bind as attribute script-defined helpers
    ParseFlagsExtended(line) ❌ parse -DX -Iy -lpath into flag scopes flag-string ingestion

    Total new API surface to write: ~12 methods + 3 small classes (Action, Builder, Node). Concretely: Action is "command-string or callable plus an optional comment". Builder is a name + a default Action + suffixes; scripts mostly register builders, fbuild executes the recorded calls. Node is a thin wrapper around a path string with __str__ returning the path (enough to satisfy env.File("...") / env.Dir("...") callers).

    The existing harness is 434 LOC of Python. The sidecar would land at maybe ~1.5K LOC total — well under the threshold where AI-assisted greenfield writing becomes the bottleneck.

    Emulation shim vs sidecar — head-to-head

    Dimension Extend the emulation shim (Option C trajectory) Lite-SCons sidecar (Option E)
    Architecture Per-method patches keep being added to MockEnv.__getattr__ Self-contained module with primitives; scripts compose freely
    Effectful Execute(action) ❌ structurally impossible without an Action abstraction ✅ runs action.execute(env); generated files appear
    Custom builders (env.AddBuildMiddleware, AddCustomTarget) ❌ no Node/target plumbing to bind to ✅ recorded as opaque ops + ordered action list returned to fbuild
    SConscript("child.py") ❌ no recursive-eval helper ✅ ~20 LOC of exec(open(child).read(), child_scope) chaining
    Per-script bug surface Grows with each new shim — every divergence is a new hard-fail One sidecar to maintain; once an API is implemented, most scripts work or don't
    Hard-fail boundary "Method X is not supported" — N reasons, one per missed method "feature X needs a real DAG" — small finite list (incremental rebuilds, scanner-driven dep discovery)
    Risk surface Semantic mismatch in env.subst recursion, list flattening, scope normalization — each surfaces as a new ticket Same risks, but contained behind a single layer with snapshot tests
    Coverage estimate vs #43 (8/25 today) +2 to +4 with each round of patches; ceiling around 12-15/25 before Execute blocks the rest Plausibly 18-22/25 once Execute + middleware + custom-target are real; remainder is real-DAG-dependent and stays on --platformio
    LOC to maintain ~600-800 (current 434 + drift) ~1.5K, one-time
    Failure mode when wrong Build silently produces stale output (no-op action ignored) Action fires but produces wrong file → script's existing error surfaces

    What a "lite SCons" can't do (and shouldn't try)

    Three things the sidecar should explicitly not attempt — these are the architectural boundary that keeps it lite:

    1. Real DAG / incremental rebuilds. SCons' value is its content-signature-based dependency graph. A lite sidecar models a single linear "resolve then return" pass. Generated sources must be regenerated each clean build — incremental rebuilds of generated content fall back to --platformio. Most PlatformIO extra_scripts don't actually need the DAG; they need one generator step to run.
    2. Scanner-driven header dependency discovery. fbuild already has its own header-scan (fbuild-header-scan); the sidecar doesn't need to replicate SCons' CScanner.
    3. PlatformIO-defined builders that wrap chip-family tooling (env.MergeFlashImage for ESP32, env.PackageJsonFirmware for OTA, etc.). The sidecar records them as named ops; fbuild either maps them to native equivalents (esptool merge_bin already lives in fbuild-deploy) or fails fast with a structured "needs platformio for builder X" message.

    Isolation model

    Sidecar gets the same subprocess isolation the current MockEnv harness uses today: spawn python lite_scons_sidecar.py <input.json>, capture stdout (resolved state JSON) + stderr (notes + errors), bounded wall-clock timeout. No shared interpreter state, no Python in the fbuild Rust binary. Same security profile as today.

    Effectful Execute(action) does run the user's tool — but inside the same subprocess, so its filesystem effects are observable to fbuild via the returned generated-files manifest. This is the only meaningful behavioural delta from today's MockEnv from a sandboxing standpoint.

    Cost / spike scope

    Initial build (AI-assisted greenfield against the inventoried surface):

    • 1-2 days for the sidecar Python module (Environment, Action, Builder, Node, subst, Execute, AddPre/PostAction, AddBuildMiddleware, AddCustomTarget, SConscript, AddMethod, ParseFlagsExtended).
    • 1 day for the Rust-side translation in script_runtime.rs: extend the returned JSON shape to carry the generated-files manifest, recorded pre/post actions, and middleware records; wire them into the native compile pipeline.
    • 2-3 days for verification against 3-5 Real-world platformio.ini sample reveals remaining native compatibility gaps #43 repos that currently fail (1 generator-script repo like UtilitechAS/amsreader-firmware, 1 Marlin-class with middleware, 1 OTA packaging, 1 SConscript-recursive).

    Total: ~1 week to a decision-quality answer. That answer is either "we go from 8/25 to 18+/25" (ship) or "Builder semantics in PlatformIO defeat us" (fall back to Option B's pio run dump path).

    Recommendation

    Spike Option E as a 1-week sub-issue and decide on results. If the sidecar covers the #43 generator-script + middleware repos cleanly, ship it as the default backend for extra_scripts and retire the MockEnv per-method patching trajectory. If we hit a Builder-semantics wall, the spike's outputs (the inventory + the failing-repo characterisation) feed directly into Option B's design.

    The user's hunch (the surface is small enough that an AI-assisted lite reimplementation is feasible) lines up with the inventory: ~12 methods + 3 small classes + the existing 434-LOC foundation. Not zero risk — DAG-edge cases will surface — but it's the only path between "drift forever on per-script shim patches" (Option C) and "host PlatformIO entirely" (Option A) that also covers effectful Execute, which is the cluster blocking the largest chunk of the #43 misses.

  2. zackees commented on Jun 14, 2026

    @zackees
    MemberAuthor

    Spike results — Option E (lite-SCons sidecar)

    Ran the spike. The prototype sidecar is ~620 LOC of Python (single file, no SCons dependency) and runs all five representative SCons patterns that the current MockEnv shim either no-ops or hard-fails. The surface-area estimate from the earlier comment held up.

    Spike repo: C:\Users\niteris\dev\fbuild-spike-553\ (scratch directory, not committed). What got built:

    • lite_scons_sidecar.py — the whole prototype: Environment, Action, Builder, Node, Ledger, subst with recursion, Execute with subprocess for command-strings + direct callable invocation for Python actions, AddPreAction / AddPostAction / AddCustomTarget / AddBuildMiddleware / SConscript / AddMethod / ParseFlagsExtended, plus BoardConfig / PioPlatform / GetProjectConfig / GetBuildType / env.get fall-through into project_options.
    • Five representative scripts under scripts/:
      1. generator.py — Execute(Action(callable)) writes include/buildinfo.h at script time.
      2. middleware.py — AddBuildMiddleware(callback, "*ArduinoCore-*/cores/arduino/*.cpp") (Marlin-class).
      3. postaction.py — AddPostAction("$BUILD_DIR/$PROGNAME$PROGSUFFIX", merge_firmware) (OTA-style packager).
      4. sconscript_main.py → sconscript_child.py — recursive env.SConscript(...) chained mutation.
      5. parseflags.py — env.get("PROJECT_EXTRA_FLAGS", default) → ParseFlagsExtended(...) → conditional if env.GetBuildType() == "release" flag append.

    Outputs after the run

    {
      "status": "ok",
      "scopes": {
        "CCFLAGS": ["-Wno-unused-parameter", "-DPARENT_RAN=1", "-Os"],
        "CPPDEFINES": [
          {"kind": "kv", "key": "BUILDINFO_PRESENT", "value": "1"},
          {"kind": "kv", "key": "CHILD_DEFINE",      "value": "1"},
          {"kind": "kv", "key": "MY_FLAG",           "value": "1"}
        ],
        "CPPPATH": [
          {"kind": "path", "value": ".../project/include"},
          {"kind": "path", "value": ".../project/include/child"},
          {"kind": "path", "value": ".../project/external/inc"}
        ],
        "LIBS": ["extrasym"]
      },
      "vars": { "PROGNAME": "device-fw-v2", ... },
      "executed_actions": [
        {"kind": "callable", "repr": "<Action callable=write_buildinfo>", "rc": 0, "wallclock_ms": 0}
      ],
      "generated_files": [
        {"path": ".../project/include/buildinfo.h", "size": 95}
      ],
      "recorded_post_actions": [
        {"target": "$BUILD_DIR/$PROGNAME$PROGSUFFIX", "action_repr": "<Action callable=merge_firmware>"}
      ],
      "middleware": [
        {"callback_repr": "tweak_flags_for_arduinocore", "regex": "*ArduinoCore-*/cores/arduino/*.cpp"}
      ],
      "notes": [], "errors": []
    }

    Everything the surface-area table predicted, the sidecar produces. Execute actually runs and the generated header lands on disk (verified by generated_files). Middleware is recorded with its regex so fbuild's native compile can call it pre-source. Post-actions are recorded with the unresolved target template (fbuild calls subst to resolve it after link). SConscript recursively mutates the same env across files. ParseFlagsExtended routes -I/-L/-l (joined and space-separated forms) into the right scopes; defines go to CPPDEFINES; everything else falls into CCFLAGS.

    Bugs the spike surfaced (and the production version will need)

    A spike's real value is the bug list it produces against a "this should obviously work" expectation:

    1. env.get(key, default) must fall through to project_options, not just _vars/_scopes. Real scripts treat env.get("FOO") and env.GetProjectOption("FOO") as interchangeable. First iteration only checked _vars/_scopes, so parseflags.py got the default string instead of the project_options value, and the wrong flag set landed in scopes. One-line fix.
    2. ParseFlagsExtended must handle both -Ipath and -I path (space-separated). PlatformIO's own platform packages emit the joined form, but third-party flag-string outputs (toolchain probes, pkg-config-style helpers) emit space-separated. Real SCons handles both. Fixed via a 2-token lookahead in the parser loop.
    3. SConscript resolves paths relative to the calling script's directory, not the project root. Real SCons docs this clearly; my first cut used project_dir and the child file was missing. Fixed via a stack-tracked __CURRENT_SCRIPT_DIR__ updated by both run_script and SConscript itself.

    All three are exactly the kind of subtle behavioural mismatch the "head-to-head" comment predicted. They're cheap to fix once observed. None of them are architectural.

    What the spike did NOT exercise (yet)

    The five scripts are synthetic — written to hit each cluster of MockEnv failures from the earlier inventory. They aren't real #43 repo scripts. The next beat (1-3 day verification round per the cost estimate) would:

    • Check out UtilitechAS/amsreader-firmware (real generator script) and run the sidecar against its extra_scripts list.
    • Check out a Marlin-class repo and exercise its AddBuildMiddleware pattern with real glob matching.
    • Find a repo using env.MergeFlashImage (the ESP32-specific custom builder) — this is where the "PlatformIO-defined chip-family builders" boundary will surface in practice. Expectation: the sidecar records it as a builder_calls entry and the Rust side either maps it to fbuild-deploy's existing esptool merge_bin or fails fast with a structured "needs platformio for builder MergeFlashImage" message.

    Decision-ready snapshot

    Item Prediction Result
    Sidecar LOC ~1.5K 620 LOC for the working prototype. Production version (error handling, edge cases, snapshot tests) lands at ~1-1.5K
    Effectful Execute works yes ✅ generated file lands on disk
    AddBuildMiddleware recordable yes ✅ regex + callback name captured
    AddPostAction recordable yes ✅ unresolved target template captured for fbuild to subst
    SConscript recursive eval yes ✅ child mutates parent env
    ParseFlagsExtended covers -I path / -Ipath / -L/-l / -D KEY=VAL yes ✅ both forms after the lookahead fix
    Subprocess isolation model reuse existing harness ✅ same input/output JSON shape as script_runtime_harness.py
    Surfaces unknown methods cleanly yes ✅ records to notes and returns no-op (matches current MockEnv permissiveness)

    Recommendation, updated

    Go. The surface-area estimate held, the architectural design (Action/Builder/Node primitives + recorded-actions ledger) covers the four clusters the MockEnv structurally can't (Execute, middleware, post-action, SConscript), and the three bugs the spike caught are all behavioural-mismatch one-liners — none of them imply we need a DAG or any of the things we deliberately excluded.

    Next concrete step: a 2-3 day verification round against 3 real #43 repos. If those land cleanly, the prototype gets rewritten as a fbuild-internal module (under crates/fbuild-build/src/lite_scons_harness.py paralleling script_runtime_harness.py), wired into script_runtime.rs via an opt-in flag, and shipped as the default backend once it gets enough miles.

    If the verification round hits a MergeFlashImage-class wall on PlatformIO-defined chip-family builders, the prototype's builder_calls ledger tells us exactly which builder names need either native-equivalent mapping or a structured "needs --platformio" fallback. That output also feeds Option B (the pio run dump path) as a known cliff if we end up there instead.

    The prototype is throwaway code, but the output of the spike — the bug list, the verified API surface, the JSON shape — is what the production design will be built from. Both directions (ship Option E / fall back to Option B) are now decidable with one more verification round.

  3. zackees commented on Jun 14, 2026

    @zackees
    MemberAuthor

    Meta decision — lite-SCons supersedes MockEnv

    This is the load-bearing decision for the rest of #553. Promoting it from "Option E spike" to "this is the production path." Recording it here so subsequent PRs have a single source of truth to point at.

    Direction

    The lite-SCons harness (now shipping opt-in via #581) replaces the MockEnv shim (script_runtime_harness.py) as the production extra_scripts backend. MockEnv is on a retirement track.

    Rationale: the spike proved that the SCons API surface PlatformIO scripts actually use is ~12 methods + 3 small classes — small enough that a hand-written sidecar covers it without vendoring SCons or hosting PlatformIO (Options A/B). Lite-SCons is a functional superset of MockEnv:

    • every method MockEnv implements, lite-SCons implements with the same semantics;
    • PLUS the four clusters MockEnv structurally can't model (Execute effects, AddBuildMiddleware, AddPostAction with unresolved target templates, SConscript recursion);
    • PLUS the three bugs the spike caught (env.get → project_options fallthrough, ParseFlagsExtended space-form, caller-relative SConscript paths).

    Keeping both backends long-term is not the plan. Two parallel implementations of the same contract is exactly the kind of drift the monocrate policy exists to prevent — every future feature would have to be wired into both, and the MockEnv path's hard-fail semantics would diverge from the lite path's record-and-record-deferred semantics in subtle ways.

    Staged retirement plan

    Each step is a separate PR, gated on the previous step landing cleanly.

    # Step Code change Gated on
    1 Opt-in coexistence — both backends ship; FBUILD_LITE_SCONS=1 selects lite shipped via #581 —
    2 Verification round — point lite-SCons at 3 real #43 repos (UtilitechAS/amsreader-firmware generator, one Marlin-class with AddBuildMiddleware, one ESP32 with MergeFlashImage); characterise the PlatformIO-builder cliff research spike → results comment on this issue #581 merged
    3 Flip the default — FBUILD_LITE_SCONS unset now selects lite; FBUILD_LITE_SCONS=0 becomes the emergency opt-out for the legacy MockEnv path. Pin existing MockEnv-specific tests to MockEnv via _with_mode(..., false) so they keep covering that path until removal one focused PR step 2 lands clean OR explicit decision to ship without verification
    4 Retire MockEnv — delete script_runtime_harness.py, delete the _with_mode overload's false branch, delete LITE_HARNESS vs HARNESS dispatch (only one harness left), delete the FBUILD_LITE_SCONS env var, retire the MockEnv-specific tests and replace them with lite-SCons equivalents one PR one release cycle of green CI on default-lite (step 3)

    What "supersedes" does NOT mean

    The three architectural boundaries lite-SCons keeps stay in place — they're not what MockEnv was solving:

    1. NO DAG / incremental rebuilds. Single-pass resolve-then-return. Generated sources must be regenerated each clean build.
    2. NO scanner-driven header dep discovery. fbuild has fbuild-header-scan for that.
    3. NO PlatformIO-defined chip-family builders (env.MergeFlashImage, env.PackageJsonFirmware, etc.) implemented natively. Recorded as builder_calls entries; fbuild maps known names to native fbuild-deploy ops or fails fast with a structured "needs --platformio for builder X" message.

    Anything that genuinely needs a real DAG (real SConscript-driven multi-target builds, content-signature incremental rebuilds, the full PlatformIO Builder system) stays on --platformio. That's the unchanged Option D fallback.

    What this means for current users

    • Today (post-feat(build): opt-in lite-SCons extra_scripts harness (#553) #581): MockEnv is the default; nothing changes for existing builds. Power users who hit MockEnv's hard-fail boundary can opt in via FBUILD_LITE_SCONS=1 and get the four extra clusters.
    • After step 3: lite-SCons is the default; FBUILD_LITE_SCONS=0 exists as the rollback. Existing builds that worked under MockEnv keep working because lite-SCons is a functional superset.
    • After step 4: the env var is gone, MockEnv is gone, fbuild has one extra-scripts backend.

    Why now, not later

    The honest answer to "should we just keep MockEnv around for safety?" is no: every script that fails on MockEnv today already gets the recommendation to use --platformio. The lite-SCons path doesn't remove that escape hatch — it just shifts the cliff. Scripts that fail on lite-SCons (e.g. they use a PlatformIO chip-family builder we don't natively map) will fall back to --platformio exactly as they would have under MockEnv. The user-visible behaviour for those scripts is unchanged.

    What changes is the cliff location: more scripts native-build, fewer scripts fall back to --platformio. That's the entire point of this issue.

    Reference

  4. zackees commented on Jun 14, 2026

    @zackees
    MemberAuthor

    Resolution

    Closing — the feasibility question is answered and the chosen approach is implemented and shipped on main.

    What this issue asked

    Investigate whether fbuild can offer real SCons execution as a platformio.ini backwards-compatibility mode for the dynamic extra_scripts behaviors that the current native shim deliberately rejects — and document the engine required and the architectural cost.

    What happened

    Four options were laid out (A: vendor SCons + PlatformIO; B: real pio run dump bridge; C: extend the MockEnv shim; D: status-quo --platformio fallback). A fifth option emerged from investigation:

    Option E — packed lite-SCons sidecar. A standalone ~620 LOC Python sidecar that reimplements only the PlatformIO-relevant SCons surface (~12 methods + 3 small classes — Action, Builder, Node). Same subprocess-isolation model as MockEnv; covers effectful Execute, AddBuildMiddleware, AddPreAction/AddPostAction, SConscript recursion, AddCustomTarget, AddMethod, ParseFlagsExtended (both joined and space-separated forms).

    The spike against five representative patterns landed cleanly, surfacing three behavioural-mismatch bugs (env.get → project_options fallthrough, ParseFlagsExtended space form, caller-relative SConscript paths) — all fixed in the same investigation pass.

    Then a four-step staged retirement turned the spike into the production backend:

    Step PR What
    1 — Opt-in coexistence #581 Ship lite_scons_harness.py alongside MockEnv; FBUILD_LITE_SCONS=1 opts in
    2 — Verification round deferred Research-style spike against 3 real #43 repos; tracked separately if anyone picks it up
    3 — Flip the default #583 lite-SCons is the default; FBUILD_LITE_SCONS=0 is the emergency opt-out
    4 — Retire MockEnv #585 Delete script_runtime_harness.py; remove the env var; single backend

    Where the answer lives

    Architectural boundaries (still falling through to --platformio)

    Three categories the lite backend deliberately doesn't model:

    1. Real DAG / incremental rebuilds. Single-pass resolve-then-return.
    2. Scanner-driven header dep discovery. fbuild has its own fbuild-header-scan.
    3. PlatformIO-defined chip-family builders (env.MergeFlashImage, env.PackageJsonFirmware, etc.). Recorded as builder_calls entries; fbuild maps known names to native fbuild-deploy ops or fails fast with a structured "needs --platformio for builder X" message.

    Anything in those three categories is the same --platformio fallback the issue's Option D referenced — that escape hatch is unchanged.

    Deferred follow-up (not gating this closure)

    Step 2 — verification round against 3 real #43 repos. The spike covered 5 representative synthetic patterns + the unit + integration tests in crates/fbuild-build/tests/lite_scons_acceptance.rs. A 2-3 day spike against UtilitechAS/amsreader-firmware (generator script), one Marlin-class with AddBuildMiddleware, and one ESP32 with MergeFlashImage would characterise the PlatformIO-chip-family-builder cliff against real-world code. Worth doing; not blocking on this issue.

    If that cliff turns out to bite real users in practice, the prototype's builder_calls ledger structure tells us exactly which builders need native equivalents in fbuild-deploy. The structural pieces are already in place.

    cc @zackees

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestquestionFurther information is requested

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions