Skip to content

Commit f9f0833

Browse files
committed
fix(diagnostics): Kill(entireProcessTree) walks the tree instead of one process group (#2031)
Kill(true) was ::killpg(pid, SIGKILL), which reaches the child's process GROUP -- and a descendant that called setsid() has left it. Measured by #2028: the setsid grandchild was ALIVE afterwards and the probe had to kill it itself. Landed under SA-5. THE GATE WAS "/rv ABSENT", AND THE REFERENCE CORRECTS THE PROPOSED DESIGN THREE TIMES. #2031 was blocked pending approval of plan 14.3's option A -- "ppid from /proc/*/stat, breadth-first from the child pid, SIGKILL each". .NET's Process.KillTree (Process.Unix.cs:97-137) differs where it counts: 1. SIGSTOP COMES FIRST, before the children are enumerated. .NET's comment says why, verbatim: "Stop the process, so it won't start additional children. This is best effort: kill can return before the process is stopped." Option A enumerated and THEN killed, so a process that forked between those two steps left a survivor -- the very defect this ticket exists to remove, reintroduced one level down. 2. THERE IS A SELF-GUARD. Kill(bool) refuses outright when the tree contains the calling process (Process.NonUap.cs:25-26), because attempting it kills the caller. 3. ESRCH is ignored and other failures are collected, then reported together. THE GUARD'S PLACEMENT IS LOAD-BEARING. It runs BEFORE the current-process no-op and before the exit check, because that is where .NET has it. A first cut put it after, which made GetCurrentProcess().Kill(true) a silent no-op where .NET throws AND left the guard unreachable through any ordinary Process object; the test caught it. Kill(false) is deliberately untouched -- .NET delegates it straight to Kill() with no tree logic (Process.NonUap.cs:17-20). Its own pre-existing no-op-on-the-current-process divergence is not bundled here. Linux specificity: the walk reads /proc/<n>/stat, and so does .NET's (GetChildProcesses goes through Process.GetProcesses(), a /proc reader on Unix). Without /proc the walk degrades to killing the direct child. The stat parse scans to the LAST ')' rather than splitting on whitespace, because field 2 is the executable name unescaped and may contain spaces and ')'. Mutations: 6, 5 caught. - The "no recursion" mutation went UNCAUGHT at first, for an instructive reason: setsid() changes the SESSION, not the parent, so the original pin's "grandchild" is still an IMMEDIATE child of the shell and a one-level walk kills it. Fix2031_TheWalkIsTransitiveNotOneLevel goes three levels deep. - The SIGSTOP mutation is NOT caught and cannot be caught deterministically: it removes a race WINDOW -- the microseconds between ChildrenOf and the SIGKILL -- and the only test that would observe it forks in a tight loop and hopes to land inside that window, i.e. a flaky test. This session has repaired two of those (#2352, #2105) precisely because an intermittently green gate is not evidence. The SIGSTOP is there because .NET has it and states its purpose. The note is at the site. Gate: 17,391 run, 17,391 passed, 0 failed, 0 skipped across 38 executables (+3, in SharpRuntimeTests_Diagnostics, 229 -> 232). sizeof(Process) unchanged (one unique_ptr). Built in build/ with --parallel 2. Downstream: zero Process code sites in cna and mobile-eggbert. docs/Migration-ProcessKillEntireTree.md
1 parent 6b82794 commit f9f0833

5 files changed

Lines changed: 303 additions & 22 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
<!-- SPDX-License-Identifier: MIT -->
2+
<!-- Copyright (c) Robert Vokac and contributors -->
3+
4+
# Migration — `Kill(entireProcessTree)` walks the tree instead of signalling a process group (ticket #2031)
5+
6+
*2026-08-19.* `Kill(true)` was `::killpg(pid, SIGKILL)`, which reaches the child's process
7+
**group** — and a descendant that called `setsid()` has left it. Measured by #2028: the setsid
8+
grandchild was **alive** afterwards and the probe had to kill it itself.
9+
10+
Landed under `docs/StandingApprovals.md` **SA-5**. No signature, layout or vtable change;
11+
`sizeof(Process)` is one `unique_ptr`, unchanged.
12+
13+
---
14+
15+
## 1. The gate was "/rv absent", and the reference corrects the proposed design three times
16+
17+
#2031 was blocked pending approval of *"the /proc descendant walk"* described in its plan §14.3 —
18+
*"ppid from `/proc/*/stat`, breadth-first from the child pid, SIGKILL each"* — on the ground that
19+
the reference could not be read. It can, and .NET's `Process.KillTree` (`Process.Unix.cs:97-137`)
20+
differs in three ways that matter:
21+
22+
1. **`SIGSTOP` comes first**, before the children are enumerated. .NET's comment says why,
23+
verbatim: *"Stop the process, so it won't start additional children. This is best effort: kill
24+
can return before the process is stopped."* Option A enumerated and **then** killed, so a
25+
process that forked between those two steps left a survivor — the very defect this ticket
26+
exists to remove, reintroduced one level down.
27+
2. **There is a self-guard.** `Kill(bool)` refuses outright when the tree contains the calling
28+
process (`Process.NonUap.cs:25-26`, `InvalidOperationException`), because attempting it kills
29+
the caller. Option A had no such check.
30+
3. **`ESRCH` is ignored and other failures are collected**, then reported together, because a
31+
process may legitimately exit between any two steps of the walk.
32+
33+
The recursion is depth-first in .NET rather than breadth-first; for killing, the two are
34+
equivalent, and the SIGSTOP ordering is not.
35+
36+
## 2. What changed
37+
38+
| Call | Was | Is |
39+
|---|---|---|
40+
| `Kill(true)`, setsid descendant | survived | killed |
41+
| `Kill(true)`, descendant three levels deep | survived | killed |
42+
| `Kill(true)` on the current process | silent no-op | `InvalidOperationException` |
43+
| `Kill(true)` with a partial failure | silent | `InvalidOperationException` naming each pid |
44+
| `Kill(false)` | `::kill(pid, SIGKILL)` | **unchanged** |
45+
| `Kill(false)` on the current process | silent no-op | **unchanged** (§3) |
46+
47+
The self-guard runs **before** the current-process no-op and before the exit check, because that
48+
is where .NET has it. Placing it after would make `GetCurrentProcess().Kill(true)` a silent no-op
49+
where .NET throws, **and would leave the guard unreachable through any ordinary `Process`
50+
object** — a first cut did exactly that and the test caught it.
51+
52+
## 3. One divergence deliberately not bundled
53+
54+
`Kill(false)` on the current process is a silent no-op here and terminates the process in .NET.
55+
That is pre-existing, is on the `Kill()` overload rather than the tree one, and .NET reaches it by
56+
delegating `Kill(false)` straight to `Kill()` (`Process.NonUap.cs:17-20`) with no tree logic at
57+
all. Bundling it would be exactly the mixing this repository's records complain about elsewhere.
58+
59+
## 4. Linux specificity
60+
61+
The walk reads `/proc/<n>/stat` for the parent pid, so it is Linux-bound — and so is .NET's,
62+
whose `GetChildProcesses` goes through `Process.GetProcesses()`, itself a `/proc` reader on Unix.
63+
On a POSIX host without `/proc`, `opendir("/proc")` fails, `ChildrenOf` returns empty, and
64+
`Kill(true)` degrades to killing the direct child only. `System::Diagnostics::Process` is already
65+
documented POSIX-only and is exercised on Linux alone.
66+
67+
The `/proc/<n>/stat` parse scans to the **last** `)` rather than splitting on whitespace, because
68+
field 2 is the executable name unescaped and may contain spaces and `)`.
69+
70+
## 5. Evidence
71+
72+
Six mutations, five caught:
73+
74+
| Mutation | Result |
75+
|---|---|
76+
| back to `killpg` | caught |
77+
| the walk does not recurse (immediate children only) | caught — **after a case was added** |
78+
| `Kill(false)` also walks the tree | caught |
79+
| the self-guard never fires | caught |
80+
| the self-guard moved after the current-process no-op | caught |
81+
| **no `SIGSTOP` before enumerating** | **NOT caught — see below** |
82+
83+
**The recursion mutation went uncaught at first**, and the reason is worth keeping: `setsid()`
84+
changes the *session*, not the parent, so the original pin's "grandchild" is still an **immediate
85+
child** of the shell and a one-level walk kills it. `Fix2031_TheWalkIsTransitiveNotOneLevel` goes
86+
three levels deep so the mutation survives it.
87+
88+
**The `SIGSTOP` mutation is not caught and cannot be, deterministically.** What it removes is a
89+
*race window* — the microseconds between `ChildrenOf` and the `SIGKILL`, during which the target
90+
could fork a child that is neither enumerated nor killed. A test could only observe it by forking
91+
in a tight loop and hoping to land inside that window, which is a **flaky test**; this repository
92+
has repaired two of those this session (#2352, #2105) on the ground that an intermittently green
93+
gate is not evidence. The `SIGSTOP` is there because .NET has it and states its purpose, not
94+
because a test distinguishes it. The note is at the site.
95+
96+
## 6. Downstream, measured
97+
98+
`cna` and `mobile-eggbert` reference `System::Diagnostics::Process` in **zero** code sites.
99+
Neither was modified.

modules/diagnostics/src/System/Diagnostics/Process.cpp

Lines changed: 128 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
#include <chrono>
1414
#include <cstdlib>
15+
#include <fstream>
1516
#include <sstream>
1617

1718
#if !defined(_WIN32) && !defined(__EMSCRIPTEN__)
@@ -22,6 +23,7 @@
2223
# include <fcntl.h>
2324
# include <cerrno>
2425
# include <cstring>
26+
# include <dirent.h>
2527
# if defined(__APPLE__)
2628
# include <crt_externs.h>
2729
# endif
@@ -503,16 +505,139 @@ bool Process::Start() {
503505
#endif
504506
}
505507

508+
509+
#if defined(SHARP_RUNTIME_PROCESS_POSIX)
510+
namespace {
511+
512+
/**
513+
* @brief The immediate children of @p pid, read from `/proc/<n>/stat`'s fourth field.
514+
*
515+
* Ticket #2031. Linux-specific by construction: `/proc` is where the parent link lives, and
516+
* .NET's own `GetChildProcesses` is equally platform-bound (it goes through
517+
* `Process.GetProcesses()`, whose Unix implementation reads `/proc`).
518+
*
519+
* `stat`'s second field is the executable name **in parentheses and unescaped**, so it may
520+
* contain spaces and even `)`. Splitting on whitespace is therefore wrong; the parse scans to
521+
* the LAST `)` and takes the fields after it, which is what every correct /proc reader does.
522+
*/
523+
std::vector<pid_t> ChildrenOf(pid_t pid) {
524+
std::vector<pid_t> children;
525+
DIR* proc = ::opendir("/proc");
526+
if (proc == nullptr) return children;
527+
while (dirent* entry = ::readdir(proc)) {
528+
char* end = nullptr;
529+
const long candidate = std::strtol(entry->d_name, &end, 10);
530+
if (end == entry->d_name || *end != '\0' || candidate <= 0) continue;
531+
532+
std::string path = std::string("/proc/") + entry->d_name + "/stat";
533+
std::ifstream stat(path);
534+
if (!stat) continue;
535+
std::string line;
536+
if (!std::getline(stat, line)) continue;
537+
const size_t close = line.rfind(')');
538+
if (close == std::string::npos) continue;
539+
// After ")" come: state, ppid, ...
540+
std::istringstream rest(line.substr(close + 1));
541+
std::string state;
542+
long ppid = 0;
543+
if (!(rest >> state >> ppid)) continue;
544+
if (static_cast<pid_t>(ppid) == pid) children.push_back(static_cast<pid_t>(candidate));
545+
}
546+
::closedir(proc);
547+
return children;
548+
}
549+
550+
/** @brief True when @p pid is @p ancestor or any descendant of it. */
551+
bool IsSelfOrDescendantOf(pid_t ancestor, pid_t pid) {
552+
if (ancestor == pid) return true;
553+
std::vector<pid_t> frontier{ancestor};
554+
for (size_t i = 0; i < frontier.size(); ++i) {
555+
for (pid_t child : ChildrenOf(frontier[i])) {
556+
if (child == pid) return true;
557+
frontier.push_back(child);
558+
}
559+
}
560+
return false;
561+
}
562+
563+
/**
564+
* @brief .NET's `Process.KillTree` (`Process.Unix.cs:97-137`), transcribed.
565+
*
566+
* The ORDER is the substance, and it is what #2031's own proposed option A was missing.
567+
* .NET stops the process **before** enumerating its children -- the comment says why,
568+
* verbatim: *"Stop the process, so it won't start additional children. This is best effort:
569+
* kill can return before the process is stopped."* Option A enumerated and then killed, so a
570+
* process that forked between those two steps left a survivor: precisely the defect the
571+
* ticket exists to remove, reintroduced one level down.
572+
*
573+
* `ESRCH` is ignored throughout, because a process may legitimately exit between any two
574+
* steps of the walk; every other errno is collected and reported together.
575+
*
576+
* HONEST NOTE ON THE EVIDENCE: a mutation removing the SIGSTOP is NOT caught, and it cannot
577+
* be caught deterministically. What it removes is a RACE WINDOW -- the microseconds between
578+
* `ChildrenOf` and the `SIGKILL`, during which the target could fork a child that is neither
579+
* enumerated nor killed. A test could only observe it by forking in a tight loop and hoping
580+
* to land inside that window, which is a FLAKY test; this repository has repaired two of
581+
* those this session (#2352, #2105) on the ground that an intermittently green gate is not
582+
* evidence. The SIGSTOP is here because .NET has it and states its purpose in a comment, not
583+
* because a test distinguishes it.
584+
*/
585+
void KillTree(pid_t pid, std::vector<std::string>& failures) {
586+
if (::kill(pid, SIGSTOP) != 0) {
587+
if (errno != ESRCH)
588+
failures.push_back("SIGSTOP to pid " + std::to_string(pid) + ": " + std::strerror(errno));
589+
return;
590+
}
591+
const std::vector<pid_t> children = ChildrenOf(pid);
592+
if (::kill(pid, SIGKILL) != 0 && errno != ESRCH)
593+
failures.push_back("SIGKILL to pid " + std::to_string(pid) + ": " + std::strerror(errno));
594+
for (pid_t child : children) KillTree(child, failures);
595+
}
596+
597+
} // namespace
598+
#endif
599+
506600
void Process::Kill() { Kill(false); }
507601

508602
void Process::Kill(bool entireProcessTree) {
509603
(void)entireProcessTree; // unused on non-POSIX platforms, where this throws below
510604
#if defined(SHARP_RUNTIME_PROCESS_POSIX)
511-
if (!impl_->started || impl_->isCurrentProcess) return;
605+
if (!impl_->started) return;
606+
// #2031: the self-guard runs FIRST, before the current-process no-op and before the exit
607+
// check, because that is where .NET has it -- `Kill(bool)` refuses immediately, ahead of
608+
// `KillTree` (`Process.NonUap.cs:23-28`). Placing it after the `isCurrentProcess` early
609+
// return would make `GetCurrentProcess().Kill(true)` a silent no-op where .NET throws, and
610+
// would leave the guard unreachable through any ordinary Process object.
611+
//
612+
// `Kill(false)` is deliberately untouched: .NET delegates it straight to `Kill()`
613+
// (`Process.NonUap.cs:17-20`), and this port's own no-op-on-the-current-process behaviour
614+
// for that overload is a separate, pre-existing divergence that is not bundled here.
615+
if (entireProcessTree
616+
&& (impl_->isCurrentProcess || IsSelfOrDescendantOf(impl_->pid, ::getpid())))
617+
throw System::InvalidOperationException(
618+
"Cannot be used to terminate a process tree containing the calling process.");
619+
if (impl_->isCurrentProcess) return;
512620
reapIfNeeded(*impl_);
513621
if (impl_->hasExited) return;
514-
if (entireProcessTree) ::killpg(impl_->pid, SIGKILL);
515-
else ::kill(impl_->pid, SIGKILL);
622+
if (!entireProcessTree) {
623+
::kill(impl_->pid, SIGKILL);
624+
return;
625+
}
626+
// #2031 (SR-AUD-273, cause D-E). This was `::killpg(pid, SIGKILL)`, which reaches the
627+
// child's process GROUP -- and a descendant that called setsid() has left it. Measured: the
628+
// setsid grandchild was ALIVE afterwards and the probe had to kill it itself.
629+
//
630+
// .NET walks the tree instead (`Process.Unix.cs:97-137`), and the SELF-GUARD below is
631+
// .NET's too (`Process.NonUap.cs:25-26`): killing a tree that contains the calling process
632+
// would kill the caller, so it is refused rather than attempted.
633+
std::vector<std::string> failures;
634+
KillTree(impl_->pid, failures);
635+
if (!failures.empty()) {
636+
std::string detail;
637+
for (const auto& f : failures) { if (!detail.empty()) detail += "; "; detail += f; }
638+
throw System::InvalidOperationException(
639+
"Not all processes in process tree could be terminated. (" + detail + ")");
640+
}
516641
#else
517642
throw System::PlatformNotSupportedException("System::Diagnostics::Process is only supported on POSIX platforms.");
518643
#endif

modules/diagnostics/tests/System/Diagnostics/ProcessGatedBehaviourPinTests.cpp

Lines changed: 75 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848

4949
#include "System/Diagnostics/Process.hpp"
5050
#include "System/Diagnostics/ProcessStartInfo.hpp"
51+
#include "System/InvalidOperationException.hpp"
5152

5253
using System::Diagnostics::Process;
5354
using System::Diagnostics::ProcessStartInfo;
@@ -278,22 +279,20 @@ TEST(ProcessGatedBehaviourPinTests, Fix2030_ConcurrentReadersSeeAConsistentBuffe
278279
// #2031 (SR-AUD-273) -- Kill(true) signals one process group.
279280
// ===========================================================================
280281

281-
// PIN FOR BLOCKED TICKET #2031. Kill(entireProcessTree = true) is ::killpg, which reaches only
282-
// the child's process group; a descendant that called setsid() has left that group and
283-
// SURVIVES, despite the parameter's full-process-tree contract. Plan section 14.3 replaces the
284-
// killpg with a transitive /proc descendant walk on Linux, after which the survivor is killed
285-
// and this test fails.
282+
// INVERTED by #2031. Kill(entireProcessTree = true) was ::killpg, which reaches only the child's
283+
// process GROUP -- and a descendant that called setsid() has left it, so it SURVIVED despite the
284+
// parameter's full-process-tree contract. It is now .NET's recursive /proc descendant walk.
286285
//
287-
// The witness is a file rather than a pid: the surviving grandchild writes it after a delay,
288-
// so "the file exists" means "the grandchild was still alive after Kill(true)" without the
289-
// test having to track a pid through two setsid-ing layers.
290-
TEST(ProcessGatedBehaviourPinTests, Pin2031_SetsidDescendantSurvivesKillEntireProcessTree) {
286+
// The witness is a file rather than a pid: the grandchild writes it after a delay, so "the file
287+
// was never written" means "the grandchild was killed with the tree" without the test having to
288+
// track a pid through two setsid-ing layers.
289+
TEST(ProcessGatedBehaviourPinTests, Fix2031_ASetsidDescendantIsKilledWithTheTree) {
291290
const std::string witness = makeUniqueWitnessPath();
292291
ASSERT_FALSE(witness.empty());
293292

294-
// The direct child stays alive (exec sleep 5) so that Kill() does not short-circuit on an
295-
// already-exited child, and spawns a grandchild in a NEW SESSION that writes the witness
296-
// one second later.
293+
// The direct child stays alive (exec sleep 5) so Kill() does not short-circuit on an
294+
// already-exited child, and spawns a grandchild in a NEW SESSION that writes the witness one
295+
// second later.
297296
const std::string script =
298297
"setsid /bin/sh -c 'sleep 1; printf alive > " + witness + "' >/dev/null 2>&1; "
299298
"exec sleep 5";
@@ -305,21 +304,79 @@ TEST(ProcessGatedBehaviourPinTests, Pin2031_SetsidDescendantSurvivesKillEntirePr
305304
process.Kill(true);
306305
ASSERT_TRUE(process.WaitForExit(5000)) << "the direct child was not killed";
307306

308-
// If the grandchild had been killed with the tree, it could never write the witness.
307+
// Wait past the grandchild's own delay. If it had survived it would have written by now.
308+
std::this_thread::sleep_for(std::chrono::milliseconds(2500));
309+
EXPECT_FALSE(witnessWasWritten(witness))
310+
<< "the setsid descendant survived Kill(true) -- the walk did not reach it";
311+
312+
::unlink(witness.c_str());
313+
}
314+
315+
// DEPTH. The case above is only two levels deep -- setsid changes the SESSION, not the parent,
316+
// so the "grandchild" is still an immediate child of the shell and a one-level walk kills it.
317+
// This one is three levels deep, so a mutation that kills only immediate children survives it.
318+
TEST(ProcessGatedBehaviourPinTests, Fix2031_TheWalkIsTransitiveNotOneLevel) {
319+
const std::string witness = makeUniqueWitnessPath();
320+
ASSERT_FALSE(witness.empty());
321+
322+
// shell -> setsid shell -> shell -> the writer. The witness is written by a
323+
// great-grandchild, which only a transitive walk reaches.
324+
const std::string script =
325+
"setsid /bin/sh -c '/bin/sh -c \"sleep 1; printf alive > " + witness + "\" & sleep 5' "
326+
">/dev/null 2>&1; exec sleep 5";
327+
328+
Process process = Process::Start(shellStartInfo(script));
329+
std::this_thread::sleep_for(std::chrono::milliseconds(300));
330+
process.Kill(true);
331+
ASSERT_TRUE(process.WaitForExit(5000));
332+
333+
std::this_thread::sleep_for(std::chrono::milliseconds(2500));
334+
EXPECT_FALSE(witnessWasWritten(witness))
335+
<< "a great-grandchild survived Kill(true) -- the walk stopped short";
336+
::unlink(witness.c_str());
337+
}
338+
339+
// THE CONTROL: Kill(false) must NOT acquire the tree behaviour. .NET's Kill(bool) delegates
340+
// straight to Kill() for false (Process.NonUap.cs:17-20), so a mutation that routes both through
341+
// the walk is caught here rather than passing as an improvement.
342+
TEST(ProcessGatedBehaviourPinTests, Fix2031_KillFalseStillLeavesTheDescendantAlone) {
343+
const std::string witness = makeUniqueWitnessPath();
344+
ASSERT_FALSE(witness.empty());
345+
const std::string script =
346+
"setsid /bin/sh -c 'sleep 1; printf alive > " + witness + "' >/dev/null 2>&1; "
347+
"exec sleep 5";
348+
349+
Process process = Process::Start(shellStartInfo(script));
350+
std::this_thread::sleep_for(std::chrono::milliseconds(300));
351+
process.Kill(false);
352+
ASSERT_TRUE(process.WaitForExit(5000));
353+
309354
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5);
310355
bool written = false;
311356
while (!written && std::chrono::steady_clock::now() < deadline) {
312357
written = witnessWasWritten(witness);
313358
if (!written) std::this_thread::sleep_for(std::chrono::milliseconds(50));
314359
}
315-
316-
EXPECT_TRUE(written)
317-
<< "the setsid descendant did NOT survive Kill(true) -- #2031 appears to have landed; "
318-
"retire this pin with it";
319-
360+
EXPECT_TRUE(written) << "Kill(false) killed a descendant it was never asked to touch";
320361
::unlink(witness.c_str());
321362
}
322363

364+
// .NET refuses to kill a tree containing the caller (Process.NonUap.cs:25-26) rather than
365+
// attempting it, because attempting it kills the caller. The current process is its own
366+
// ancestor, so a Process object naming it must be refused -- and the message is .NET's,
367+
// transcribed from Strings.resx:342-344.
368+
TEST(ProcessGatedBehaviourPinTests, Fix2031_ATreeContainingTheCallerIsRefused) {
369+
Process self = Process::GetCurrentProcess();
370+
try {
371+
self.Kill(true);
372+
ADD_FAILURE() << "Kill(true) on the calling process was attempted";
373+
} catch (const System::InvalidOperationException& e) {
374+
EXPECT_NE(std::string(e.what()).find(
375+
"Cannot be used to terminate a process tree containing the calling process."),
376+
std::string::npos) << e.what();
377+
}
378+
}
379+
323380
// ===========================================================================
324381
// Layout pins (plan section 10) -- these are not gated, they are ABI tripwires.
325382
// ===========================================================================

plan.sqlite3

0 Bytes
Binary file not shown.

0 commit comments

Comments
 (0)