Skip to content

Commit ae63c26

Browse files
committed
Implement opaque Mailbox/Pool for "information hiding" - no changes in the client code
1 parent cfd6f16 commit ae63c26

11 files changed

Lines changed: 439 additions & 157 deletions

File tree

design/secondary/lang/c3/3tk-log.md

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,84 @@ Current state is in [3tk-status.md](3tk-status.md).
77

88
---
99

10+
## 2026-09-03 — 3TK-58: `Mailbox`/`Pool` opaque handles
11+
12+
**`Mailbox` and `Pool` became opaque handles**, following the idiom C3's own
13+
stdlib uses for `std::thread::channel::UnboundedChannel`: `typedef Mailbox =
14+
void;` / `typedef Pool = void;` are now the public types, and the real
15+
fields moved into `@private` structs in the same module — `_Mbox` in
16+
`mailbox.c3`, `_Pool` in `pool.c3`. Every method's first line casts the
17+
opaque handle back to the real type; the first parameter, no longer named
18+
`self` since the type it names is no longer real, is `mbox`/`pool` instead.
19+
Internal-only helpers (`enqueue`, `dequeue`, `has_queued`, `send_at`,
20+
`bucket_for`, `take_back`, `take_back_handle`, `_close`, and the
21+
`@closed_fast` macro) moved to be methods on `_Mbox`/`_Pool` directly, since
22+
they are only ever called from inside a method that has already cast.
23+
`TYPE` and `to_handle`/`of` were repointed at `_Mbox`/`_Pool`, since the
24+
identity a `Handle` carries has to be the real struct's `typeid` — leaving
25+
`TYPE` as `Mailbox::typeid` after the typedef would have silently made it
26+
`void`'s typeid instead, which is not what `init()` writes into a mailbox's
27+
own `Inner.link.type` (that still runs against `_Mbox`/`_Pool` through
28+
`mtk::helper::init`, taking the real pointer).
29+
30+
**Scope held to `Mailbox`/`Pool` only**, per the plan: `PoolBucket` and the
31+
core (`PolyNode`/`Inner`) are untouched.
32+
33+
**New API: `Mailbox.is_quiet()` / `Pool.is_quiet()``bool`**, returning
34+
`self._closed && self._active == 0` under the mutex — the same predicate
35+
`release()` already asserted. Narrower than a raw active-count accessor by
36+
the owner's call, made in this session before the plan was written.
37+
38+
**Three test files were brought back to black-box, one test dropped.**
39+
`test/t_mailbox.c3` (two sites) and `test/t_pool.c3` (three sites) read
40+
`_active == 0` directly; all five became `is_quiet()` calls. One of
41+
`t_pool.c3`'s three sites (`the_pool_close_then_join_then_release`) checked
42+
`_active == 0` **before** `close()` ran, while the pool was still open —
43+
`is_quiet()` requires `_closed` too, so it cannot stand in there. That
44+
assertion was dropped rather than mistranslated; the test still asserts
45+
`!p.is_closed()` at that point and moves straight to `close()`, where
46+
`is_quiet()` picks back up. `t_identity.c3`, read first per the plan, turned
47+
out to touch none of `Mailbox`/`Pool` at all — its `.node`/`Msg`/`Job`/`Twin`
48+
lines were never in scope, so nothing there changed.
49+
50+
**`t_concurrency.c3`'s `the_deadline_is_anchored_once` was dropped, not
51+
rewritten**, exactly as the plan called for: it reached `mb._cv.broadcast()`
52+
directly to provoke a spurious wakeup, and there is no black-box way to do
53+
that once `_cv` is unreachable — every public way to signal the condition
54+
variable changes one of the three things the receive loop checks before
55+
looping back to wait, so the case the test provoked is structurally
56+
unreachable from outside the module. Its `spurious_waker` helper went with
57+
it. Part 2.5, D7 stays documented, not mechanically tested — tracked as the
58+
"Tests improvements" TODO in `3tk-status.md`.
59+
60+
**Verification.** `./3tk/run-builds.sh`: 87 checks, 0 failures, four builds
61+
green, 140 tests each (down from 141 — the one dropped test, and nothing
62+
else). `./3tk/check-doc-loop.sh` against the new
63+
`matryoshka-3tk/design/3tk-reference-006.md`: 466 descriptor sentences, 464
64+
found — the 2 missing are the same pre-existing gaps (a `helper.c3` and a
65+
`managed.c3` module-summary sentence) present against `3tk-reference-005.md`
66+
before this stage too, confirmed by running the check against both files.
67+
Module blocks: `mailbox.c3` and `pool.c3` both `same`; the one `DIFFERS`
68+
(`managed.c3`) is the same pre-existing gap. No `Mailbox{...}` / `Pool{...}`
69+
struct literal existed anywhere under `3tk/`, so no construction site needed
70+
updating.
71+
72+
**Reference doc.** `3tk-reference-006.md` written directly in
73+
`matryoshka-3tk/design/`, per this session's ruling that design docs there
74+
are editable in place: Parts 4 and 5's Participants sections describe the
75+
opaque shape (keeping "the tool itself" in the prose so the module-summary
76+
sentence match against `mailbox.c3`/`pool.c3`'s doc comments still holds),
77+
every method signature block updated to `(&mbox, ...)` / `(&pool, ...)`,
78+
`is_quiet` added to both control-API blocks, and the `@closed_fast` note in
79+
Part 6 repointed at `_Mbox`/`_Pool` since the macro is no longer reachable
80+
from outside the module at all. `3tk-reference-005.md` moved to that repo's
81+
`backup/` with a plain `mv`.
82+
83+
**Not yet copied anywhere else or pushed**`matryoshka-3tk`'s `design/` is
84+
where the doc was written directly, but `src/`/`test/` changes are only in
85+
`matryoshka-tk`'s copy, per the standing rule that 3tk source changes go
86+
there and the owner copies to `matryoshka-3tk` themselves.
87+
1088
## 2026-08-31 — 3TK-57 follow-up: `shc` gets its own module description page
1189

1290
**Found after the `exm``shc` rename landed: `shc` rendered in `docs.html`
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
# 3tk — staging plan 022
2+
3+
Written 2026-09-03.
4+
5+
**Provenance.** Follows [3tk-staging-plan-021.md](backup/3tk-staging-plan-021.md).
6+
021 declared one stage, **3TK-57**, and it has since run and closed
7+
(2026-08-31, GitHub Actions CI, built in `matryoshka-3tk`). **021 is fully
8+
spent.** This plan declares one stage: **3TK-58.**
9+
10+
State is in [3tk-status.md](3tk-status.md). Narrative is in
11+
[3tk-log.md](3tk-log.md). Neither is duplicated here.
12+
13+
---
14+
15+
## Why this plan exists
16+
17+
**`Mailbox` and `Pool` are public structs today, with every field readable
18+
and writable from outside the module** — the leading underscore is
19+
convention only, not an enforced boundary; `ref/3tk-api-003.md` even states
20+
"C3 0.8.3 hides neither a field nor a method" as the rationale for the
21+
current shape. The owner wants that changed: `Mailbox` and `Pool` become
22+
**opaque handles**, following the idiom already used by C3's own stdlib
23+
(`std::thread::channel::UnboundedChannel`, in
24+
`/home/g41797/dev/langs/c3/lib/std/threads/unbounded_channel.c3`) —
25+
`typedef Mailbox = void;` as the public type, the real fields in an
26+
`@private` impl struct in the same module, every method casting the opaque
27+
pointer to the real type as its first line. Client code that only calls
28+
public methods (`mb.send(...)`, `pool.get(...)`, etc.) does not change; code
29+
that reached into `_active`/`_cv` directly no longer compiles.
30+
31+
**Scope, ruled before this plan was written**: `Mailbox` and `Pool` only —
32+
not `PoolBucket` (also a public struct today), and not the core
33+
(`PolyNode`/`Inner`). This matches the boundary [3tk-status.md](3tk-status.md)
34+
already records from the earlier API 12 work: flatten `Mailbox`/`Pool` only,
35+
never the core.
36+
37+
**The owner's tests are black-box by convention.** The exceptions to that —
38+
three test files reaching `Mailbox`/`Pool` fields directly today — are
39+
in scope for this stage, not a follow-up, because an opaque `Mailbox`/`Pool`
40+
makes those reads a compile error. One of the three cannot be replaced by any
41+
public call (reasoning below); it is dropped, not rewritten.
42+
43+
## The stage
44+
45+
```
46+
3TK-58 Mailbox/Pool opaque handles 3tk/src/, 3tk/test/, matryoshka-3tk 3tk-reference-006.md
47+
```
48+
49+
## 3TK-58 — Mailbox/Pool opaque handles
50+
51+
**1. `src/mailbox.c3`, `src/pool.c3`.**
52+
53+
- `struct Mailbox { ... }``struct _Mbox @private { ... }` (same fields, same
54+
layout — `Inner node` stays the first field, since `to_handle`/`of` still
55+
compute a `Handle` from its address).
56+
- `struct Pool { ... }``struct _Pool @private { ... }`, same treatment.
57+
- `typedef Mailbox = void;` / `typedef Pool = void;` become the public types.
58+
- Every method signature changes from `fn T Mailbox.method(&self, ...)` to
59+
`fn T Mailbox.method(&mbox, ...)` (parameter renamed since `self` no longer
60+
names the real type), with `_Mbox* self = (_Mbox*)mbox;` as the first line
61+
of the body — body otherwise unchanged. Same for `Pool`.
62+
- `to_handle`, `of`, and the `@closed_fast` macro take the opaque pointer and
63+
cast internally, the same way.
64+
- The descriptive `<* *>` doc comment currently above `struct Mailbox` /
65+
`struct Pool` moves to above the new `typedef` line. Mechanically safe:
66+
`check-doc-loop.sh` matches every `<* *>` block's sentences against the
67+
reference text as a flat pool, regardless of what declaration follows it —
68+
confirmed by reading the script before this plan was written. c3c itself
69+
has no working docgen to have a placement rule at all (`MANUAL.md` ~line
70+
10427: "`c3c docs` ... Not added yet!").
71+
72+
**2. New public API**: `Mailbox.is_quiet()` / `Pool.is_quiet()``bool`,
73+
returning `self._closed && self._active == 0` under the mutex, same
74+
predicate `release()` already asserts. Deliberately narrower than a raw
75+
`active_count()` accessor — the owner's call, to avoid exposing the count as
76+
a number tests could otherwise be tempted to inspect more closely than the
77+
contract promises.
78+
79+
**3. Test fixes, same stage:**
80+
81+
| file | today | after |
82+
|---|---|---|
83+
| `test/t_mailbox.c3:443,471,510` | `always_assert(mb._active == 0, ...)` | `always_assert(mb.is_quiet(), ...)` |
84+
| `test/t_pool.c3:672,700,733,736` | `always_assert(p._active == 0, ...)` | `always_assert(p.is_quiet(), ...)` |
85+
| `test/t_concurrency.c3` | `the_deadline_is_anchored_once` calls `mb._cv.broadcast()` directly to provoke a spurious wakeup | **dropped** — no replacement |
86+
| `test/t_identity.c3:43-44,70-71,98-99` | `.node` access, target type not yet confirmed | read first; fix only if it touches `Mailbox`/`Pool` |
87+
88+
**Why `the_deadline_is_anchored_once` has no replacement.** The defect it
89+
guards against (Part 2.5, D7 — `wait_until`'s deadline must be anchored once,
90+
not recomputed on each spurious wakeup) only shows up when the mailbox's
91+
internal wait loop re-enters `cv.wait_until(deadline)` with *nothing*
92+
changed: no new item, `_wake_gen` unmoved, not closed. Every public way to
93+
signal the condition variable changes one of exactly those three things:
94+
`send`/`send_oob` add an item (the loop's `dequeue()` then succeeds and it
95+
returns), `wake_all()` bumps `_wake_gen` (the loop sees the generation change
96+
and returns `WOKEN`), `close()` sets `_closed` (same). There is no sequence
97+
of public calls that re-enters the wait loop while all three stay unchanged
98+
— the "spurious, keep waiting" case is structurally unreachable from outside
99+
the module by construction, which is exactly what the existing doc comment
100+
on this test already said before this stage: *"there is no public way to
101+
produce a spurious wakeup, because a spurious wakeup is not a feature."*
102+
Once `_cv` itself is no longer reachable, the test's own mechanism for
103+
provoking the case it tests goes with it. Tracked as the "Tests improvements"
104+
TODO in [3tk-status.md](3tk-status.md); the guarantee stays documented, not
105+
mechanically tested, going forward.
106+
107+
**4. Reference doc**, written directly in the separate `matryoshka-3tk` repo
108+
(per the owner's ruling this session, [[design-docs-editable-in-3tk-repo]] in
109+
Claude's own memory — design docs there are editable directly, not deferred):
110+
a new `3tk-reference-006.md`, rewriting the `Mailbox`/`Pool` sections to
111+
describe the opaque shape and retracting "C3 0.8.3 hides neither a field nor
112+
a method," which described the shape this stage removes.
113+
`3tk-reference-005.md` moves to that repo's `backup/` with a plain `mv`
114+
(never `git mv` — git stays untouched there too).
115+
116+
**5. Verification.**
117+
118+
- `./3tk/run-builds.sh` after the `src/` and test changes. Compilation is the
119+
check: any leftover direct field access (anything this plan's search
120+
missed) fails to build. Fix iteratively until green, same precedent as
121+
3TK-50's steps.
122+
- `./3tk/check-doc-loop.sh` against the new `3tk-reference-006.md` once it
123+
exists (the in-repo default `REF` path is already known stale — point it
124+
at the new file explicitly).
125+
- Confirmed before writing this plan: no `Mailbox{...}` / `Pool{...}` struct
126+
literal exists anywhere under `3tk/`, so no construction site needs
127+
updating for that reason.
128+
129+
---
130+
131+
## Rules that hold
132+
133+
- **No stage runs `git`.** Moves in this repo are plain `mv`; the owner saves
134+
and pushes. The same now applies inside `matryoshka-3tk` for design docs,
135+
per this session's ruling.
136+
- **A change to `3tk/src` revises `ref/` in the same stage** — this stage's
137+
`ref/` update is the reference book in `matryoshka-3tk`, since that is
138+
where the live reference now lives; this repo's `ref/3tk-api-003.md` is
139+
already known stale and is not the target.
140+
- **Tests stay black-box after this stage.** The three files fixed here are
141+
brought back into that convention, not given a new sanctioned way to reach
142+
internals.
143+
144+
## Versioning
145+
146+
**`3tk-staging-plan-021.md` is superseded by this file** and moves to
147+
`backup/`. `3tk-reference-005.md`, in `matryoshka-3tk/design/`, is superseded
148+
by `3tk-reference-006.md` as part of this stage's own work, not before it.
149+
150+
## What this plan leaves to the owner
151+
152+
- **Whether the dropped `the_deadline_is_anchored_once` coverage gap is
153+
acceptable long-term**, or whether some other verification (a stress test,
154+
a manual sanitizer run, something else) should stand in for it. Tracked as
155+
the "Tests improvements" TODO, not resolved here.
156+
- **"Managed Outers — re-thinking"** — raised this session, not elaborated,
157+
entirely separate from 3TK-58 and not started by it.
158+
- **The seven questions plan 018 left and 019 carried**, and everything in
159+
`3tk-status.md`'s *Open questions* — untouched by this stage, and not
160+
reopened by it.

0 commit comments

Comments
 (0)