Sr. Content Developer at Microsoft, working remotely in PA, TechBash conference organizer, former Microsoft MVP, Husband, Dad and Geek.
156504 stories
·
33 followers

Introducing Corvus.Text.Json V5: Migration, Analyzers, and What's Next

1 Share

At endjin, we maintain Corvus.JsonSchema, and this is the final post in our series introducing the V5 engine. Over the previous thirteen posts, we've covered pooled parsing, mutable documents, source generators, schema validation, annotations, three query languages, YAML, JSON Patch, extended types, and TOON conversion.

Now let's wrap up with migration from V4 and the production analyzers that ship with V5.

V4 isn't going away

Before we talk about migration, let's be clear: V4 is not deprecated. It continues to be maintained and is the right choice when you want the guarantees of an immutable document model.

V4 V5
Mutation Immutable - With*() returns new instance Mutable - Set*() mutates in-place
Safety No aliasing possible Version-tracked stale detection
Performance Good Considerably faster
Best for Thread-safe sharing, functional pipelines High-throughput request/response

Both engines ship in the same corvusjson CLI tool, share the same schema analysis engine, and support the same JSON Schema drafts. You choose which engine to target with the --engine flag.

Migration path

If you do want to move from V4 to V5, the path is well-supported.

Quick reference

V4 V5
Corvus.Json namespace Corvus.Text.Json namespace
MyType.Parse(json) ParsedJsonDocument<MyType>.Parse(json)
entity.Validate(ctx, level) entity.EvaluateSchema() or entity.EvaluateSchema(collector)
entity.WithProperty(...) mutable.SetProperty(...) via builder
JsonAny, JsonString, etc. JsonElement with GetString(), GetInt32(), etc.
--engine V4 --engine V5

Migration analyzers

Install the migration analyzer package:

dotnet add package Corvus.Text.Json.Migration.Analyzers

The analyzers detect V4 patterns - namespace usages, API calls, mutation patterns - and offer automatic Roslyn code fixes. In Visual Studio, you'll see lightbulb suggestions that convert V4 code to the V5 equivalent.

Copilot-assisted migration

For larger migrations, GitHub Copilot can help. The V5 documentation includes a Copilot migration guide with prompts designed to help Copilot understand the V4→V5 mapping and apply it across your codebase.

Production analyzers

V5 ships with 10 Roslyn analyzers that help you write correct, high-performance code:

ID What it catches
CTJ001 String literal where UTF-8 u8 suffix would avoid transcoding
CTJ002 Unnecessary cast to .NET type when implicit conversion suffices
CTJ003 Match lambda that should be static to avoid closure allocation
CTJ004 ParsedJsonDocument created without using or Dispose()
CTJ005 JsonWorkspace created without using or Dispose()
CTJ006 JsonDocumentBuilder created without using or Dispose()
CTJ007 EvaluateSchema() result discarded - probably a bug
CTJ008 String comparison where NameEquals would avoid allocation
CTJ009 Manual Utf8JsonWriter creation where workspace renting is available
CTJ010 String-based Parse where ReadOnlyMemory<byte> overload is available

Six of these have automatic code fixes. CTJ004–CTJ006 are particularly important. They catch the most common mistake with pooled memory: forgetting to dispose.

There's also CTJ-NAV, a refactoring that lets you navigate from a schema-generated type directly to its JSON Schema source in your IDE.

Getting started

# Core library (includes analyzers)
dotnet add package Corvus.Text.Json

# Source generator
dotnet add package Corvus.Text.Json.SourceGenerator

# CLI tool
dotnet tool install --global Corvus.Json.Cli

# Optional: query languages
dotnet add package Corvus.Text.Json.Jsonata
dotnet add package Corvus.Text.Json.JMESPath
dotnet add package Corvus.Text.Json.JsonLogic

# Optional: YAML
dotnet add package Corvus.Text.Json.Yaml

# Optional: JSON Patch
dotnet add package Corvus.Text.Json.Patch

# Optional: dynamic validation
dotnet add package Corvus.Text.Json.Validator

The full documentation is at github.com/corvus-dotnet/Corvus.JsonSchema.

Series recap

Over these fourteen posts, we've covered:

  1. Why V5 Exists - two engines, one toolchain, different trade-offs
  2. Source-Generated Types - schema-first types with full IntelliSense
  3. Schema Validation - over 10× faster, all major drafts
  4. Pooled-Memory Parsing - 136 bytes per document
  5. Mutable Documents - builder pattern with version tracking
  6. Standalone Evaluator - annotations for schema-driven tooling
  7. JSONata - query and transformation with 100% conformance
  8. JMESPath - on average 28× faster JSON queries
  9. JsonLogic - safe business rules as data
  10. YAML 1.2 - zero-allocation conversion with event streaming
  11. JSON Patch - RFC 6902 with a fluent builder
  12. JSON Pointer - zero-allocation path resolution
  13. Extended Types - UTF-8 URIs, BigNumber, and NodaTime
  14. Migration, Analyzers, and What's Next (this post)

If you have questions, find bugs, or want to contribute, the GitHub repository is the place to go.



Read the whole story
alvinashcraft
just a second ago
reply
Pennsylvania, USA
Share this story
Delete

Advanced Dart: Learn Asynchronous Programming with Streams, Isolates, and the Event Loop

1 Share

I had been writing Flutter apps for over a year before I actually understood how Dart handles concurrency.

I knew how to use await. I knew FutureBuilder and StreamBuilder well enough to get things working. But I didn't really understand what was happening underneath: why some code ran in a specific order, why certain operations froze my UI, or why stream subscriptions kept causing memory leaks I couldn't track down.

The moment I actually sat down and learned the event loop, everything else clicked. Why mounted checks work. Why compute() exists. Why streams behave differently depending on how many listeners you attach. These weren't separate things to memorize. They were all consequences of the same underlying model.

This article is the explanation I wish I'd had earlier. We'll go deep on how Dart's event loop actually works, how streams give you control over data that arrives over time, and how isolates let you escape the single thread when you need real parallelism — with practical Flutter examples throughout.

Table of Contents

How Dart's Single-Threaded Model Works

Most languages let you run code on multiple threads simultaneously. One thread handles the network call, another handles user input, another renders the UI — all running at the same time in parallel.

Dart doesn't work that way. Dart runs everything on a single thread. One thing at a time. Always.

When I first learned this, it felt like a limitation. How could a single thread handle a network call, a user tapping a button, and rendering 60 frames per second simultaneously? The answer is that it doesn't handle them simultaneously — it handles them in turns, managed by the event loop.

Think of it like a chef working alone in a kitchen. One chef, one pair of hands. They can't chop and stir at the same time. But a good chef doesn't stand idle waiting for water to boil — they go prep vegetables, come back when the water's ready, then move to the next task. They stay productive by switching between tasks as each one becomes available.

Dart is that chef. The event loop is the system that decides which task to pick up next.

The Event Loop and Its Two Queues

The event loop runs for the entire lifetime of your Dart app. Its job is simple: check if there's work to do, do it, then check again. It does this continuously, in a loop, until the app exits.

Work doesn't happen immediately in Dart. When something is ready to run — a network response arriving, a timer firing, a .then() callback completing — it gets added to a queue. The event loop processes items from those queues one at a time.

Dart has exactly two queues, and understanding both is what separates developers who use async from developers who truly understand it.

The Microtask Queue

This is the high-priority queue. The event loop always empties this queue completely before looking at anything else. .then() callbacks and Future.microtask() land here.

Think of it as the fast checkout lane: short, urgent tasks that should run as soon as possible after the current synchronous code finishes.

The Event Queue

This is where everything external goes — timer callbacks, network responses, user input events, stream data, and Future.delayed() completions. The event loop processes one item from this queue, then goes back to check the microtask queue before processing the next event.

Here's what that ordering looks like in practice:

void main() {
  print('1 — synchronous, runs immediately');

  // Goes into the EVENT queue — regular lane
  Future.delayed(Duration.zero, () {
    print('4 — event queue');
  });

  // Goes into the MICROTASK queue — high priority lane
  Future.microtask(() {
    print('3 — microtask queue');
  });

  print('2 — synchronous, runs immediately');
}

// Output:
// 1 — synchronous, runs immediately
// 2 — synchronous, runs immediately
// 3 — microtask queue
// 4 — event queue

Items 1 and 2 run first because they're synchronous — no queue involved, just straight execution. Then 3 runs before 4 even though both were scheduled with zero delay, because microtasks always run before events.

This ordering matters more than it might seem. When you chain multiple .then() calls, each callback goes into the microtask queue — which is why they feel immediate and always run before any timer or I/O callback, even one scheduled with zero delay.

void main() {
  Future(() => print('event 1'));
  Future(() => print('event 2'));
  Future.microtask(() => print('microtask 1'));
  Future.microtask(() => print('microtask 2'));
  print('synchronous');
}

// Output:
// synchronous
// microtask 1
// microtask 2
// event 1
// event 2

Both microtasks run before either event, regardless of the order they were scheduled in.

How async/await Fits Into This

async/await doesn't create new threads. It doesn't run things in parallel. It's syntactic sugar built on top of the event loop, a cleaner way to write code that works with Dart's single-threaded concurrency model.

Here's the best way I've found to think about it. Imagine you're a waiter in a restaurant, and you're the only waiter on shift. You can only do one thing at a time, but you don't have to stand at the kitchen pass waiting for food. You hand the order to the kitchen and walk away. You go refill water, take another order, clear a table. When the kitchen rings the bell, you pick up the food and deliver it.

await is that moment of handing the order to the kitchen and walking away. You're not blocking, you're pausing this particular task and telling the event loop "come back to me when this is ready." The event loop can now handle other things while the network call, file read, or timer is in progress.

When the awaited operation completes, the rest of your function gets added to the queue and runs when the event loop gets back to it.

Future<void> loadUser() async {
  print('A — before await');

  // Dart pauses here and hands control back to the event loop.
  // The event loop is now free to handle other work —
  // rendering frames, processing other futures, handling taps —
  // while the network call is in progress.
  final user = await dio.get('/user');

  // This only runs when the network response arrives
  // and the event loop gets back to this function.
  print('B — after await, got user');
}

void main() {
  loadUser();

  // This runs before B because loadUser() paused at the await
  // and returned control here before the network call completed.
  print('C — main continues');
}

// Output:
// A — before await
// C — main continues
// B — after await, got user

Why Blocking the Event Loop Causes Jank in Flutter

Flutter's UI rendering runs on the same main isolate as your Dart code. The engine needs the event loop to be free roughly every 16 milliseconds to render a frame at 60fps. Any synchronous operation that takes longer than that blocks the event loop completely — no frames get rendered, no taps get processed, the UI freezes.

// This is dangerous in Flutter.
// Parsing a large JSON response synchronously
// can take 100-300ms on slower devices.
// The event loop is completely blocked the entire time.
// Flutter drops every frame during that window.
// The user sees a frozen screen.
final users = (response.data as List)
    .map((json) => User.fromJson(json))
    .toList();

await doesn't help here because the work is CPU-bound — the CPU is busy the entire time, so there's no natural pause where the event loop can breathe. That's exactly the problem isolates exist to solve, which we'll get to shortly.

Streams: Controlling Data That Arrives Over Time

A Future delivers one value and completes. A Stream delivers multiple values over time and stays open until it's cancelled or exhausted.

If a Future is ordering food at a restaurant — you wait once, you get one meal, it's done — then a Stream is a subscription newsletter. New editions keep arriving over time, and you keep receiving them until you unsubscribe.

// A stream that counts from 1 to 5, one number per second.
// async* marks this as a stream generator function.
// yield pushes a value into the stream and pauses
// until the listener is ready for the next value.
Stream<int> countStream() async* {
  for (int i = 1; i <= 5; i++) {
    await Future.delayed(const Duration(seconds: 1));
    yield i;
  }
  // When the loop ends the stream closes automatically.
}

You can consume a stream with await for or with .listen():

// Method 1 — await for: clean, readable for simple cases
await for (final number in countStream()) {
  print(number); // prints 1, 2, 3, 4, 5, one per second
}

// Method 2 — listen(): more control, can cancel midway
final subscription = countStream().listen(
  (number) => print(number),
  onError: (error) => print('Error: $error'),
  onDone: () => print('Stream closed'),
);

// Cancel after 3 seconds — stops receiving values
await Future.delayed(const Duration(seconds: 3));
subscription.cancel();

Single-Subscription vs Broadcast Streams

This distinction trips up a lot of Flutter developers, and understanding it prevents a whole category of confusing errors.

Single-subscription streams can only have one listener at a time. This is the default. Most streams — file reads, HTTP response bodies — are single-subscription. Try to listen twice and you get a StateError.

final stream = countStream();

stream.listen(print); // fine
stream.listen(print); // throws: Stream has already been listened to

Broadcast streams can have any number of simultaneous listeners. All of them receive the same values. This is what you want for app-wide events, user interactions, or anything multiple parts of your app need to react to.

// StreamController.broadcast() creates a stream
// that any number of listeners can subscribe to.
final controller = StreamController<String>.broadcast();

controller.stream.listen((v) => print('Listener 1: $v'));
controller.stream.listen((v) => print('Listener 2: $v'));

// Both listeners receive this value
controller.sink.add('Hello');
// Listener 1: Hello
// Listener 2: Hello

// Always close the controller when you're done with it.
// An unclosed controller keeps resources alive indefinitely.
controller.close();

Using StreamController to Create Streams Manually

StreamController gives you full manual control. You decide exactly when to push values, when to push errors, and when to close the stream. This is how you build reactive data sources from scratch.

class LocationService {
  // Broadcast so multiple widgets can listen to
  // location updates simultaneously.
  final _controller = StreamController<Position>.broadcast();

  // Expose only the stream publicly.
  // The controller stays private so only this class
  // can push new values into it.
  Stream<Position> get locationStream => _controller.stream;

  void startTracking() {
    Timer.periodic(const Duration(seconds: 2), (_) {
      final position = Position(lat: 0.3476, lng: 32.5825);
      // sink.add() pushes a value into the stream.
      // All active listeners receive it immediately.
      _controller.sink.add(position);
    });
  }

  void dispose() {
    // Always close the controller when you're done.
    // An unclosed controller is a memory leak.
    _controller.close();
  }
}

Using Streams in Flutter with StreamBuilder

StreamBuilder is the Flutter widget for consuming a stream directly in the UI. It rebuilds every time a new value arrives.

StreamBuilder<List<Message>>(
  stream: firestore
      .collection('messages')
      .snapshots()
      .map((snapshot) => snapshot.docs
          .map((doc) => Message.fromJson(doc.data()))
          .toList()),
  builder: (context, snapshot) {
    if (snapshot.connectionState == ConnectionState.waiting) {
      return const CircularProgressIndicator();
    }

    if (snapshot.hasError) {
      return Text('Error: ${snapshot.error}');
    }

    if (!snapshot.hasData || snapshot.data!.isEmpty) {
      return const Text('No messages yet');
    }

    return ListView.builder(
      itemCount: snapshot.data!.length,
      itemBuilder: (context, index) {
        return MessageBubble(message: snapshot.data![index]);
      },
    );
  },
)

Always Cancel Stream Subscriptions in dispose

This is one of the most common memory leaks in Flutter apps, and it comes directly from not understanding streams.

An active subscription keeps the stream's callback alive. If the widget it belonged to is gone but the subscription is still running, callbacks fire on a disposed widget, setState gets called after dispose, and objects that should have been freed stay in memory.

class _ChatScreenState extends State<ChatScreen> {
  StreamSubscription<Message>? _subscription;

  @override
  void initState() {
    super.initState();
    _subscription = messageStream.listen((message) {
      if (mounted) setState(() => messages.add(message));
    });
  }

  @override
  void dispose() {
    // cancel() unsubscribes from the stream.
    // Without this, the callback keeps firing
    // even after this screen is removed from the tree.
    _subscription?.cancel();
    super.dispose();
  }
}

StreamTransformers and Advanced Stream Control

Once you understand streams, you quickly discover that raw streams rarely give you exactly what you want. You need to filter values, transform them, debounce rapid emissions, or combine multiple streams. That's where stream operators and StreamTransformer come in.

Dart's Stream class has a rich set of built-in transformation methods:

final stream = countStream();

// map — transform each value before it reaches listeners
stream
    .map((number) => number * 2)
    .listen(print); // 2, 4, 6, 8, 10

// where — filter out values that don't match a condition
stream
    .where((number) => number.isEven)
    .listen(print); // 2, 4

// take — only emit the first N values, then close
stream
    .take(3)
    .listen(print); // 1, 2, 3

// skip — ignore the first N values
stream
    .skip(2)
    .listen(print); // 3, 4, 5

// distinct — only emit when the value changes from the last one
Stream.fromIterable([1, 1, 2, 2, 3])
    .distinct()
    .listen(print); // 1, 2, 3

For more complex transformations, you can build a custom StreamTransformer. This is the pattern to reach for when the built-in operators don't cover your use case — for example, when you need to transform values in a way that requires maintaining state between emissions.

// A StreamTransformer that only emits values above a threshold
// and prefixes each one with a label.
StreamTransformer<int, String> aboveThreshold(int threshold) {
  return StreamTransformer.fromHandlers(
    handleData: (value, sink) {
      // sink.add() pushes a transformed value downstream.
      // If we don't call sink.add(), the value is filtered out.
      if (value > threshold) {
        sink.add('Above threshold: $value');
      }
    },
    handleError: (error, stackTrace, sink) {
      // Forward errors downstream unchanged.
      sink.addError(error, stackTrace);
    },
    handleDone: (sink) {
      // Close the output stream when the input stream closes.
      sink.close();
    },
  );
}

// Usage
countStream()
    .transform(aboveThreshold(3))
    .listen(print);
// Above threshold: 4
// Above threshold: 5

Debouncing with Streams in Flutter

One of the most practical stream patterns in Flutter apps is debouncing a search field. Without debouncing, every keystroke fires an API call. With debouncing, you wait for the user to stop typing before firing.

class _SearchScreenState extends State<SearchScreen> {
  final _searchController = TextEditingController();
  final _searchStream = StreamController<String>();
  StreamSubscription? _subscription;
  List<Result> _results = [];

  @override
  void initState() {
    super.initState();

    _subscription = _searchStream.stream
        // Wait 300ms after the last keystroke before emitting.
        // If a new value arrives within 300ms, the timer resets.
        // This prevents firing an API call on every keystroke.
        .asyncExpand((query) async* {
          await Future.delayed(const Duration(milliseconds: 300));
          yield query;
        })
        // Ignore duplicate queries — no point re-fetching
        // if the user typed the same thing again.
        .distinct()
        // For each query, call the API and emit the results.
        // asyncMap cancels the previous call if a new query
        // arrives before the previous one completes.
        .asyncMap((query) => _repository.search(query))
        .listen((results) {
          if (mounted) setState(() => _results = results);
        });

    _searchController.addListener(() {
      _searchStream.add(_searchController.text);
    });
  }

  @override
  void dispose() {
    _searchController.dispose();
    _subscription?.cancel();
    _searchStream.close();
    super.dispose();
  }
}

Isolates: Escaping the Single Thread

Dart is single-threaded, but that doesn't mean you're limited to one thread forever. Isolates are Dart's way of running code on a completely separate thread — with one important difference from threads in other languages.

In most languages, threads share memory. Two threads can read and write the same variable at the same time, which creates race conditions and requires careful locking to prevent.

Dart isolates don't share memory at all. Each isolate has its own separate memory heap. The only way two isolates can communicate is by passing messages — like sending notes through a slot in a wall rather than sharing a whiteboard.

This makes isolates safe by design. There are no race conditions because there's nothing to race over. Each isolate owns its data completely.

Main Isolate                    Worker Isolate
─────────────────               ─────────────────
Own memory heap                 Own memory heap
Own event loop                  Own event loop
UI rendering                    Heavy computation
User input                      No UI access
│                               │
│──── sends data ──────────────→│
│                               │ (processes independently)
│←─── receives result ──────────│

When You Actually Need an Isolate

The distinction that matters is CPU-bound vs I/O-bound work:

  • I/O-bound work: waiting for a network response, reading a file — just use await. The CPU is idle while waiting, so the event loop stays free.

  • CPU-bound work: actually computing something, processing data, parsing large files — needs an isolate. The CPU is busy the whole time, so await can't help.

If parsing your API response takes 200ms, await doesn't save you. The event loop is blocked for those 200ms regardless. You need to move that work to a separate isolate.

Isolate.run() — the Modern Approach

Isolate.run() was added in Dart 2.19 and is the cleanest way to run a one-off task in a background isolate. It spawns the isolate, runs your function, returns the result, and closes the isolate automatically.

// In your repository:
Future<List<User>> getUsers() async {
  // Step 1 — network call is I/O-bound.
  // We await it and the event loop stays free while waiting.
  final response = await dio.get('/users');

  // Step 2 — parsing thousands of users is CPU-bound.
  // We move it to a separate isolate with Isolate.run().
  // The main isolate's event loop stays free the whole time.
  // Flutter keeps rendering frames normally.
  final users = await Isolate.run(() {
    final data = response.data as List<dynamic>;
    return data
        .map((json) => User.fromJson(json as Map<String, dynamic>))
        .toList();
  });

  return users;
}

compute() — Flutter's Built-in Helper

compute() is Flutter's wrapper around isolates that predates Isolate.run(). It's still widely used and works well, but has one constraint: the function you pass must be a top-level or static function, not a closure that captures local variables.

// The function must be top-level or static.
// It can't be a closure because closures that capture
// state can't be sent across isolate boundaries.
List<User> parseUsers(dynamic data) {
  return (data as List)
      .map((json) => User.fromJson(json as Map<String, dynamic>))
      .toList();
}

// In your repository:
final users = await compute(parseUsers, response.data);

For most use cases, Isolate.run() is simpler and more flexible. compute() is still useful if you need to support Flutter versions below 2.19.

Full Isolate Communication with SendPort and ReceivePort

For long-running background tasks where you need to send multiple messages back and forth — a background sync service, a real-time data processor, a file watcher — you need a full isolate with SendPort and ReceivePort.

void main() async {
  // ReceivePort is how the main isolate listens
  // for messages coming back from the worker.
  final receivePort = ReceivePort();

  // Spawn the worker isolate and give it a SendPort
  // so it can send messages back to us.
  await Isolate.spawn(
    workerFunction,
    receivePort.sendPort,
  );

  // Listen for messages from the worker.
  receivePort.listen((message) {
    print('Main received: $message');
  });
}

// This function runs entirely in the worker isolate.
// It has its own memory heap, completely separate
// from the main isolate. It cannot access any
// variables from main() directly.
void workerFunction(SendPort sendPort) {
  for (int i = 0; i < 5; i++) {
    // sendPort.send() passes a message to the main isolate.
    // The message is copied, not shared — no shared memory.
    sendPort.send('Processed item $i');
  }
}

Choosing the Right Approach

Situation Use
One-off background task Isolate.run()
Need to support Flutter below 2.19 compute()
Long-running background worker Full isolate with SendPort
Waiting for network or file I/O Just await — no isolate needed

Putting It All Together in Flutter

Here's a complete example that uses all three concepts together (the event loop, streams, and isolates) in a single Flutter feature: a search screen that fetches results from a mock API, parses them in a background isolate, and delivers them via a stream.

import 'dart:isolate';
import 'package:flutter/material.dart';

// Model
class SearchResult {
  final String id;
  final String title;
  const SearchResult({required this.id, required this.title});
}

// Top-level function — required for Isolate.run()
// because it can't be a closure
List<SearchResult> parseResults(List<dynamic> data) {
  // Simulate expensive parsing work
  return data.map((item) => SearchResult(
    id: item['id'].toString(),
    title: item['title'] as String,
  )).toList();
}

// Repository
class SearchRepository {
  // Mock data — in a real app this would be a network call
  final List<Map<String, dynamic>> _mockData = List.generate(
    100,
    (i) => {'id': i, 'title': 'Result ${i + 1}'},
  );

  Future<List<SearchResult>> search(String query) async {
    // Simulate network delay
    await Future.delayed(const Duration(milliseconds: 500));

    // Filter mock data
    final filtered = _mockData
        .where((item) =>
            (item['title'] as String)
                .toLowerCase()
                .contains(query.toLowerCase()))
        .toList();

    // Parse in a background isolate so the main
    // isolate's event loop stays free
    return Isolate.run(() => parseResults(filtered));
  }
}

// Screen
class SearchScreen extends StatefulWidget {
  const SearchScreen({super.key});

  @override
  State<SearchScreen> createState() => _SearchScreenState();
}

class _SearchScreenState extends State<SearchScreen> {
  final _controller = TextEditingController();
  final _repository = SearchRepository();

  bool _isLoading = false;
  List<SearchResult> _results = [];
  String? _error;

  Future<void> _search(String query) async {
    if (query.trim().isEmpty) {
      setState(() => _results = []);
      return;
    }

    setState(() {
      _isLoading = true;
      _error = null;
    });

    try {
      final results = await _repository.search(query);

      // mounted check — the user might have navigated away
      // while the search was running
      if (!mounted) return;

      setState(() {
        _results = results;
        _isLoading = false;
      });
    } catch (e) {
      if (!mounted) return;

      setState(() {
        _error = 'Search failed. Please try again.';
        _isLoading = false;
      });
    }
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Search')),
      body: Column(
        children: [
          Padding(
            padding: const EdgeInsets.all(16),
            child: TextField(
              controller: _controller,
              decoration: const InputDecoration(
                labelText: 'Search',
                border: OutlineInputBorder(),
                prefixIcon: Icon(Icons.search),
              ),
              onChanged: _search,
            ),
          ),
          Expanded(child: _buildBody()),
        ],
      ),
    );
  }

  Widget _buildBody() {
    if (_isLoading) {
      return const Center(child: CircularProgressIndicator());
    }

    if (_error != null) {
      return Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            Text(_error!),
            const SizedBox(height: 16),
            ElevatedButton(
              onPressed: () => _search(_controller.text),
              child: const Text('Try again'),
            ),
          ],
        ),
      );
    }

    if (_results.isEmpty) {
      return const Center(child: Text('No results found.'));
    }

    return ListView.builder(
      itemCount: _results.length,
      itemBuilder: (context, index) {
        final result = _results[index];
        return ListTile(
          leading: Text(result.id),
          title: Text(result.title),
        );
      },
    );
  }
}

void main() {
  runApp(const MaterialApp(home: SearchScreen()));
}

This example brings together everything we've covered:

  • The event loop keeps the UI responsive while the mock network delay is in progress — await hands control back to the event loop so Flutter keeps rendering frames

  • Isolates handle the parsing work in the background so even with a large result set the main thread stays free

  • The mounted check protects against the widget being disposed while the search is in flight

  • All four UI states (loading, error, empty, and results) are handled explicitly

Final Thoughts

Understanding the event loop, streams, and isolates helps you understand why Dart behaves the way it does. Once that mental model is in place, a lot of things that used to feel arbitrary start making sense.

Why do you need the mounted check? Because await pauses your function and returns control to the event loop — the widget can be disposed before your function resumes. Why does compute() help with jank? Because CPU-bound work blocks the event loop, and moving it to an isolate frees the loop to keep rendering. Why do broadcast streams exist? Because the default single-subscription stream only allows one listener, and some data sources need to serve multiple parts of your app simultaneously.

These aren't separate rules to memorize. They're all consequences of the same single-threaded concurrency model, once you understand it from the ground up.

If you're already comfortable with await and FutureBuilder, pick one concept from this article and go deeper on it this week. Build the stream debounce example. Try Isolate.run() on a real parsing task in one of your apps. Watch what happens to your frame rate in Flutter DevTools before and after. The understanding sticks much faster when you see it working in your own code.



Read the whole story
alvinashcraft
13 seconds ago
reply
Pennsylvania, USA
Share this story
Delete

Configuring Copilot CLI Isolation via the GitHub Copilot SDK

1 Share

In the previous post, we walked through local sandboxing in the Copilot CLI: enable it with /sandbox enable, tune filesystem and network rules through the TUI, and your agent's shell execution is isolated by Microsoft MXC. Simple, useful, done.

But if you're building with the Copilot SDK, embedding the agent runtime into your own .NET application, you can't type /sandbox enable into a session you're programmatically orchestrating. So the question becomes: how do you get the same isolation guarantees when you own the host?

The good news: sandbox support is coming to the SDK as a preview feature. The entry point is Session.Rpc.Options.UpdateAsync, and it lets you push a sandbox configuration into a running session from code.

Preview caveat: this API is behind the experimental surface of the SDK. It's real, it works, but the shape may change before it stabilises. Treat it as preview-quality and don't build production contracts on top of it just yet.

What you're actually configuring

Before looking at code, it's worth understanding what the SDK sandbox does and doesn't do — because it's the same underlying thing as the CLI sandbox.

The CLI's /sandbox command writes a configuration into settings.json and the MXC isolation layer picks it up. The SDK's Session.Rpc.Options.UpdateAsync pushes that same configuration into a session over the JSON-RPC channel that the SDK uses to communicate with the Copilot CLI process it manages. MXC still does the actual isolation. You're just providing the config programmatically rather than interactively.

What the sandbox isolates: shell command execution that the Copilot agent initiates on your behalf. Same scope as the CLI: filesystem access, network connectivity, system capabilities.

What it doesn't replace: OnPermissionRequest. That's still your gate for deciding whether to approve a tool call at all. The sandbox is about constraining what an approved tool call can reach. The two are complementary.

The basic pattern

The key pieces:

  1. Create a session as normal, with an OnPermissionRequest handler
  2. Call Session.Rpc.Options.UpdateAsync with a sandboxConfig parameter
  3. Pass the sandbox configuration as a JsonElement (loaded from file or constructed inline)
  4. Then run your session as normal — sandbox is active for all subsequent tool execution
using GitHub.Copilot;
using GitHub.Copilot.Rpc;
using System.Text.Json;

await using var client = new CopilotClient();

await using var session = await client.CreateSessionAsync(new SessionConfig
{
    OnPermissionRequest = async (request, invocation) =>
    {
        return PermissionDecision.ApproveOnce();
    }
});

// Load sandbox config from a JSON file alongside your application
var sandboxConfig = JsonSerializer.Deserialize<SandboxConfig>(
    File.ReadAllText("sandbox-config.json"));

// Push the sandbox configuration into the session
await Session.Rpc.Options.UpdateAsync(sandboxConfig: sandboxConfig);

// Now run the agent — tool execution is sandboxed
await session.SendAndWaitAsync($"<enquiry>{Enquiry}</enquiry>");

The Session.Rpc.Options surface is the SDK's escape hatch into the lower-level JSON-RPC options layer, which is why it lands in the Rpc namespace rather than on the session directly. The UpdateAsync method accepts a named sandboxConfig parameter of type JsonElement, mirroring the structure the CLI would write to settings.json.

The sandbox configuration file

The sandbox-config.json you pass follows the same schema as the CLI's sandbox configuration. Here's a realistic starting point for a .NET project doing dependency analysis:

{
  "enabled": true,
  "addCurrentWorkingDirectory": true,
  "sandboxMcpServers": true,
  "sandboxLspServers": true,
  "userPolicy": {
    "filesystem": {
      "readwritePaths": [],
      "readonlyPaths": [
        "C:\\Users\\user\\.nuget\\packages",
        "C:\\Program Files\\dotnet"
      ],
      "deniedPaths": [],
      "clearPolicyOnExit": true
    },
    "network": {
      "allowOutbound": true,
      "allowLocalNetwork": false,
      "allowedHosts": [
        "api.nuget.org",
        "api.github.com"
      ],
      "blockedHosts": []
    }
  }
}

A few things worth noting about the structure:

addCurrentWorkingDirectory automatically grants read/write access to the session's working directory — you don't need to hardcode it in readwritePaths. sandboxMcpServers and sandboxLspServers extend the isolation boundary to MCP and LSP server processes as well, not just shell commands.

Within userPolicy, filesystem access is split across three explicit lists: readwritePaths, readonlyPaths, and deniedPaths. The network side has a coarser toggle (allowOutbound, allowLocalNetwork) plus per-host overrides via allowedHosts and blockedHosts. clearPolicyOnExit ensures the sandbox policy doesn't linger after the session ends.

Building the config inline

Externalising to a file is the pragmatic default: it lets you tweak isolation rules without recompiling. But there are scenarios where you want to construct the configuration dynamically — for example, scoping the filesystem allowlist to the current session's working directory:

var sandboxConfig = JsonSerializer.SerializeToElement<SandboxConfig>(new
{
    enabled = true,
    filesystem = new[]
    {
        new { path = workingDirectory, permission = "readWrite" },
        new { path = nugetPackagesPath, permission = "read" }
    },
    network = new[]
    {
        new { host = "api.nuget.org", access = "allow" }
    }
});

await Session.Rpc.Options.UpdateAsync(sandboxConfig: sandboxConfig);

JsonSerializer.SerializeToElement gives you a SandboxConfig object from an anonymous object without going via a string round-trip.

Updating the config mid-session

UpdateAsync can be called more than once. If your session has phases with different access requirements — say, a dependency resolution phase that needs NuGet access, followed by a code generation phase that shouldn't — you can tighten or loosen the configuration at each transition:

// Phase 1: dependency resolution — NuGet access open
await session.Rpc.Options.UpdateAsync(sandboxConfig: JsonElement.Parse("""
{
  "enabled": true,
  "addCurrentWorkingDirectory": true,
  "sandboxMcpServers": true,
  "sandboxLspServers": true,
  "userPolicy": {
    "filesystem": {
      "readwritePaths": [],
      "readonlyPaths": ["C:\\Users\\user\\.nuget\\packages"],
      "deniedPaths": [],
      "clearPolicyOnExit": true
    },
    "network": {
      "allowOutbound": true,
      "allowLocalNetwork": false,
      "allowedHosts": ["api.nuget.org"],
      "blockedHosts": []
    }
  }
}
"""));

await session.SendAndWaitAsync("Analyse the project's dependency tree");

// Phase 2: code generation — lock down network entirely
await session.Rpc.Options.UpdateAsync(sandboxConfig: JsonElement.Parse("""
{
  "enabled": true,
  "addCurrentWorkingDirectory": true,
  "sandboxMcpServers": true,
  "sandboxLspServers": true,
  "userPolicy": {
    "filesystem": {
      "readwritePaths": ["D:\\projects\\myapp\\src"],
      "readonlyPaths": [],
      "deniedPaths": [],
      "clearPolicyOnExit": true
    },
    "network": {
      "allowOutbound": false,
      "allowLocalNetwork": false,
      "allowedHosts": [],
      "blockedHosts": []
    }
  }
}
"""));

await session.SendAndWaitAsync("Refactor the data access layer");

This is where the SDK model becomes genuinely more powerful than the CLI's interactive /sandbox configuration: you can drive isolation policy from your application logic, not just set it once at session start.

OnPermissionRequest is still your first line

The sandbox constrains what approved tool calls can reach. OnPermissionRequest controls whether a tool call gets approved at all. They're doing different jobs, and you want both.

A sensible pattern is to log at the permission handler, allow what makes sense for the task, and rely on the sandbox to enforce the boundary underneath:

OnPermissionRequest = async (request, invocation) =>
{
    logger.LogInformation(
        "Permission request: {Kind} - {Description}",
        request.Kind,
        invocation.Description);

    // Only allow bash execution — file operations are fine,
    // but direct shell access gets logged and approved with full sandbox context
    if (request.Kind == "shell")
    {
        logger.LogWarning("Shell execution requested: {Command}", invocation.Description);
    }

    return PermissionDecision.ApproveOnce();
}

With the sandbox active, approving a shell command means it runs under MXC constraints — it can only reach what your sandbox config allows. The permission log still gives you an audit trail of what the agent attempted.

Read the whole story
alvinashcraft
29 seconds ago
reply
Pennsylvania, USA
Share this story
Delete

My Om Malik Story

1 Share
Read the whole story
alvinashcraft
43 seconds ago
reply
Pennsylvania, USA
Share this story
Delete

2.9.3

1 Share

Don't hard-fail the distro validation script when a download fails (#…

Read the whole story
alvinashcraft
54 seconds ago
reply
Pennsylvania, USA
Share this story
Delete

Trump admin bars Polestar from selling its new EVs in the US

1 Share
The Department of Commerce declined to give the Chinese-owned automaker a special authorization to keep selling EVs in the U.S.
Read the whole story
alvinashcraft
7 hours ago
reply
Pennsylvania, USA
Share this story
Delete
Next Page of Stories