|
1 | 1 | package fixture.compose |
2 | 2 |
|
3 | 3 | import androidx.compose.runtime.Composable |
4 | | -import androidx.compose.runtime.RememberObserver |
5 | | -import androidx.compose.runtime.currentComposer |
6 | 4 | import androidx.compose.runtime.remember |
7 | 5 | import androidx.compose.ui.Modifier |
| 6 | +import python.multiplatform.compose.PythonCallableArena |
| 7 | +import python.multiplatform.compose.withPythonComposer |
8 | 8 | import python.multiplatform.ffi.Python3 |
9 | | -import python.multiplatform.ffi.pythonx.PythonCallableScope |
10 | 9 | import python.multiplatform.ffi.pythonx.PythonCallables |
11 | | -import python.multiplatform.reflection.HandleTable |
12 | 10 |
|
13 | 11 | /** |
14 | 12 | * **The one hand-written `@Composable` in the whole design**, and the only thing Python cannot do |
15 | 13 | * for itself. |
16 | 14 | * |
| 15 | + * ### What moved out of here |
| 16 | + * |
| 17 | + * The composer plumbing and the callable holder are no longer this fixture's: they are |
| 18 | + * `python-multiplatform-compose`'s `withPythonComposer` and `PythonCallableArena`, which the host |
| 19 | + * entry point `PythonContent` is built on too. What stays is executing a *source string* per pass, |
| 20 | + * which is a test harness rather than an entry point -- a host draws a Python-declared root with |
| 21 | + * `PythonContent` (`PythonContentRenderTest`). |
| 22 | + * |
17 | 23 | * ### Why exactly one, and not one per component |
18 | 24 | * |
19 | 25 | * The 2024 `pythonx-compose` wrote a Kotlin wrapper per widget -- 37 files, 28 of them empty -- |
@@ -51,98 +57,63 @@ fun PythonComposition(source: String) { |
51 | 57 | // `remember`, and this is the only reason the entry point needs one. A Python `content=lambda:` |
52 | 58 | // has to outlive the call that passed it -- Compose stores it in the slot table -- so something |
53 | 59 | // must hold a Python reference for it, and the only thing whose lifetime *is* the composition's |
54 | | - // is a remembered value. See [PythonCallableArena]. |
55 | | - val arena = remember { PythonCallableArena() } |
56 | | - val composer = currentComposer |
57 | | - val reference = HandleTable.register(composer) |
58 | | - try { |
| 60 | + // is a remembered value. See `python.multiplatform.compose.PythonCallableArena`. |
| 61 | + val arena = remember { CountingCallableArena() } |
| 62 | + withPythonComposer { |
59 | 63 | PythonCallables.withScope(arena.scope) { |
60 | | - Python3.exec("import python_multiplatform.binding as _pm_binding\n_pm_binding.push_composer(${reference.raw})") |
61 | | - try { |
62 | | - Python3.exec(source) |
63 | | - } finally { |
64 | | - Python3.exec("import python_multiplatform.binding as _pm_binding\n_pm_binding.pop_composer()") |
65 | | - } |
| 64 | + Python3.exec(source) |
66 | 65 | } |
67 | | - } finally { |
68 | | - HandleTable.release(reference) |
69 | 66 | } |
70 | 67 | } |
71 | 68 |
|
72 | 69 | /** |
73 | | - * Who holds a Python callable, and the one hook that says when to let go. |
74 | | - * |
75 | | - * `docs/archive/pythonx-adapter-design.md` §6 item 1: *"When Compose drops the slot, does anything tell the |
76 | | - * Kotlin holder? `RememberObserver.onForgotten` is the only hook that reports it… Item 1 is the one |
77 | | - * that fails silently and should be tested first."* This is that hook, wired to the one thing that |
78 | | - * can act on it. |
79 | | - * |
80 | | - * ### Why the composition and not something shorter or longer |
| 70 | + * The library's [PythonCallableArena] (`python-multiplatform-compose`), counted. |
81 | 71 | * |
82 | | - * | candidate holder | what it gets wrong | |
83 | | - * |---|---| |
84 | | - * | the composition **pass** | ends when `PythonComposition` returns, and Compose calls a stored `content` on every later recomposition | |
85 | | - * | the `PyObject`'s own cleaner | fires whenever the collector reaches the Kotlin wrapper, which is not a time and is not every platform | |
86 | | - * | `HandleTable` alone | a strong root nothing gives back: the handle goes into a slot and is never handed to Python, so there is no `__del__` to release it | |
87 | | - * | **this** | `onRemembered` … `onForgotten` is exactly the interval in which Compose may call the content | |
| 72 | + * The holder itself -- why the composition, why `onForgotten`, why `onAbandoned` too -- is |
| 73 | + * documented where it now lives. What stays here is test-visible counters, because "the arena never |
| 74 | + * held anything" and "the arena held it and gave it back" produce the same reference count and must |
| 75 | + * not produce the same verdict. |
88 | 76 | * |
89 | | - * ### Both ends, because a leak test alone would pass a double release |
90 | | - * |
91 | | - * [PythonCallableScope.close] reports how many callables it actually released and answers `0` on |
92 | | - * every later call, and [released] accumulates that -- so a test can assert that the reference count |
93 | | - * came back *and* that nothing released it twice. `agent-rules` §14 is why those are two assertions: |
94 | | - * a reference dropped twice does not fail where it happens, it corrupts a free list and surfaces |
95 | | - * somewhere unrelated. |
96 | | - * |
97 | | - * `onAbandoned` closes the scope too. It is the case where the composition that created this was |
98 | | - * discarded before it was ever applied, so nothing will ever call `onForgotten`; the two are mutually |
99 | | - * exclusive by Compose's contract, and [close] being idempotent means it does not matter here if |
100 | | - * that contract is ever weakened. |
| 77 | + * [released] accumulates what `PythonCallableScope.close` *reported* rather than counting calls to |
| 78 | + * it, which is what makes a double release visible: a second close answers `0`, so a total higher |
| 79 | + * than the number of callables that crossed can only come from releasing something twice. |
101 | 80 | */ |
102 | | -class PythonCallableArena : RememberObserver { |
103 | | - |
104 | | - val scope: PythonCallableScope = PythonCallables.newScope() |
| 81 | +class CountingCallableArena : PythonCallableArena() { |
105 | 82 |
|
106 | 83 | override fun onRemembered() { |
| 84 | + super.onRemembered() |
107 | 85 | created++ |
108 | 86 | latest = this |
109 | 87 | } |
110 | 88 |
|
111 | 89 | override fun onForgotten() { |
112 | 90 | forgotten++ |
113 | | - released += scope.close() |
| 91 | + val before = super.released |
| 92 | + super.onForgotten() |
| 93 | + Companion.released += super.released - before |
114 | 94 | } |
115 | 95 |
|
116 | 96 | override fun onAbandoned() { |
117 | 97 | abandoned++ |
118 | | - released += scope.close() |
| 98 | + val before = super.released |
| 99 | + super.onAbandoned() |
| 100 | + Companion.released += super.released - before |
119 | 101 | } |
120 | 102 |
|
121 | | - /** |
122 | | - * Test-visible counters, because "the arena never held anything" and "the arena held it and gave |
123 | | - * it back" produce the same reference count and must not produce the same verdict. |
124 | | - * |
125 | | - * [released] accumulates what [PythonCallableScope.close] *reported* rather than counting calls |
126 | | - * to it, which is what makes a double release visible: a second close answers `0`, so a total |
127 | | - * higher than the number of callables that crossed can only come from releasing something twice. |
128 | | - */ |
129 | 103 | companion object { |
130 | 104 | var created: Int = 0 |
131 | 105 | var forgotten: Int = 0 |
132 | 106 | var abandoned: Int = 0 |
133 | 107 | var released: Int = 0 |
134 | 108 |
|
135 | 109 | /** |
136 | | - * The most recently remembered arena, so a test can read [PythonCallableScope.liveCount] |
137 | | - * **while the composition is still alive**. |
138 | | - * |
139 | | - * The counters above can only be read after disposal, and "how much did this composition |
140 | | - * accumulate across its recompositions" is a question about the interval before it -- |
141 | | - * `RecompositionAccumulationTest` is the whole reason this exists. Set in [onRemembered] |
142 | | - * rather than in the constructor because an arena that was built and then abandoned by a |
143 | | - * discarded composition never becomes the one a render is measuring. |
| 110 | + * The most recently remembered arena, so a test can read `PythonCallableScope.liveCount` |
| 111 | + * **while the composition is still alive** -- `RecompositionAccumulationTest` is the whole |
| 112 | + * reason this exists. Set in [onRemembered] rather than in the constructor because an arena |
| 113 | + * that was built and then abandoned by a discarded composition never becomes the one a render |
| 114 | + * is measuring. |
144 | 115 | */ |
145 | | - var latest: PythonCallableArena? = null |
| 116 | + var latest: CountingCallableArena? = null |
146 | 117 |
|
147 | 118 | fun resetCounters() { |
148 | 119 | created = 0 |
|
0 commit comments