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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,14 @@ it, so a toolchain bump leaves the old SDK on PATH until the session is
replaced, and a run against the wrong SDK announces nothing: it builds, it runs,
its tests pass.

Three things enforce it, none of which relies on anybody remembering:
Four things enforce it, none of which relies on anybody remembering:

| Where | What it refuses |
|---|---|
| `require_mise` in `tool/dev/_lib.sh` | no mise, no `mise.toml`, or a flutter that resolves outside mise's own installs — the last one is the dangerous case, because `mise exec` will happily forward to a system SDK |
| `tool/check/tooling.sh` | a bare toolchain command in the docs or CI; and, for every script in `tool/`, one that does not parse, is not executable, has no shebang, or reaches `flutter` / `dart` / `mise exec` without going through `pinned` |
| `tool/run.sh` | runs both before it starts anything (0.34 s) |
| `tool/internal/apply_sdk_patches.sh` | an SDK that is missing a patch this repo owns, or a patch that no longer applies to the pinned version — see [SDK patches](#sdk-patches) |
| `tool/run.sh` | runs them before it starts anything (0.34 s) |

```sh
tool/dev/analyze.sh
Expand All @@ -61,6 +62,29 @@ tool/dev/analyze.sh
the CI gates, `release/` versioning and notes, `gen/` asset and code
generators, `internal/` pieces other scripts call and nobody runs by hand.

### SDK patches

**The pinned Flutter SDK is not stock.** `tool/patches/*.patch` is applied to it
by `require_mise`, so every script here — and every CI job, which reaches the
toolchain the same way — runs a patched framework. Each patch names its upstream
issue in its own header; read that before touching one.

This is the one place the repo deliberately does what the rest of this section
exists to prevent, so it is held to the same standard. The patch lives in the
repo rather than in an install, it is applied at the single gate every toolchain
call already passes, and it says so the first time it changes anything. A patch
applied on one laptop and forgotten on a runner would be exactly the
works-here-fails-there the pin was bought to stop.

Two things follow, and both matter:

- **A patch that no longer applies is a hard stop**, not a skip. That is almost
always a Flutter bump. Somebody has to decide whether upstream fixed the bug —
delete the patch — or whether it has to be re-cut against the new source.
- **Every patch carries a test that fails without it.** The test, not a check
script, is what proves the patch is really in; it is also what stays behind
once the patch is deleted, to say the upstream fix is real.

## Running

```sh
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,14 @@ class WeatherSkyPainter extends CustomPainter {
..setFloat(40, hazeSky.r)
..setFloat(41, hazeSky.g)
..setFloat(42, hazeSky.b)
..setImageSampler(1, skyColumn);
..setImageSampler(1, skyColumn)
// Slot 0 is per-sprite and the loop below overwrites it — but
// `Paint.shader=` refuses a shader with any sampler still unassigned,
// and on the first frame after `fragmentShader()` slot 0 has never been
// written. Seeding it here is what makes that frame draw at all: the
// throw propagates out of `paint()`, so it took the whole sky down with
// it, not just the clouds. `cloudSprites` is non-empty by the guard.
..setImageSampler(0, cloudSprites.first);

final paint = Paint()..shader = shader;
for (final p in placed) {
Expand Down
99 changes: 99 additions & 0 deletions test/shared/semantics_overlay_portal_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// The regression guard for `tool/patches/0001-semantics-attach-stale-children.patch`.
//
// Nothing in DPIP is under test here — this is the framework, reproduced at the
// smallest shape that reaches the bug. It lives in the suite because the patch
// has to be kept honest by something that runs on every machine and in CI, and
// a test that fails on an unpatched SDK does that without a check script: the
// patch is applied by `require_mise`, so a run that skipped it fails here.
//
// When upstream fixes flutter#189902 and mise.toml moves to a version that
// carries the fix, the patch stops applying (and says so, loudly) — this test
// should then pass on its own. Keep it. It is the thing that says the fix is
// really in.
//
// Shape credit: the reduction in the upstream issue. Each of the three oddities
// below is load-bearing; drop any one and the assert does not fire.
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

/// Two tooltips, one of which is rigged to reach the bug.
///
/// Every [Tooltip] is an [OverlayPortal] — `RawTooltip` is built on
/// `OverlayPortal.overlayChildLayoutBuilder` — which is why an app that never
/// names `OverlayPortal` (DPIP does not) still hits an OverlayPortal defect.
class _TwoTooltips extends StatelessWidget {
const _TwoTooltips();

/// A border, so the two rows do not collapse into one render object and the
/// semantics subtree has an interior node to lose track of.
Widget _decorate(Widget child) => DecoratedBox(
decoration: BoxDecoration(border: Border.all()),
child: child,
);

Widget _tooltip(String label) => Tooltip(
message: '$label tooltip',
// `explicitChildNodes` is what stops the child folding into the tooltip's
// own node, so there is a separate SemanticsNode available to be stolen.
child: Semantics(explicitChildNodes: true, child: Text('$label text')),
);

@override
Widget build(BuildContext context) {
Widget bad = _tooltip('bad');
// The second Overlay is what lets one node end up parented under a subtree
// that a later pass rebuilds from the bottom up — the "stealing".
bad = Overlay.wrap(child: ExcludeSemantics(child: bad));
// A tight box: it stops the enclosing node being rebuilt on the pass that
// would otherwise refresh the stale `_children` list and hide the bug.
bad = SizedBox(width: 200, height: 100, child: bad);

return Dialog(
child: Column(
spacing: 20,
children: [_decorate(_tooltip('good')), _decorate(bad)],
),
);
}
}

void main() {
testWidgets('a tooltip shown and dismissed twice keeps the semantics tree '
'consistent (flutter#189902)', (tester) async {
// Semantics is only built when something asks for it — a screen reader on
// a device, this handle in a test. Without it `flushSemantics` does no work
// and the bug cannot be reached.
final handle = tester.ensureSemantics();

await tester.pumpWidget(
const MaterialApp(home: Scaffold(body: _TwoTooltips())),
);

final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer(location: Offset.zero);
addTearDown(gesture.removePointer);

final target = tester.getCenter(find.text('bad text'));
const away = Offset(5, 5);

// Twice is the whole test. The first show/dismiss leaves a SemanticsNode
// with `attached == true` and `parent == null`; the second dismiss walks
// into it and trips `assert(!child.attached)` in `_replaceChildren`.
for (var pass = 1; pass <= 2; pass++) {
await gesture.moveTo(target);
await tester.pump();
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();

await gesture.moveTo(away);
await tester.pump();
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
}

// Disposed in the body, not `addTearDown`: the binding's own end-of-test
// check for leaked handles runs before tear-downs do.
handle.dispose();
});
}
5 changes: 5 additions & 0 deletions tool/dev/_lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,11 @@ EOF
fi

export DPIP_MISE_CHECKED=1

# The pinned SDK carries repo-owned patches (tool/patches). Applied here, at
# the one gate every toolchain call already passes through, so no script and
# no machine can be the one that forgot. Silent unless it changes something.
"$root/tool/internal/apply_sdk_patches.sh"
}

# `flutter`/`dart`/anything else, on the pinned toolchain.
Expand Down
69 changes: 69 additions & 0 deletions tool/internal/apply_sdk_patches.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
# Applies tool/patches/*.patch to the pinned Flutter SDK.
#
# Called from `require_mise` in tool/dev/_lib.sh, so every script that reaches
# the toolchain has already run it. Nobody runs this by hand.
#
# Patching the SDK is the thing this repo is otherwise most careful *not* to
# do — AGENTS.md → Toolchain exists because a build off the wrong SDK announces
# nothing. A patched SDK is a wrong SDK by that definition, so the same rule
# applies to the patch: it lives in the repo, it is applied by a script every
# entry point calls, and it is never left to a machine to have remembered. The
# alternative is one laptop where the bug is fixed and a CI runner where it is
# not, which is exactly the failure the pin was bought to prevent.
#
# Each patch is one of three states, and only one of them is silent:
#
# applies cleanly → apply it, and say so
# already applied → nothing to do, say nothing
# neither → stop. The SDK moved out from under the patch, which
# usually means a Flutter bump. Somebody has to decide
# whether upstream fixed it; a skipped patch would let
# the bug back in without a word.
set -euo pipefail

root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
patch_dir="$root/tool/patches"

[[ -d $patch_dir ]] || exit 0
shopt -s nullglob
patches=("$patch_dir"/*.patch)
((${#patches[@]})) || exit 0

# `mise where`, not the resolved binary's parent: this wants the SDK root that
# `packages/flutter/...` hangs off, and require_mise has already established
# that mise owns it.
sdk="$(cd "$root" && mise where flutter 2>/dev/null || true)"
if [[ -z $sdk || ! -d $sdk ]]; then
printf '\n Cannot locate the pinned Flutter SDK to patch it.\n' >&2
printf ' Run `mise install` from %s.\n\n' "$root" >&2
exit 1
fi

for p in "${patches[@]}"; do
name="$(basename "$p")"

# --forward alone is not enough to tell "applies" from "already applied":
# both refuse, with different messages. A reverse dry-run answers it
# directly — a patch that can be undone is a patch that is already in.
if patch -p1 -d "$sdk" --forward --dry-run --silent <"$p" >/dev/null 2>&1; then
Comment on lines +43 to +49

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

performance · medium
由於 apply_sdk_patches.sh 會在每次透過 require_mise 初始化工具鏈時執行,且針對每個補丁都會進行兩次 patch --dry-run 檢查,隨著補丁數量增加,可能會導致開發者指令的啟動延遲。建議可以透過檢查 tool/patches/ 目錄的內容雜湊(hash)來實作快取機制,若雜湊未變動則跳過檢查。

patch -p1 -d "$sdk" --forward --silent <"$p"
printf ' \033[33m●\033[0m patched SDK: %s\n' "$name" >&2
elif patch -p1 -d "$sdk" --reverse --dry-run --silent <"$p" >/dev/null 2>&1; then
Comment on lines +49 to +52

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · medium
腳本使用了 GNU patch 特有的 --reverse 參數。在 macOS 等預設使用 BSD patch 的環境下,該參數可能不存在,導致工具鏈失效。建議在執行前檢查 patch 版本,或在文件說明中明確要求使用 GNU patch

: # already applied
else
cat >&2 <<PATCHFAIL

tool/patches/$name no longer applies to the pinned SDK.

SDK: $sdk

The patch is pinned to one Flutter version and the pin has moved. Read the
header of the patch — it names the upstream issue. If that issue is fixed in
this version, delete the patch; its regression test stays and should pass on
its own. If it is not fixed, re-cut the patch against the new source.

PATCHFAIL
exit 1
fi
done
49 changes: 49 additions & 0 deletions tool/patches/0001-semantics-attach-stale-children.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
Guard SemanticsNode.attach() against a stale child list.

flutter#189902 https://github.com/flutter/flutter/issues/189902 (open)
cut against flutter 3.47.0

`detach()` documents that `_children` is allowed to be stale — it may hold
nodes that have since been re-parented — and skips those:

if (child.parent == this) { child.detach(); }

`attach()` walks the same list with no such guard, so it re-attaches a node
that now belongs to somebody else. That produces a node with `attached == true`
and `parent == null`, and the next `_replaceChildren` trips

'package:flutter/src/semantics/semantics.dart': line 3013
Failed assertion: '!child.attached': is not true.

Every `Tooltip` is an `OverlayPortal` (`RawTooltip` is built on
`OverlayPortal.overlayChildLayoutBuilder`), so showing and dismissing one
twice is enough to reach it — which is what DPIP hit. Related, all open and
all traced to `fccfa978a97` "Reland: Refactor OverlayPortal semantics", in
stable since 3.41.0: flutter#187198, flutter#182604.

The assert is debug-only, but the inconsistent parent/child pointers it
catches are real in release too, where they silently mis-shape the tree a
screen reader walks.

`test/shared/semantics_overlay_portal_test.dart` fails without this patch.
That test — not a check script — is what keeps the patch honest: when upstream
lands a fix and mise.toml moves off 3.47.0, this patch stops applying and
tool/internal/apply_sdk_patches.sh fails loudly. Delete it then, and the test
should stay green on its own.

--- a/packages/flutter/lib/src/semantics/semantics.dart 2026-09-01 13:49:30
+++ b/packages/flutter/lib/src/semantics/semantics.dart 2026-09-01 13:49:30
@@ -3263,7 +3263,12 @@
}
if (_children != null) {
for (final SemanticsNode child in _children!) {
- child.attach(owner);
+ // The list of children may be stale and may contain nodes that have
+ // been assigned to a different parent. Mirrors the guard `detach()`
+ // already has. See https://github.com/flutter/flutter/issues/189902
+ if (child.parent == this) {
+ child.attach(owner);
+ }
}
}
}
Loading