Skip to content
Draft
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
5 changes: 5 additions & 0 deletions src/cli/test/fx_test_specs.zig
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ pub const io_spec_tests = [_]TestSpec{
.io_spec = "0<abc|1>Hello from stdout!|1>Line 1 to stdout|2>Line 2 to stderr|1>Line 3 to stdout|2>Error from stderr!|1>Crypto hashes ok",
.description = "Basic effectful functions: Stdout.line!, Stderr.line!",
},
.{
.roc_file = "test/fx/phantom_capability_parameter.roc",
.io_spec = "1>41",
.description = "Repro for issue 10770: a nominal's phantom parameter survives a call boundary",
},
.{
.roc_file = "test/fx/subdir/app.roc",
.io_spec = "1>Hello from stdout!|1>Line 1 to stdout|2>Line 2 to stderr|1>Line 3 to stdout|2>Error from stderr!",
Expand Down
43 changes: 39 additions & 4 deletions src/postcheck/monotype/lower.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1304,6 +1304,14 @@ fn relateCheckedMonoRequestNodeAt(
.named => |checked_named| switch (request_content) {
.named => |request_named| {
if (sameNamedValueDefinition(checked_named, request_named)) {
// A declaration's arguments are components of the value the
// same way a record's fields are. Relating only the backing
// reaches every argument the backing mentions, so this
// relation exists for the phantom arguments: the ones the
// backing never mentions.
for (checked_named.args, request_named.args) |checked_arg, request_arg| {
try relateCheckedMonoRequestNodeAt(graph, checked_arg, request_arg, row_width, seen);
}
const checked_backing = checked_named.backing orelse {
try graph.unify(checked_root, request_root);
return;
Expand Down Expand Up @@ -5260,7 +5268,12 @@ const Builder = struct {

const stable = switch (view.const_store.get(node)) {
.pending => Common.invariant("pending ConstStore node reached static data eligibility"),
.fn_value => false,
// Static data emits an erased callable as one allocation naming
// its procedure through a relocation, so a capture-free function
// value is fully decided by the ConstStore. A capture record is
// not: its slots carry their own evidence, which this walk has no
// ConstStore node to answer for.
.fn_value => |fn_id| view.const_store.getFn(fn_id).captures.len == 0,
.zst,
.scalar,
.str,
Expand Down Expand Up @@ -19753,6 +19766,17 @@ const BodyContext = struct {
return null;
}

/// Whether a constant restored here can only name what it builds.
///
/// A node reached while an enclosing ConstStore node is still being built
/// restores to a read of that node's binding local, and static data is
/// emitted by a standalone initializer procedure with no such local in
/// scope. A node's own binding is not in this stack by the time its
/// candidate is decided, so only genuinely enclosing ones are counted.
fn constRestorationIsClosed(self: *BodyContext) bool {
return self.draft.active_const_node_bindings.items.len == 0;
}

fn constNodeRepresentationsEql(
self: *BodyContext,
left: ActiveConstNodeRepresentation,
Expand Down Expand Up @@ -29108,7 +29132,8 @@ const BodyContext = struct {
if (!moduleBytesEqual(checked.constModuleId(const_locator).bytes, store_view.key.bytes)) {
Common.invariant("static-data const context referenced a different ConstStore module");
}
if (self.builder.static_data_literals and
if (self.constRestorationIsClosed() and
self.builder.static_data_literals and
try self.builder.constNodeHasStableStaticDataRepresentation(store_view, node) and
self.builder.constNodeMayUseStaticDataCandidate(store_view, node, bare_fn))
{
Expand Down Expand Up @@ -29150,15 +29175,25 @@ const BodyContext = struct {
request_node,
const_locator,
);
if (self.builder.static_data_literals and
if (self.constRestorationIsClosed() and
self.builder.static_data_literals and
try self.builder.constNodeHasStableStaticDataRepresentation(store_view, node) and
self.builder.constNodeMayUseStaticDataCandidate(store_view, node, bare_fn))
{
// A resolved request already names a committed type, so sealing it
// here lets this use share one static allocation with every other
// use of the same constant. An unresolved request keeps its graph
// cell and its own allocation, which stays correct because the
// static data it names is a copy of the same bytes.
const request_cell = if (try self.graph.typeIsResolved(request_node))
DraftTypeCell.fromSealed(try self.resolvedTypeViewForNode(request_node))
else
DraftTypeCell.fromGraphNode(request_node);
const id = try self.builder.staticDataValue(
const_locator,
node,
checked_type,
DraftTypeCell.fromGraphNode(request_node),
request_cell,
);
return try self.addExprWithTypeCell(DraftTypeCell.fromGraphNode(request_node), .{ .static_data_candidate = .{
.static_data = id,
Expand Down
55 changes: 55 additions & 0 deletions test/fx/phantom_capability_parameter.roc
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
app [main!] { pf: platform "./platform/main.roc" }

import pf.Host
import pf.Stdout

Seed : { path : I64 }

# Repro for https://github.com/roc-lang/roc/issues/10770
#
# `Cap(a)` never mentions `a` in its payload, so `a` reaches `new_with_eq` only
# as the declaration argument of the `Cap(a)` it returns. Dropping that argument
# at the call boundary leaves `a` unresolved, and an unresolved type variable
# finalizes as uninhabited, which makes every specialized body below reachable
# only by a runtime error.
Cap(a) := [Cap({ probe! : Box((I64 => I64)) })].{
new : () -> Cap(a)
new = || Cap.new_with_eq(|_left, _right| True)

new_with_eq : (a, a -> Bool) -> Cap(a)
new_with_eq = |is_equal| {
split : Box(a) -> { keep : Box(a), out : Box(a) }
split = |boxed| {
value = Box.unbox(boxed)
{ keep: Box.box(value), out: Box.box(value) }
}
split_handle = Box.box(split)

probe! : I64 => I64
probe! = |offset| {
taken : Box(a)
taken = Host.take_seed!()
parts = Box.unbox(split_handle)(taken)
left = Box.unbox(parts.out)
_ = parts.keep
if is_equal(left, left) { offset } else { offset + 1 }
}

Cap({ probe!: Box.box(probe!) })
}

probe_of : Cap(a) -> Box((I64 => I64))
probe_of = |Cap(handle)| handle.probe!
}

main! = || {
seed : Seed
seed = { path: 1 }
Host.store_seed!(Box.box(seed))

cap : Cap(Seed)
cap = Cap.new()
probe! = Box.unbox(Cap.probe_of(cap))

Stdout.line!(probe!(41).to_str())
}
9 changes: 9 additions & 0 deletions test/fx/platform/Host.roc
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,15 @@ Host :: {
## Return a host callable that consumes the fifth ABI argument when called.
boxed_transition! : I64 => Box(UnitToBoxedUnitToI64)

## Store any boxed Roc value in the host, which owns it until `take_seed!`
## hands it back. The host never inspects it, so its type survives only in
## Roc's type system, the way a platform's opaque host-owned values do.
store_seed! : Box(a) => {}

## Return the boxed value `store_seed!` was given. The result type is the
## only place `a` appears, so nothing at a call site constrains it.
take_seed! : () => Box(a)

## Store a boxed function in the host by incrementing its outer refcount.
store_boxed! : Box(I64ToI64) => {}

Expand Down
29 changes: 29 additions & 0 deletions test/fx/platform/host.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1313,6 +1313,33 @@ fn hostedHostRoundtripBoxed(boxed: ?[*]u8) callconv(.c) ?[*]u8 {
return boxed;
}

/// The one boxed Roc value `store_seed!` is holding for `take_seed!`.
/// Ownership moves in on the store and back out on the take, so the host never
/// needs to know the value's type to keep its refcount balanced.
var stored_seed: ?[*]u8 = null;

fn hostedHostStoreSeed(boxed: ?[*]u8) callconv(.c) void {
const ops = g_roc_ops.?;
if (stored_seed != null) {
ops.crash("host was given a second seed while still holding one");
unreachable;
}
stored_seed = boxed orelse {
ops.crash("host was given a null seed");
unreachable;
};
}

fn hostedHostTakeSeed() callconv(.c) ?[*]u8 {
const ops = g_roc_ops.?;
const seed = stored_seed orelse {
ops.crash("host was asked for a seed it was never given");
unreachable;
};
stored_seed = null;
return seed;
}

fn hostedHostStoreBoxed(boxed: ?[*]u8) callconv(.c) void {
const ops = g_roc_ops.?;
if (stored_boxed_callable) |prev| {
Expand Down Expand Up @@ -1409,6 +1436,8 @@ comptime {
@export(&hostedHostRoundtripBoxed, .{ .name = "roc_host_roundtrip_boxed", .visibility = .hidden });
@export(&hostedHostBoxedTransition, .{ .name = "roc_host_boxed_transition", .visibility = .hidden });
@export(&hostedHostStoreBoxed, .{ .name = "roc_host_store_boxed", .visibility = .hidden });
@export(&hostedHostStoreSeed, .{ .name = "roc_host_store_seed", .visibility = .hidden });
@export(&hostedHostTakeSeed, .{ .name = "roc_host_take_seed", .visibility = .hidden });
@export(&hostedHostStoredBoxedCall, .{ .name = "roc_host_stored_boxed_call", .visibility = .hidden });
@export(&hostedHostSumStrBytes, .{ .name = "roc_host_sum_str_bytes", .visibility = .hidden });
@export(&hostedPaddedCheck, .{ .name = "roc_padded_check", .visibility = .hidden });
Expand Down
2 changes: 2 additions & 0 deletions test/fx/platform/main.roc
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ platform ""
"roc_host_roundtrip_boxed": Host.roundtrip_boxed!,
"roc_host_boxed_transition": Host.boxed_transition!,
"roc_host_store_boxed": Host.store_boxed!,
"roc_host_store_seed": Host.store_seed!,
"roc_host_take_seed": Host.take_seed!,
"roc_host_stored_boxed_call": Host.stored_boxed_call!,
"roc_host_sum_str_bytes": Host.sum_str_bytes!,
"roc_padded_check": Padded.check!,
Expand Down
45 changes: 32 additions & 13 deletions test/glue/fx_platform_cglue_expected.h
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,7 @@ typedef void (*HostedFn)(void);

// Hosted Function Count

#define HOSTED_FUNCTION_COUNT 20
#define HOSTED_FUNCTION_COUNT 22


#define HOSTED_IDX_BUILDER_PRINT_VALUE 0
Expand All @@ -348,12 +348,14 @@ typedef void (*HostedFn)(void);
#define HOSTED_IDX_HOST_RESET_BOXED_DROP_REPORT 11
#define HOSTED_IDX_HOST_ROUNDTRIP_BOXED 12
#define HOSTED_IDX_HOST_STORE_BOXED 13
#define HOSTED_IDX_HOST_STORED_BOXED_CALL 14
#define HOSTED_IDX_HOST_SUM_STR_BYTES 15
#define HOSTED_IDX_PADDED_CHECK 16
#define HOSTED_IDX_STDERR_LINE 17
#define HOSTED_IDX_STDIN_LINE 18
#define HOSTED_IDX_STDOUT_LINE 19
#define HOSTED_IDX_HOST_STORE_SEED 14
#define HOSTED_IDX_HOST_STORED_BOXED_CALL 15
#define HOSTED_IDX_HOST_SUM_STR_BYTES 16
#define HOSTED_IDX_HOST_TAKE_SEED 17
#define HOSTED_IDX_PADDED_CHECK 18
#define HOSTED_IDX_STDERR_LINE 19
#define HOSTED_IDX_STDIN_LINE 20
#define HOSTED_IDX_STDOUT_LINE 21

// Argument Structures

Expand Down Expand Up @@ -481,6 +483,15 @@ typedef struct {
RocErasedCallable arg0;
} HostStoreBoxedArgs;

/**
* Arguments for Host.store_seed!
* Roc signature: Box(rigid) => {}
* Refcounted fields are owned by the hosted function.
*/
typedef struct {
RocBox arg0;
} HostStoreSeedArgs;

/**
* Arguments for Host.stored_boxed_call!
* Roc signature: I64 => I64
Expand Down Expand Up @@ -585,12 +596,18 @@ extern RocErasedCallable roc_host_roundtrip_boxed(RocErasedCallable arg0);
/* Host.store_boxed!: Box(I64 -> I64) => {} */
extern void roc_host_store_boxed(RocErasedCallable arg0);

/* Host.store_seed!: Box(rigid) => {} */
extern void roc_host_store_seed(RocBox arg0);

/* Host.stored_boxed_call!: I64 => I64 */
extern int64_t roc_host_stored_boxed_call(int64_t arg0);

/* Host.sum_str_bytes!: List(Str) => U64 */
extern uint64_t roc_host_sum_str_bytes(RocList arg0);

/* Host.take_seed!: {} => Box(rigid) */
extern RocBox roc_host_take_seed(void);

/* Padded.check!: Padded => Str */
extern RocStr roc_padded_check(Padded arg0);

Expand Down Expand Up @@ -631,12 +648,14 @@ typedef struct {
HostedFn host_reset_boxed_drop_report_bang; /* index 11, C name: host_reset_boxed_drop_report */
HostedFn host_roundtrip_boxed_bang; /* index 12, C name: host_roundtrip_boxed */
HostedFn host_store_boxed_bang; /* index 13, C name: host_store_boxed */
HostedFn host_stored_boxed_call_bang; /* index 14, C name: host_stored_boxed_call */
HostedFn host_sum_str_bytes_bang; /* index 15, C name: host_sum_str_bytes */
HostedFn padded_check_bang; /* index 16, C name: padded_check */
HostedFn stderr_line_bang; /* index 17, C name: stderr_line */
HostedFn stdin_line_bang; /* index 18, C name: stdin_line */
HostedFn stdout_line_bang; /* index 19, C name: stdout_line */
HostedFn host_store_seed_bang; /* index 14, C name: host_store_seed */
HostedFn host_stored_boxed_call_bang; /* index 15, C name: host_stored_boxed_call */
HostedFn host_sum_str_bytes_bang; /* index 16, C name: host_sum_str_bytes */
HostedFn host_take_seed_bang; /* index 17, C name: host_take_seed */
HostedFn padded_check_bang; /* index 18, C name: padded_check */
HostedFn stderr_line_bang; /* index 19, C name: stderr_line */
HostedFn stdin_line_bang; /* index 20, C name: stdin_line */
HostedFn stdout_line_bang; /* index 21, C name: stdout_line */
} HostedFunctions;


Expand Down
9 changes: 9 additions & 0 deletions test/provided-callable-host/app.roc
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ app [
make_boxed_callable,
drop_boxed_callable,
make_aliased_boxed_callables,
make_shared_boxed_callables,
drop_aliased_boxed_callables,
] { pf: platform "./platform/main.roc" }

Expand Down Expand Up @@ -33,5 +34,13 @@ make_aliased_boxed_callables = || {
Box.box({ first: boxed, second: boxed })
}

# The same requirement for a top-level binding, which every reference reads
# rather than rebuilding.
shared_probe : Box(U64 -> U64)
shared_probe = identity_probe()

make_shared_boxed_callables : () -> Box(AliasedCallables)
make_shared_boxed_callables = || Box.box({ first: shared_probe, second: shared_probe })

drop_aliased_boxed_callables : Box(AliasedCallables) -> {}
drop_aliased_boxed_callables = |_callables| {}
40 changes: 24 additions & 16 deletions test/provided-callable-host/platform/host.zig
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const HostEnv = struct {
extern fn roc_make_boxed_callable(offset: u64) callconv(.c) ?[*]u8;
extern fn roc_drop_boxed_callable(callable: ?[*]u8) callconv(.c) void;
extern fn roc_make_aliased_boxed_callables() callconv(.c) ?[*]u8;
extern fn roc_make_shared_boxed_callables() callconv(.c) ?[*]u8;
extern fn roc_drop_aliased_boxed_callables(callables: ?[*]u8) callconv(.c) void;

/// Host view of the app's `{ first : Box(U64 -> U64), second : Box(U64 -> U64) }`.
Expand Down Expand Up @@ -119,23 +120,30 @@ fn main(argc: c_int, argv: [*][*:0]u8) callconv(.c) c_int {

// One boxed callable held by two record fields is one heap allocation, so
// both fields must reach the host as the same erased-callable pointer.
const aliased_ptr = roc_make_aliased_boxed_callables() orelse {
std.debug.print("provided aliased callable maker returned null\n", .{});
return 1;
};
if (host_env.alloc_count == 0) {
std.debug.print("provided aliased callable maker did not allocate\n", .{});
return 1;
}
const aliased: *const AliasedCallables = @ptrCast(@alignCast(aliased_ptr));
const first = aliased.first;
const second = aliased.second;
roc_drop_aliased_boxed_callables(aliased_ptr);

var failed = false;
if (first != second) {
std.debug.print("provided aliased callables arrived as {?*} and {?*}\n", .{ first, second });
failed = true;
const makers = [_]struct { name: []const u8, make: *const fn () callconv(.c) ?[*]u8 }{
.{ .name = "aliased", .make = &roc_make_aliased_boxed_callables },
.{ .name = "shared", .make = &roc_make_shared_boxed_callables },
};
for (makers) |maker| {
const before_allocs = host_env.alloc_count;
const aliased_ptr = maker.make() orelse {
std.debug.print("provided {s} callable maker returned null\n", .{maker.name});
return 1;
};
if (host_env.alloc_count == before_allocs) {
std.debug.print("provided {s} callable maker did not allocate\n", .{maker.name});
return 1;
}
const aliased: *const AliasedCallables = @ptrCast(@alignCast(aliased_ptr));
const first = aliased.first;
const second = aliased.second;
roc_drop_aliased_boxed_callables(aliased_ptr);

if (first != second) {
std.debug.print("provided {s} callables arrived as {?*} and {?*}\n", .{ maker.name, first, second });
failed = true;
}
}
if (host_env.dealloc_count != host_env.alloc_count) {
std.debug.print("provided aliased callable drop released {d} of {d} allocations\n", .{
Expand Down
Loading
Loading