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
15 changes: 15 additions & 0 deletions docs/limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -866,6 +866,21 @@ String-keyed, String-valued type. The promotion preserves the Hash and stored
String identities through callers and ivars. RBS-pinned returns and methods
with nonempty or alternate Hash producers keep their existing inferred types.

With `--share-strings`, a boxed call may also compose an exact String builtin
row with same-named user-method returns when the settled dispatch plan accounts
for both. For example, a blockless two-argument `String#sub` can return a fresh
String through its String arm while a same-named user method returns `nil`;
matched and unmatched substitutions both keep their String result separate
from the receiver. A builtin row's argument peeks also cannot discard values
retained by its same-named user arm. The analysis still joins every reachable
user return, so a user arm that returns an existing String retains that alias
and any required mutation refusal. Ambiguous builtin or dynamic dispatch
remains conservative. If the program declares singleton readers or writers,
this composition precision is also disabled: a user method can reach class-side
publication through a helper even when that accessor is absent from the call's
dispatch plan, and those holder edges are not fully represented here. Such
programs retain the previous boxed-container effects.

Not yet shared:

- String `self` handed to a mutating block through `yield` or `block.call` in the default build. Build with `--share-strings` to keep receiver mutations through this route. Read-only blocks and fresh String returns work in both builds;
Expand Down
302 changes: 280 additions & 22 deletions src/analyze_share.c

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions src/call_plan.c
Original file line number Diff line number Diff line change
Expand Up @@ -1897,6 +1897,19 @@ static const PolyPlan *cplan_poly_level(Compiler *c, int id, int full) {
return p;
}

const PolyPlan *cplan_poly_fresh(Compiler *c, int id) {
static PolyPlan fresh;
if (id < 0 || id >= c->node_cap) { free(fresh.arm); fresh.arm = NULL; fresh.n = 0; return &fresh; }
#ifndef NDEBUG
int tmp0 = g_tmp;
#endif
cpoly_resolve(c, id, &fresh, 1);
#ifndef NDEBUG
assert(g_tmp == tmp0); /* resolving emits nothing */
#endif
return &fresh;
}

const PolyPlan *cplan_poly_block(Compiler *c, int id) {
static PolyPlan bp;
free(bp.arm);
Expand Down
4 changes: 4 additions & 0 deletions src/call_plan.h
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,10 @@ const PolyPlan *cplan_poly(Compiler *c, int id);
receiver form). Cheaper: the builtin families and trials, which only the
--plan-check shadow compares, are left out. */
const PolyPlan *cplan_poly_arms(Compiler *c, int id);
/* Resolve the complete plan without reading or writing the per-node memo.
Use while inference facts are still moving; the scratch plan is overwritten
by the next fresh resolve, so finish reading it before another planner call. */
const PolyPlan *cplan_poly_fresh(Compiler *c, int id);
int cplan_struct_aset(Compiler *c, int cid, const char *name, int argc);
/* A plan the caller keeps across emissions that may resolve others (a
resolve outside the memo reuses one buffer); cplan_poly_free drops it. */
Expand Down
26 changes: 26 additions & 0 deletions test/share/boxed_pure_user_arm_keeps_nested_argument.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# String#delete_prefix has a pure boxed String row. This mixed call's user
# arm keeps the Array argument and mutates its nested String after the call.
# The zero-argument test run selects Params while retaining String in the
# receiver expression, so the boxed builtin peek must not erase that flow.
# spinel: share
class Params
def delete_prefix(prefix)
@saved_prefix = prefix
nil
end

def saved_prefix
@saved_prefix
end
end

params = Params.new
source = String.new("captured")
receiver = [params, String.new("unused")][ARGV.length]
receiver.delete_prefix([source])
if ARGV.empty?
params.saved_prefix[0] << "!"
puts source
else
puts "string"
end
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
captured!
34 changes: 34 additions & 0 deletions test/share/boxed_sub_class_value_return_keeps_nested_string.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# spinel: share
# A boxed String/class/instance receiver union must retain a nested String
# returned from a reachable class-value Hash arm.
class Params
class << self
attr_accessor :payload

def sub(pattern, replacement)
{ "payload" => @payload }
end
end
end

class OtherArm
def sub(pattern, replacement)
nil
end
end

source = String.new("https://")
Params.payload = source
seed = Params.sub("seed", "")
puts "seed_payload_is_source=#{seed.fetch("payload").equal?(source)}"

[source, Params, OtherArm.new].each do |receiver|
result = receiver.sub("://", "")
if result.is_a?(Hash)
nested = result.fetch("payload")
puts "nested_is_source=#{nested.equal?(source)}"
nested << "!"
elsif result.is_a?(String)
result << "!"
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
seed_payload_is_source=true
nested_is_source=true
22 changes: 22 additions & 0 deletions test/share/boxed_sub_fresh_with_user_arm.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# A same-named but unreachable user method stays in the program while the
# String receiver's sub result is used. The unrelated Thread.value mutation
# must still see the shared String, and a non-matching sub must remain fresh.
# spinel: share
module Params
def self.sub(a, b)
nil
end
end

protocol = Thread.new { "https://" }.value.sub("://", "")
trigger = String.new("trigger")
Thread.new { trigger }.value << "-after"

no_match_source = Thread.new { String.new("plain") }.value
no_match = no_match_source.sub("://", "!")
no_match << "!"

puts protocol
puts trigger
puts no_match_source
puts no_match
4 changes: 4 additions & 0 deletions test/share/boxed_sub_fresh_with_user_arm.rb.expected
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
https
trigger-after
plain
plain!
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# spinel: share
# The outer boxed call has only String and instance-method arms. The instance
# arm returns a Hash from a class helper that reads a published class ivar;
# Payload intentionally defines no class-side `sub` method.
class Payload
class << self
attr_accessor :payload

def box
{ "payload" => @payload }
end
end
end

class OtherArm
def sub(pattern, replacement)
Payload.box
end
end

source = String.new("https://")
Payload.payload = source

[source, OtherArm.new].each do |receiver|
result = receiver.sub("://", "")
if result.is_a?(Hash)
nested = result.fetch("payload")
puts "nested_is_source=#{nested.equal?(source)}"
nested << "!"
elsif result.is_a?(String)
result << "!"
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
nested_is_source=true
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# spinel: share
# The outer boxed call has only String and instance-method arms. A helper
# publishes its class ivar through an explicit class method, without readers
# or writers generated by attr_accessor.
class Payload
class << self
def put(value)
@payload = value
end

def box
{ "payload" => @payload }
end
end
end

class OtherArm
def sub(pattern, replacement)
Payload.box
end
end

source = String.new("https://")
Payload.put(source)

[source, OtherArm.new].each do |receiver|
result = receiver.sub("://", "")
if result.is_a?(Hash)
nested = result.fetch("payload")
puts "nested_is_source=#{nested.equal?(source)}"
nested << "!"
elsif result.is_a?(String)
result << "!"
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
nested_is_source=true
65 changes: 65 additions & 0 deletions test/share/boxed_sub_user_arms_run_both.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# One call site runs both String#sub and same-named user methods. Its user
# arms return nil, a fresh String, a borrowed String, or a container holding
# a borrowed String; those return facts must remain intact beside the builtin.
# spinel: share
class ParamsNil
def sub(pattern, replacement)
nil
end
end

class ParamsFresh
def sub(pattern, replacement)
String.new("fresh")
end
end

class ParamsBorrowed
attr_reader :returned

def initialize
@returned = String.new("borrowed")
end

def sub(pattern, replacement)
@returned
end
end

class ParamsContainer
attr_reader :returned

def initialize
@returned = String.new("container")
end

def sub(pattern, replacement)
[@returned]
end
end

def invoke_sub(receiver)
receiver.sub("://", "!")
end

source = Thread.new { "https://" }.value
string_result = invoke_sub(source)
string_result << "!"
puts source
puts string_result

puts invoke_sub(ParamsNil.new).nil?

fresh_result = invoke_sub(ParamsFresh.new)
fresh_result << "!"
puts fresh_result

borrowed = ParamsBorrowed.new
borrowed_result = invoke_sub(borrowed)
borrowed_result << "!"
puts borrowed.returned

container = ParamsContainer.new
container_result = invoke_sub(container)
container_result[0] << "!"
puts container.returned
6 changes: 6 additions & 0 deletions test/share/boxed_sub_user_arms_run_both.rb.expected
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
https://
https!!
true
fresh!
borrowed!
container!
Loading