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
18 changes: 12 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,7 @@ fn process(size: int) {
| OWN019 | inline-ёмкость слишком велика для stack-backed политики (выше потолка стека) |
| OWN021 | `stack`/`inline` динамического размера без статической границы (нет `max =`) |
| OWN023 | `scratch` с `fallback = forbidden`, но размер может превысить inline-лимит |
| OWN024 | буфер помечен `sensitive`, но не зануляется на release (нет `clear = true`) |

### Неподдерживаемое / структурное / граница

Expand Down Expand Up @@ -399,9 +400,10 @@ pool, а ты три часа смотришь на GC-график. Поэто
Release вызовы вырезаются, логирование не становится новым bottleneck'ом.

3. **Runtime counters** — `OwnCounters` (`ScratchStackHits`, `ScratchPoolFallbacks`,
`ScratchPoolBytesRented`, `ScratchReleaseCount`) под `[Conditional("OWNSHARP_COUNTERS")]`.
Отвечают на главный вопрос: мы реально часто попадаем в стек, или inline-лимит
подобран мимо?
`ScratchPoolBytesRented`, `ScratchPoolBytesReturned`, `ScratchTotalRequestedBytes`,
`ScratchMaxRequestedBytes`, `ScratchReleaseCount`, `ScratchForcedClears`) под
`[Conditional("OWNSHARP_COUNTERS")]`. Отвечают на главный вопрос: мы реально часто
попадаем в стек, или inline-лимит подобран мимо?

### Политики

Expand Down Expand Up @@ -456,9 +458,13 @@ faithful-inline (release ровно там, где написан; без `try/f
**OWN021** (для динамики есть `stack`). Plain-локал, объявленный в теле буфера и
использованный после release, не оборачивается в hoist'нутый `try` (иначе вышел бы
из C#-scope) — такой буфер лоуэрится inline.
Булевы настройки (`clear_on_release`, `counters`) и `trace` валидируются: опечатка
вроде `clear_on_release = ture` — **OWN030**, а не тихое отключение clear на
sensitive-буфере. `native` хранит `byte*` (backing, освобождается на release), но наружу
Булевы настройки (`clear_on_release`, `counters`, `sensitive`) и `trace`
валидируются: опечатка вроде `clear_on_release = ture` — **OWN030**, а не тихое
отключение clear на sensitive-буфере. А `sensitive = true` без `clear = true` —
**OWN024**: пометил секретным — обязан занулить перед тем, как backing-память
(пул/аллокатор/кадр стека) переиспользуют. `counters` теперь и
`ScratchTotalRequestedBytes`/`ScratchMaxRequestedBytes` (распределение запросов),
`ScratchPoolBytesReturned` (баланс с `Rented`) и `ScratchForcedClears`. `native` хранит `byte*` (backing, освобождается на release), но наружу
отдаёт `Span<byte>`-view — borrow/call видят тот же логический тип, что и
pooled/stack/scratch. Borrow-параметр типа `Buffer` (и в `extern`, и в **локальной**
`fn`) рендерится как `Span<byte>`/`ReadOnlySpan<byte>`, так что один
Expand Down
20 changes: 18 additions & 2 deletions ownlang/buffers.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,11 @@ class BufferMode(Enum):
# heap, defeating the explicit storage guarantee.
VALID_OPTIONS = frozenset({
"policy", "inline", "inline_bytes", "max", "max_bytes",
"fallback", "clear", "trace", "counters",
"fallback", "clear", "trace", "counters", "sensitive",
})
VALID_POLICY_KEYS = frozenset({
"inline_bytes", "max_bytes", "fallback", "clear_on_release",
"trace", "counters",
"trace", "counters", "sensitive",
})

# Modes whose backing storage may live on the stack. A stack-backed buffer must
Expand Down Expand Up @@ -95,6 +95,7 @@ class BufferInfo:
fallback_pool: bool # scratch: heap fallback allowed
fallback_forbidden: bool # stack: heap fallback explicitly forbidden
clear_on_release: bool # zero the bytes before returning/releasing
sensitive: bool # holds secret data: must be cleared on release
trace: bool # emit OwnTrace hooks
counters: bool # emit OwnCounters hooks
policy_name: str | None
Expand Down Expand Up @@ -358,10 +359,24 @@ def first_int(sources: list[tuple[bool, str]], label: str,
# ---- flags from options / policy --------------------------------------
clear = _bool_flag(opts.get("clear"), base.get("clear_on_release"),
False, "clear_on_release", diags, line)
sensitive = _bool_flag(opts.get("sensitive"), base.get("sensitive"),
False, "sensitive", diags, line)
trace = _trace_flag(opts.get("trace"), base.get("trace"), True, diags, line)
counters = _bool_flag(opts.get("counters"), base.get("counters"),
True, "counters", diags, line)

# A buffer marked sensitive must be zeroed before its backing memory can be
# observed again — pooled/scratch arrays go back to a shared ArrayPool, native
# memory is handed back to the allocator, and even a stack frame is reused by
# the next call. Marking it sensitive without clearing is the silent leak the
# flag exists to prevent, so require an explicit `clear = true`.
if sensitive and not clear:
diags.append(Diagnostic(
"OWN024",
"buffer is marked sensitive but is not cleared on release; add "
"'clear = true' so its bytes are zeroed before the backing memory "
"is reused", line))

info = BufferInfo(
mode=mode,
elem="byte",
Expand All @@ -371,6 +386,7 @@ def first_int(sources: list[tuple[bool, str]], label: str,
fallback_pool=fallback_pool,
fallback_forbidden=fallback_forbidden,
clear_on_release=clear,
sensitive=sensitive,
trace=trace,
counters=counters,
policy_name=pol_name,
Expand Down
43 changes: 42 additions & 1 deletion ownlang/codegen.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ def _buffer_lowering(self, name: str, intent: A.BufferIntent
if info.trace:
pre.append(f'OwnTrace.StackSelected("{fn}", "{name}", {size}, {L});')
if sc:
pre.append(f"OwnCounters.Requested({size});")
pre.append("OwnCounters.StackHit();")
if info.size_const == L:
pre.append(f"Span<byte> {name} = stackalloc byte[{L}];")
Expand All @@ -266,13 +267,16 @@ def _buffer_lowering(self, name: str, intent: A.BufferIntent
if info.trace:
pre.append(f'OwnTrace.StackSelected("{fn}", "{name}", {size}, {L});')
if sc:
pre.append(f"OwnCounters.Requested({size});")
pre.append("OwnCounters.StackHit();")
pre.append(f"Span<byte> {name}_backing = stackalloc byte[{L}];")
pre.append(f"Span<byte> {name} = {name}_backing[..{size}];")
if sc:
fin.append("OwnCounters.Release();")
if info.clear_on_release:
fin.append(f"{name}.Clear();")
if sc:
fin.append("OwnCounters.ForcedClear();")

elif scratch_pool:
if not info.size_is_const:
Expand All @@ -284,6 +288,8 @@ def _buffer_lowering(self, name: str, intent: A.BufferIntent
pre.append(f"byte[]? {name}_rented = null;")
pre.append(f"Span<byte> {name}_backing = stackalloc byte[{L}];")
pre.append(f"Span<byte> {name};")
if sc:
pre.append(f"OwnCounters.Requested({size});")
pre.append(f"if ({size} <= {L})")
pre.append("{")
if info.trace:
Expand All @@ -305,8 +311,16 @@ def _buffer_lowering(self, name: str, intent: A.BufferIntent
fin.append("OwnCounters.Release();")
if info.clear_on_release:
fin.append(f"{name}.Clear();")
if sc:
fin.append("OwnCounters.ForcedClear();")
fin.append(f"if ({name}_rented is not null)")
fin.append(f" ArrayPool<byte>.Shared.Return({name}_rented);")
if sc:
fin.append("{")
fin.append(f" ArrayPool<byte>.Shared.Return({name}_rented);")
fin.append(f" OwnCounters.PoolReturned({size});")
fin.append("}")
else:
fin.append(f" ArrayPool<byte>.Shared.Return({name}_rented);")

elif info.mode == BufferMode.POOLED:
# pooled is not scratch: trace it, but do not touch the Scratch.*
Expand Down Expand Up @@ -684,7 +698,26 @@ def _usings(mod: A.Module) -> list[str]:
public static long ScratchStackHits;
public static long ScratchPoolFallbacks;
public static long ScratchPoolBytesRented;
public static long ScratchPoolBytesReturned;
public static long ScratchTotalRequestedBytes;
public static long ScratchMaxRequestedBytes;
public static long ScratchReleaseCount;
public static long ScratchForcedClears;

[System.Diagnostics.Conditional("OWNSHARP_COUNTERS")]
public static void Requested(int bytes)
{
System.Threading.Interlocked.Add(ref ScratchTotalRequestedBytes, bytes);
// CAS retry loop: update max if bytes > current max; retry if concurrent update
long cur;
do
{
cur = System.Threading.Interlocked.Read(ref ScratchMaxRequestedBytes);
if (bytes <= cur) return;
}
while (System.Threading.Interlocked.CompareExchange(
ref ScratchMaxRequestedBytes, bytes, cur) != cur);
}

[System.Diagnostics.Conditional("OWNSHARP_COUNTERS")]
public static void StackHit()
Expand All @@ -697,9 +730,17 @@ def _usings(mod: A.Module) -> list[str]:
System.Threading.Interlocked.Add(ref ScratchPoolBytesRented, bytes);
}

[System.Diagnostics.Conditional("OWNSHARP_COUNTERS")]
public static void PoolReturned(int bytes)
=> System.Threading.Interlocked.Add(ref ScratchPoolBytesReturned, bytes);

[System.Diagnostics.Conditional("OWNSHARP_COUNTERS")]
public static void Release()
=> System.Threading.Interlocked.Increment(ref ScratchReleaseCount);

[System.Diagnostics.Conditional("OWNSHARP_COUNTERS")]
public static void ForcedClear()
=> System.Threading.Interlocked.Increment(ref ScratchForcedClears);
}
'''

Expand Down
1 change: 1 addition & 0 deletions ownlang/diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ class Severity(Enum):
"OWN019": "inline capacity too large for a stack-backed policy",
"OWN021": "stack allocation requires a statically known bound",
"OWN023": "scratch fallback forbidden but the size may exceed the inline limit",
"OWN024": "sensitive buffer is not cleared on release",
# ---- unsupported ----
"OWN020": "unsupported construct (out of scope for the MVP)",
# ---- name resolution & structural ----
Expand Down
2 changes: 2 additions & 0 deletions ownlang/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ def build_report(mod: A.Module, diags: list[Diagnostic]) -> dict:
else "forbidden")),
"escapePolicy": info.escape_policy,
"clearOnRelease": info.clear_on_release,
"sensitive": info.sensitive,
"trace": info.trace,
"counters": info.counters,
"policy": info.policy_name,
Expand All @@ -102,6 +103,7 @@ def render_report(report: dict) -> str:
lines.append(f" Fallback: {e['fallback']}")
lines.append(f" EscapePolicy: {e['escapePolicy']}")
lines.append(f" ClearOnRelease: {str(e['clearOnRelease']).lower()}")
lines.append(f" Sensitive: {str(e['sensitive']).lower()}")
if e["policy"]:
lines.append(f" Policy: {e['policy']}")
lines.append(" Generated branches:")
Expand Down
22 changes: 22 additions & 0 deletions tests/run_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,8 +280,24 @@ def codes(src: str) -> list[str]:
("buf_policy_ok",
"policy P { inline_bytes = 512; fallback = pool; } "
"fn f(n: int){ let b = Buffer.scratch(n, policy = P); release b; }", []),
("buf_sensitive_cleared_ok",
"fn f(n: int){ let b = Buffer.pooled(n, sensitive = true, clear = true); "
"release b; }", []),
("buf_sensitive_policy_ok",
"policy Secret { sensitive = true; clear_on_release = true; } "
"fn f(n: int){ let b = Buffer.scratch(n, policy = Secret); release b; }", []),

# ---- buffer storage policies: faults ----
("buf_sensitive_no_clear",
"fn f(n: int){ let b = Buffer.pooled(n, sensitive = true); release b; }",
["OWN024"]),
("buf_sensitive_policy_no_clear",
"policy Secret { sensitive = true; clear_on_release = false; } "
"fn f(n: int){ let b = Buffer.scratch(n, policy = Secret); release b; }",
["OWN024"]),
("buf_bad_sensitive",
"fn f(n: int){ let b = Buffer.scratch(n, sensitive = ture); release b; }",
["OWN030"]),
("buf_stack_dyn_unbounded",
"fn f(n: int){ let b = Buffer.stack(n); release b; }", ["OWN021"]),
("buf_stack_too_large",
Expand Down Expand Up @@ -401,6 +417,12 @@ def buffer_smoke() -> list[str]:
'OwnTrace.ScratchSelected("parse", "tmp", size, 1024, "ArrayPool");',
"OwnCounters.StackHit();",
"OwnCounters.PoolFallback(size);",
"OwnCounters.Requested(size);",
"OwnCounters.PoolReturned(size);",
"public static long ScratchTotalRequestedBytes;",
"public static long ScratchMaxRequestedBytes;",
"public static long ScratchPoolBytesReturned;",
"public static long ScratchForcedClears;",
"ArrayPool<byte>.Shared.Rent(size)",
"ArrayPool<byte>.Shared.Return(tmp_rented)",
"try",
Expand Down
11 changes: 11 additions & 0 deletions tests/test_codegen.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,17 @@ def case(name: str, prelude: str, fn_src: str) -> Check:
"fn c(){ let d = acquire Buffer(2); release d; }") \
.has("static void a(").has("static void c(")

# A scratch buffer with clear-on-release zeroes its bytes and bumps the
# ForcedClear counter (the one Scratch.* metric the golden smoke doesn't hit).
case("scratch_clear_forced_clear_counter", SCHEMATIC,
"fn f(n: int){ let b = Buffer.scratch(n, clear = true); release b; }") \
.has(".Clear();").has("OwnCounters.ForcedClear();")

# The requested/returned byte counters are emitted around the scratch arms.
case("scratch_byte_counters", SCHEMATIC,
"fn f(n: int){ let b = Buffer.scratch(n); release b; }") \
.has("OwnCounters.Requested(n);").has("OwnCounters.PoolReturned(n);")


# ---------------------------------------------------------------------------
# runner
Expand Down
Loading