Round 2 plan: honest FFI reference capabilities for stdlib + tools (#4925) #5461
Replies: 2 comments
-
|
Working through the Windows IOCP read path step by step convinced me of a limit worth stating plainly: "honest caps" has a ceiling at the C boundary, and IOCP is the proof.
No capability can state this contract. The lesson I take from this: capabilities describe what Pony references may do. They don't describe C. When we say a parameter capability is "honest," the most we can actually mean is "this truthfully bounds what Pony can expect during the call." For synchronous, non-retaining C, which is nearly everything in the audit, that is a real and useful honesty, and it's what this plan delivers. For retained-pointer C like overlapped I/O, full honesty is unrepresentable. The window between return and completion is governed by the field that roots the buffer, the discipline that doesn't touch it, and the happens-before edge the completion message provides when it finally arrives. That's the same safety story every IOCP program in C or C# has. It's contained: users of So the plan's treatment of these sites is the most-true cap plus a written admission. The buffer parameters go to The only way to turn the discipline into enforcement is to take the buffer away from Pony for the window: the runtime owns and roots in-flight buffers, and completions come back through a path that can carry an object. That's a redesign of how asio notifications work. Possible. Its own round, if anyone ever wants it. |
Beta Was this translation helpful? Give feedback.
-
|
Following up on the IOCP ceiling above: there are two ways out of it, and both are proven in other ecosystems. 1. Avoid completions for sockets entirely: talk to AFD and get readiness, like epoll. WinSock is a layer over the Auxiliary Function Driver, and 2. Keep completions and make the ownership transfer real: the kernel gives us back the buffer. This is the contract IOCP actually has — ownership leaves at enqueue and should come back at completion. Expressed honestly: enqueue consumes the buffer ( The first option removes the problem. The second makes the problem's true semantics expressible. Either one retires the "contained unsafety" admission for these sites. |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
This is the Round 2 plan for the C-FFI safety effort (#4925; Round 1 was the
Pointer/UnsafePointersplit, #5427). The goal this round: make every FFI declaration inpackages/andtools/capability-honest. The rule is simple. The capability on an FFI parameter must describe what C does to the memory. C writes through it, the declaration says a mutable cap. C only reads, weaker caps are fine.Branch:
ffi-cap-accuracy, stacked on Round 1'sunsafe-pointer. Two standing constraints up front: thereadonlyoptimization that #4927 disabled stays disabled (re-enabling it is a later round, after the ecosystem has had time to adjust), and the plan assumes no C changes (one open question below pokes at that boundary for a single function we own).I audited every FFI declaration we have against the honesty rule. Most of the fixes are mechanical and the full plan for them is below. But one class of call site has no honest expression in today's language, and I'm leading with it because it blocks part of the plan and it deserves its own design conversation.
The problem that blocks part of the plan: you can't lend an iso to C
Today's declaration, from
packages/net/udp_socket.pony:C writes the datagram bytes into
bufferand the sender's sockaddr intofrom(socket.c,pony_os_recvfrom). Both parameters saytag. Both are lies, and they're the exact class of lie that produced #4925: LLVM 21 started using our parameter attributes and deleted writes that C actually performs. The honest declaration is:Two words change. Now look at the only call site, the POSIX read loop:
Both objects are
iso, and they need to be:datais delivered toUDPNotify.receivedasArray[U8] iso,fromasNetAddress(avalclass; theconsumegivesiso^, which converts). Under the honest declaration, nothing here typechecks, and every route out is a dead end. I checked each of these against the compiler rather than reasoning from memory:data.cpointer()can't produce arefpointer. Anything reachable through an alias of anisois at mosttag. That's not a flaw; that's the alias law that makesisosafe to send.fromdirectly aliases the iso, and an alias ofisois onlytag-compatible. Same law.consume fromdoesn't help either. I'd assumed it would typecheck against therefparameter and merely destroy the binding; the compiler corrected me. Arguments are aliased, so the consumediso^arrives at the parameter check asiso, andisois not a subtype ofref. Transfer is expressible only by declaring the parameterisoand consuming into it, and then the object is gone unless C returns the pointer back. That works for runtime shims we own and fails for the actual point of FFI:read(2),getsockopt(2), OpenSSL. The world of C will never hand our pointers back to us.recoverblock round-trips exactly one object. You consume the iso in, view it asreffor the call, take the result back out. That's the fix Joe and I sketched on LLVM 21 readonly optimization breaks FFI writes through tag pointers #4925 and it handles most of the audit. It cannot handle this site: one call fills two objects, they leave at different capabilities, and a recover lifts its result to one target capability. There is no(iso, val)exit. Recovering a tuple toisois rejected outright.Inside today's language, what's left is machinery: copy the address out field-by-field through a new package-private constructor, or invent a wrapper class so two objects ride through the one-object construct as one. I prototyped both. They compile. They're also patches around a missing language capability, and building them would entrench the gap instead of fixing it. We're not doing that.
The actual problem: iso, but lent
State what the call site wants and the shape of the gap is plain. C may mutate this memory for the duration of the call. C retains nothing. I keep my
isoafterward. Pony hasconsumefor permanent transfer andrecoverfor one-object round trips, and nothing that says "yours for this call, mine again after."Note what's true during the call itself: a synchronous FFI call suspends the calling actor, and
isoalready guarantees no other actor can reach the memory. So while C runs, the iso's exclusivity is intact. Theconsumethe type system demands isn't a price the call needs; it's alias bookkeeping with no way to say "same iso, before and after."The buffer half of the problem is harder still. What crosses the FFI for
dataisn't the array, it'sdata.cpointer(): a pointer derived from the iso mid-expression. Any language-level answer would have to cover not just object arguments but pointers derived from them, and that is real design work, not a one-line subtype check.What today's language offers is the recover round-trip (one object per call) and, for C we own, shims that take ownership and give it back. Whether the language should grow something better than that is a genuine design question, and it's bigger than this plan. I want to hear shapes before anchoring on one. Two constraints any proposal has to respect: the buffer case crosses as a derived pointer, not as the object; and nothing in this space touches the retention problem below, which no call-boundary change of any shape can fix.
Retention is a different problem entirely
There's one place in the audit where even "honest during the call" is unreachable, and I want to name it because it bounds what any answer to the above could ever cover. On Windows,
pony_os_recvfromqueues overlapped I/O: the kernel writes through the buffer and address pointers after the call returns, until the IOCP completion fires. That's not temporary access during a call. It isn'trefeither. It's transfer-now, ownership-back-at-completion, a third semantics that no capability at a call boundary describes — and no language change to the synchronous case would change that. Today it's a documented exception, same as the asio event handles.Until something changes here — a language answer for the synchronous case, or the owned-C shim in open question 4 — the plan leaves exactly one parameter knowingly dishonest:
pony_os_recvfrom'sfromstaystag, with a comment pointing here. Everything else gets fixed now.What the audit found
Every
use @declaration inpackages/andtools/was classified: 476 top-level declarations (a further handful ofuse @...strings exist only inside test-fixture string literals and docstring examples — not compiled declarations). C mutation behavior was verified againstsrc/libponyrt/,src/libponyc/, or documented libc/Win32 semantics, not guessed from parameter names.The genuinely-unsafe set, grouped by shape:
tag(the direct siblings of the original LLVM 21 readonly optimization breaks FFI writes through tag pointers #4925getsocknamebug):pony_os_sockname/peername/getaddr/recvfromfillingNetAddress tag, and thepony_os_stat/fstat/fstatattrio fillingFileInfo tag, pluspony_os_nameinfo's out-pointers. Two of these are activevalviolations today, not just latent ones:UDPSocket._ipis an embeddedvalNetAddress that C fills in three constructors, andFileInfo.modeis aFileMode valwhose bits C writes throughp->mode->*(stat.c).tag: every C-fills-a-Pony-buffer call goes throughcpointer()/cstring(), which returntagtoday regardless of receiver.read/_read,pony_os_recv/recvfrom/stdin_read,getsockopt,snprintf/_snprintf(used as a write target throughcstring()),ReadFile, andget_compiler_exe_directoryin the three tool frontends.addressofout-params behindtagdeclarations:waitpid'sstat_loc,ponyint_win_pipe_create's fd out-params,ponyint_win_process_create'serror_msg,OpenProcessToken's token handle. Call sites already hold mutable locals; only the declarations lie.tag: theFILE*family (pony_os_std_flush/print/write;fprintfin debug, assert, json, iregex, and the tool_unreachablefiles), the directory handles (closedir,ponyint_unix_readdir,FindNextFileA,FindClose), andfreeaddrinfo. Ataghandle is sendable while C mutates behind it.AsioEventID(UnsafePointer[AsioEvent] tag) by design, because events must stay sendable to be delivered through_event_notifybehaviors and the runtime owns the synchronization. The plan documents this as the deliberate exception rather than pretendingrefis achievable there.tools/lib/pony_compileris its own problem: 33 findings, all stemming from one convention — the library claimsPointer[_AST] valand a sendableclass val ASTwhile libponyc mutates AST nodes in place (ast_passes_programrewrites the whole tree through avalparameter; even read paths mutate token caches; finalizers free throughbox). Fixing that honestly changes the library's public types and how pony-lsp/pony-doc/pony-lint hold ASTs. That's a design round of its own, deferred, with the findings tracked.Two compiler facts shaped a lot of verdicts. Bare pointer types in FFI declarations default to
ref, so many out-params (@pipe's fds,@clock_gettime's timespec, thecount_outs) are already accurate. And a parameter typedPointer[None]/UnsafePointer[None]goes through the void*-rule (void_star_param,src/libponyc/expr/ffi.c), which skips capability checking entirely — so several fixes tighten[None]to a concrete element type, or the new cap would enforce nothing.The audit also confirmed the escape hatches:
addressofmintsPointer[T] refto anyvarregardless of receiver capability (including fromfun boxmethods),.usize()launders pointers into integers that the void*-rule accepts back, and_unsafe()+from_cpointeris builtin's internal forgery tool. All current uses are authorized; closing the hatches is future language/lint work.Decisions already made
cpointer()itself changes, no second accessor. Viewpoint adaptation is the mechanism that exists for exactly this; details under Divergence 1.Divergences
Each point where this plan changes scope or behavior relative to today, with an explicit justification. Why there are nine: #5 is the round's mission itself; #1, #4, and #9 are enablers without which the mission's declaration changes cannot compile; #2 and #3 are the two active
valviolations the audit found; #6 is a two-line bug fix on a declaration the mission already edits; #7 and #8 are policy decisions. Every entry traces to an audit finding or to a compiler constraint demonstrated with a scratch program — none is elective restructuring.Array[A].cpointer()andString.cpointer()change return type fromPointer[...] tagtothis->Pointer[A]/this->Pointer[U8]. This is a breaking change for code callingcpointer()onisoreceivers.cpointer()always returnstag, regardless of receiver cap — so every C-fills-this-buffer FFI call crosses the boundary attag, and the mission'sPointer[U8] refdeclarations would be unsatisfiable.refholders getref,valholders getval,boxholders getbox. All existing read-only uses on ref/val/box receivers keep compiling (every cap is a subtype oftag).isoreceivers can no longer call it directly (auto-receiver-recovery rejects arrow-typed returns) and use the consume-into-recover patterns below.chop/unchopin String and Array rewrite through the private_ptrfield (~10 lines each pair; String rewrite verified compiling, Array is isomorphic); two read-only test sites (net/_test.pony:893, 900) bind the pointer before a recover; every other in-tree break is an FFI write site this plan restructures anyway. I tried the alternative (a second,fun refaccessor;cpointer()untouched): it avoids the breakage but leaves the main accessor permanently dishonest-by-weakness and adds API surface forever.cstring()does NOT change: its fresh-allocation fallback path returns a pointer that cannot honestly carry the receiver's cap (verified: the body fails to typecheck under an arrow return), and its legitimate uses are read-only — its docstring will say so.FileInfo.modefield type changes fromFileMode valtoFileMode(ref).modeisFileMode valfrom birth, and the stat trio writes every mode bit through it — an active mutation of val-claimed memory, the exact LLVM 21 readonly optimization breaks FFI writes through tag pointers #4925 bug class.FileMode(ref); C fills it during construction through an honest mutable cap.FileInfo refis a half-truth while the object's ownmodefield still claims val during the write. There is no way to keep the field val and have C legally write it (embedwould be allocation-free and honest, but it inlines the object andpony_stat_tdeclaresmodeas a pointer — the C mirror breaks, and C changes are off the table). Practical impact: none — everyFileInfois builtnew val, so viewpoint adaptation makesinfo.moderead asFileMode valexactly as today; only the declared field type (API-visible) changes.UDPSocket._iplosesembed: becomeslet _ip: NetAddress(val), assigned from arecoverblock in which C fills arefNetAddress.embed _ip: NetAddress— an embedded val object that@pony_os_socknamefills in three constructors; the other active val violation.refinside a recover, let C fill it, lift to val, assign to aletfield.embedfield is constructed in place and can never pass through a recover, so there is no way to keepembedand have C write through an honest cap. Cost: one extra small allocation per UDPSocket constructed; behaviorally invisible._to_string/_format_floatswitch the snprintf output buffer fromcstring()tocpointer().@snprintf(s.cstring(), ...)— a read accessor (null-terminated C string) used as a write target.s.cpointer()—sis aString refat both sites, so the viewpoint-adapted return isPointer[U8] ref.strparam isPointer[U8] ref, thetag-returningcstring()no longer compiles there, andcstring()cannot be the thing that changes (see Should sequences be scopes #1).[None]params/returns get concrete element types.tag/val/boxclaims (Groups A-D above).[None]param enforces nothing (void*-rule). Visible only inside the declaring packages — FFI declarations are package-scoped.processis copied, not adopted:error_msg's inner type becomesUnsafePointer[U8] valand the call site switchesString.from_cstring→String.copy_cstring.char*thatFormatMessageALocalAlloc's is adopted as the backing store of a Pony String — foreign memory wearing aPointer, which Pony's GC will eventually treat as pony heap (latent heap corruption, Windows error path only).AsioEventIDkeepstagand gets an explicit documented exception (docstring at the type alias).UnsafePointer[AsioEvent] taghandles; nothing documents that this contradicts the cap-honesty principle.be _event_notify, and behavior parameters must be sendable, sorefis impossible without redesigning runtime event delivery (far out of scope). The honest alternative to an impossible fix is a documented exception. Open question 2 if anyone sees a better shape.Pointer[_AST] valand a sendableclass val ASTwhile libponyc mutates ASTs in place — 33 findings.UnsafePointer[_AddrInfo]), retyping two returns and two read-only params the audit itself classified as accurate.UnsafePointer[U8]on returns andUnsafePointer[None]on params.primitive _AddrInfoelement type everywhere; caps change only on@freeaddrinfo(tag→ref — it frees the chain).[None](void*-rule), and once that param has a concrete element type, every handle flowing into it must carry the same element type to typecheck. Read-only params keeptag— no cap churn.Removed by decision (the record of the road not taken): a tenth and eleventh divergence existed in earlier drafts — NetAddress explicit constructors plus a package-private
_from_parts/_copypair, and a Windows UDP wrapper iso field consolidating_read_buf+_read_from. Both compiled. Both were patches around the gap at the top (one FFI call filling two objects that must exit at different caps, against a recover's single-target-cap limit). They are out;pony_os_recvfrom.fromstaystag, tracked, instead.Non-divergences worth stating:
cstring()'s signature is untouched; no compiler or runtime C changes anywhere (pending open question 4); read-onlytagparams (paths, format strings, write/send buffers) are deliberately not churned.Fix patterns
All verified against the compiler with scratch programs before they went in the plan. The recover mechanics the patterns rely on, each verified:
valworks (everything becomes val), but you cannot get two different caps out of one recover — there is no(iso, val)exit, and recovering a multi-object tuple toisois rejected outright. That single-target-cap constraint is why the recvfrom carve-out exists.vars from inside the recover; sendable fields (_fd: U32,_event: AsioEventID) are readable inside it;addressofon an outervarcompiles; non-sendable values must be bound before it.new valconstructors require sendable parameters, andletfields with initializers cannot be constructor-reassigned (both of these are why the rejected copy-machinery needed a scalar parts-constructor — recorded above).isoreceiver cannot call the viewpoint-adaptedcpointer()directly. The admission check (auto_recover_call,src/libponyc/expr/call.c) evaluates the method's formal result type without substituting the call-site receiver into the arrow, sothis->Pointer[U8]fails a check thatPointer[U8] tag— the substituted result for an iso receiver — would pass. A compiler issue for this is filed in step 10; until then,isowrite sites use the consume-into-recover patterns and iso read uses bind a pointer before a recover.The patterns (the mutable pointer is always obtained by calling
cpointer()on arefbinding):recover val let x: T ref = T; @ffi(..., x); x end— the explicitrefannotation matters; an unannotated binding infersisoand the call fails.buf = recover iso let b: Array[U8] ref = consume buf; n = @ffi(..., b.cpointer(), ...); b end— the FFI's scalar result threads out via an outervarwhen the post-fill logic (truncate length, errno checks) needs it.process_monitor.ponyalready uses this swap today.new valconstructors may passthisatrefto FFI — verified to typecheck. This is how theFileInfostat calls stay structurally unchanged.UnsafePointer[X] refparam; holders already have effective-ref handles from bare FFI returns.Step ordering
The
cpointer()signature change (step 1) immediately breaks everyiso-receiver call site, so those restructures must land with it. The reverse dependency does not exist: a restructured call site produces arefpointer, andrefsatisfies the oldtagdeclarations — so step 1 (signature + all call-site dances) leaves the whole tree green, and the per-package steps then flip declaration caps onto call sites that already supplyref. Every step compiles and passes its tests before the next; each step is one commit.Step 1 — the viewpoint-adapted
cpointer()+ every call site it breakspackages/builtin/array.pony:fun cpointer(offset: USize = 0): this->Pointer[A].packages/builtin/string.pony:fun cpointer(offset: USize = 0): this->Pointer[U8].cpointer()documents that the returned pointer carries the receiver's capability and is the pointer to hand to C functions that write (mutable receiver required);cstring()documents its read-only contract and points writers atcpointer().chop/unchopin String and Array through the private_ptrfield (let start_ptr = _ptristagthrough the iso receiver — sendable, crosses into the existing recover;_unsafe()._offset(...)inside). String rewrite verified compiling; Array is isomorphic — verify here.net/_test.pony:893, 900(bind the pointer before the recover).refpointers they now produce still satisfy the oldtagdecls, so the tree stays green):builtin/stdin.pony(P-iso-local, count threaded out);files/file.ponyread/read_string (P-iso-local, four paths);process/_pipe.pony_Pipe.read(itsread_bufis an iso parameter — consume into the recover, thread len/errno out, rebuild the(buf, len, errno)returns outside; the several early-return branches all reshape the same way);net/tcp_connection.pony_queue_read/_pending_reads(P-iso-field on_read_buf);net/udp_socket.ponyPOSIX + Windows buffer sites (P-iso-local / P-iso-field;fromcontinues to pass as today against its unchanged decl);net/ossocket.ponyget_so(P-iso-local, getsockopt result threaded out;option_sizestays an outer var —addressofon an outer var from inside a recover compiles, verified); the three tools' identical_find_exe_directoryhelpers intools/pony-doc/main.pony,tools/pony-lint/main.pony,tools/pony-lsp/compiler_notify.pony(P-iso-local with the FFI'sBoolthreaded out; theString.from_array(consume buf)vsNonebranch stays outside the recover).packages/builtin_test/_test.pony: new testclass \nodoc\ iso _TestCPointerViewpoint, registered in Main's TestList — declare a mutating FFI (e.g.@memsetwith aPointer[U8] refparam), fill an Array and a String throughcpointer()onrefreceivers, assert contents; exercise P-iso-local for an iso buffer. Counterfactual-check each assertion (break it, confirm it fires) before calling the test done. No negative-compile tests: rejection of weak receivers is standard cap typing, not new compiler behavior.build/release/ponyc --pass=expr packages/stdlib, thenmake test-stdlib-release, then (tools were touched)make test-pony-compiler && make test-pony-lint && make test-pony-lsp && make test-pony-doc— deleting the tool test binaries first so the run is against fresh builds.Step 2 — builtin decls
stdin.pony:@pony_os_stdin_read(buffer: Pointer[U8] ref, ...)(site restructured in step 1)._to_string.pony:@snprintf/@_snprintfstrparam →Pointer[U8] ref; call sites switchs.cstring()→s.cpointer()(Divergence 4).std_stream.pony:pony_os_std_flush/print/writefileparam →UnsafePointer[U8] ref(concrete element type restores checking; matches the_streamfield). Call sites unchanged —_streamis already effective ref.build/release/ponyc --pass=expr packages/stdlib, thenmake test-stdlib-release.Step 3 — net
pony_os_sockname/pony_os_peername/pony_os_getaddrip/ipaddr→NetAddress ref;pony_os_recvfrombuffer→Pointer[U8] ref(fromstaysNetAddress tag— the carve-out, with a comment pointing at the problem section above);pony_os_recvbuffer→Pointer[U8] ref;getsockoptoption_value→Pointer[U8] ref;pony_os_nameinfohost/servouter pointers → bare (effective ref);freeaddrinfo→UnsafePointer[_AddrInfo] ref.primitive _AddrInfoand unify the addrinfo handle (Divergence 9):@pony_os_addrinfo[UnsafePointer[_AddrInfo]],@pony_os_nextaddr[UnsafePointer[_AddrInfo]](addr: UnsafePointer[_AddrInfo] tag),@pony_os_getaddr(addr: UnsafePointer[_AddrInfo] tag, ...)(addr is read-only — tag stays),@freeaddrinfo(addr: UnsafePointer[_AddrInfo] ref).dns.pony_resolve(P-out for each NetAddress); tcp_listener/tcp_connectionlocal_address/remote_address(P-out); udp_socket constructorsembed _ip→let _ip: NetAddressvia P-out (Divergence 3); udp POSIX read keeps the step-1 buffer shape,fromkeeps today's shape (the outer iso binding is sendable, so it is referenceable inside the buffer's recover, passes against the unchangedtagdecl, and is consumed toreceived()as before); udp Windows path is P-iso-field on_read_bufonly,_read_fromuntouched.build/release/ponyc --pass=expr packages/stdlib, thenmake test-stdlib-release.Step 4 — files
file_info.pony: stat triofileparam →FileInfo ref(P-this);modefield →let mode: FileMode = FileMode(Divergence 2; docstring updated).file.pony:@read/@_readbuffer→Pointer[U8] ref(concrete type + honest cap; sites restructured in step 1).directory.pony:closedir/FindClosehandle tag → ref;ponyint_unix_readdirdir→UnsafePointer[_DirectoryHandle] ref;FindNextFileAhandle→UnsafePointer[_DirectoryHandle] ref.file_path.pony:@OpenProcessTokentoken_handle→Pointer[USize](typed, effective ref — matches theaddressof tokensite).build/release/ponyc --pass=expr packages/stdlib, thenmake test-stdlib-release.Step 5 — process
_process.pony:@waitpidstat_loc→ barePointer[I32];@ponyint_win_process_createerror_msg→ barePointer[UnsafePointer[U8] val]and the call site switches toString.copy_cstring(Divergence 6). The out-var types asUnsafePointer[U8] val(sendable) so the copy can happen inside arecover val, and the now-redundantrecover message.clone() endgoes away._pipe.pony:@readbuf→Pointer[U8] ref;@ReadFilebuffer→Pointer[U8] ref(sites restructured in step 1;ReadFilehere passes NULLoverlapped— a synchronous read, no retained pointer);@ponyint_win_pipe_createnear_fd/far_fd→ barePointer[U32].build/release/ponyc --pass=expr packages/stdlib, thenmake test-stdlib-release.Step 6 — format, debug, assert, json, iregex
format/_format_float.pony: snprintf twinsstr→Pointer[U8] ref; sites switchcstring()→cpointer()(Divergence 4).debug/debug.pony,assert/assert.pony,json/_mort.pony,iregex/_mort.pony:@pony_os_stdout/stderrreturns →UnsafePointer[U8](they'reFILE*, foreign — a Round 1 leftover fixed where the cap fix already touches the line);@fprintfstream→UnsafePointer[U8] ref(fmtstays tag); debug.pony's_streamhelper return type follows.build/release/ponyc --pass=expr packages/stdlib+make test-stdlib-release.Step 7 — tool frontends
tools/pony-doc/main.pony,tools/pony-lint/main.pony,tools/pony-lsp/compiler_notify.pony:@get_compiler_exe_directoryoutput_path→Pointer[U8] ref(helpers restructured in step 1).tools/{pony-doc,pony-lint,pony-lsp}/_unreachable.pony: same fprintf/stderr one-touch fix as step 6.tools/pony-lsp/main.pony:@pony_os_stdoutreturn →UnsafePointer[U8];@_filenostream→UnsafePointer[U8] tag(reads only — cap stays tag).Built ...-testsline appears, so the run is against fresh builds):make test-pony-compiler && make test-pony-lint && make test-pony-lsp && make test-pony-doc.Step 8 — example, documentation, release notes
examples/ffi-buffer-fill/: a C function fills a PonyArray[U8]throughcpointer(), demonstrating the honest-cap FFI declaration (Pointer[U8] ref), the viewpoint-adapted accessor, the P-out shape, and the P-iso-local shape — companion.cfile and per-example README in the style offfi-struct/; entry added toexamples/README.md.make test-examplesexcludesffi-*examples (they need the companion shared library), so it's verified by a manual gcc + ponyc build, documented in the example README like the other two.packages/builtin/asio_event.pony: docstring onAsioEventIDdocumenting the exception — the runtime mutates event internals through this tag handle; tag is required because events are delivered via behaviors; synchronization is owned by the runtime; do not copy this pattern for ordinary foreign handles..release-notes/): the breaking change —cpointer()now returns a viewpoint-adapted pointer, with a before/after example for theiso-receiver pattern; theFileInfo.modetype change; the guidance change ("FFI declarations for C-written memory take ref;cstring()is for read-only use").Step 9 — full validation
(the tool test binaries deleted first, as above). Windows-only branches (
if windowsdeclarations and sites in directory.pony, _pipe.pony, _process.pony, file_path.pony, _to_string.pony, the lsp main) can't be typechecked on Linux — the draft PR leans on Windows CI for those, and I expect at least one CI iteration there.Step 10 — bookkeeping
CapRights0class-layout fragility the audit turned up (C reads/writes 16 bytes throughaddressof _r0, relying on field adjacency in aclass);File._pending_writevstoring rawcpointer()results without anchoring the ByteSeq for GC across partial writes; the Windows LocalFree leak; the pony_compiler findings (including five C-signature mismatches and asource_contents()use-after-free hazard the audit caught incidentally) and its cap design round.isoreceiver calling the newcpointer()would gettagback, restoring direct read use on iso receivers). Its absence is what forces thechop/unchoprewrites and the iso read-site bindings.What stays imperfect, on purpose
pony_os_recvfrom.fromstaysNetAddress tag— the one knowingly dishonest parameter left. One call fills two objects that must exit at different caps; today's language cannot express that call site honestly, and the in-language workarounds are patches. Tracking comment at the declaration points here.refmakes the at-call claim honest. Noted in code comments and above.addressof,.usize()laundering,USize(0)-as-NULL through the void*-rule, and_unsafe()+from_cpointeradoption (whichchoplegitimately depends on for its sound-by-disjointness zero-copy split). All audited uses are authorized; closing the hatches is future language/lint work.[None]params (e.g. PeekNamedPipe's always-NULL buffer). Element types were tightened where C actually writes; always-NULL and read-only[None]params were left alone.Open questions
pony_os_recvfromis ours: a variant that allocates the buffer C-side withpony_allocand returns{char* buf, size_t len}for Pony to adopt (the establishedpony_os_realpath/ponyint_unix_readdiridiom), withfromconsumed in asisoand handed back as a struct field (C already writes NetAddress through the existingipaddress_tmirror — no new layout coupling), would make this round's only lie unnecessary. It costs one new runtime function and relaxes "no C changes" in exactly one spot we own. The same trick could replace the consume-dance at half a dozen more sites if taken further, at the price of more exported runtime surface — and it does nothing for the general problem, because the world's C still won't return our pointers. Worth it for the one function, or hold the line and let the carve-out stand until the language has an answer?Beta Was this translation helpful? Give feedback.
All reactions