Stream: t-compiler

Topic: function pointers and address spaces


Jake Goulding (Jun 09 2019 at 17:25, on Zulip):

Over in AVR-land, we recently hit an issue about casting function pointers. It immediately affected us for printing function pointers with Debug, but it also occurs with futures-related stuff, such as this simplified Rust code:

fn alpha(i: i32) -> i32 { i + 1 }
static FOO: fn(i32) -> i32 = alpha;

This causes

Assertion failed: (CastInst::castIsValid(Instruction::BitCast, C, DstTy) && "Invalid constantexpr bitcast!"), function getBitCast, file /Users/shep/Projects/avr-rust/src/llvm-project/llvm/lib/IR/Constants.cpp, line 1776.

Because the address spaces don't match:

if (SrcPtrTy->getAddressSpace() != DstPtrTy->getAddressSpace())
Jake Goulding (Jun 09 2019 at 17:26, on Zulip):

This is the information we've gathered, but I'm not sure what to do next, or even what questions to ask next, so I figured I'd cast a wide net here and see if anyone had any suggestions.

RalfJ (Jun 10 2019 at 09:23, on Zulip):

wow, so on AVR the address space for fn ptrs is different that for normal ptrs?

RalfJ (Jun 10 2019 at 09:23, on Zulip):

embedded stuff is wild^^

Jake Goulding (Jun 10 2019 at 15:16, on Zulip):

@RalfJ right, because AVR is Harvard architecture, not von Neumann (see also https://github.com/avr-rust/rust/issues/53)

Jake Goulding (Jun 10 2019 at 15:16, on Zulip):

So the code lives in a different address space "PROGMEM"

RalfJ (Jun 10 2019 at 15:51, on Zulip):

looks like first and foremost a compiler issue to me

RalfJ (Jun 10 2019 at 15:51, on Zulip):

but this also means you cannot cast fn ptrs to raw ptrs...

RalfJ (Jun 10 2019 at 15:52, on Zulip):

rustc would have to set the "function" address space for fn types, I guess

Jake Goulding (Jun 10 2019 at 16:17, on Zulip):

you cannot cast fn ptrs to raw ptrs

Right, LLVM dies. We had to add a hack for this right now:

fmt::Pointer::fmt(&(*self as usize as *const ()), f)
//                        ^^^^^^^^

rustc would have to set the "function" address space for fn types

I think it does sometimes, which is why this is causing a problem. I think we need to find the piece of code that is responsible for casting function pointers and enhance it to preserve the address space for the source on the destination (maybe?)

Jake Goulding (Jun 10 2019 at 16:18, on Zulip):

I'm not sure which compiler devs have experience in this area. I want to ping eddyb because I ping them for everything, but figured I'd wait a little while to see if anyone else has knowledge.

nikomatsakis (Jun 10 2019 at 20:11, on Zulip):

@nagisa might have an idea, or @Nikita Popov

nagisa (Jun 11 2019 at 00:51, on Zulip):

I have knowledge of address spaces but no idea why this specifically fails. it may be possible that all our statics assume data address space

nagisa (Jun 11 2019 at 00:52, on Zulip):

regardless of their type

nagisa (Jun 11 2019 at 00:52, on Zulip):

that being said I’m fairly confident our backend does not deal with address spaces at ALL

nagisa (Jun 11 2019 at 00:53, on Zulip):

I think I remember some murmur about somebody doing something in that direction but I haven’t heard anything about it since

Jake Goulding (Jun 11 2019 at 11:55, on Zulip):

@nagisa do you think there's a "small" part of the compiler you could point us to that deals with the address spaces of such things?

Nikita Popov (Jun 11 2019 at 20:43, on Zulip):

From a quick search, we have https://github.com/rust-lang/rust/blob/49d139c64b69ec5289f9f81db885ecfc2c7a8366/src/librustc_codegen_llvm/abi.rs#L365 and https://github.com/rust-lang/rust/blob/49d139c64b69ec5289f9f81db885ecfc2c7a8366/src/librustc_codegen_llvm/type_.rs#L308.

Nikita Popov (Jun 11 2019 at 20:51, on Zulip):

Possibly the code in https://github.com/rust-lang/rust/blob/da9ebc828c982d2ed49396886da85011e1b0a6c0/src/librustc_codegen_ssa/mir/rvalue.rs#L167 is relevant, it deals a lot with casting around function types.

Jake Goulding (Jun 12 2019 at 15:07, on Zulip):

@eddyb saw my Discord ramblings:

the i8* is coming from us lowering miri allocations to LLVM

it wouldn't be that hard to change the addrspace when lowering a pointer to a function, as opposed to a data pointer (cc oli)
https://github.com/rust-lang/rust/blob/c4797fa4f4a696b183b3aa1517ee22c78d0f5d7a/src/librustc_codegen_llvm/common.rs#L322
so there's a cast here, where type_i8p shouldn't always be the same addrspace: https://github.com/rust-lang/rust/blob/c4797fa4f4a696b183b3aa1517ee22c78d0f5d7a/src/librustc_codegen_llvm/common.rs#L331

and then here, either check self.tcx.alloc_map.lock().get(alloc_id) for GlobalAlloc::Function or avoid the cast in scalar_to_backend by not passing in a type to scalar_to_backend (and so move the cast at the end of scalar_to_backend to its other 2 callers) https://github.com/rust-lang/rust/blob/c4797fa4f4a696b183b3aa1517ee22c78d0f5d7a/src/librustc_codegen_llvm/consts.rs#L50

Jake Goulding (Jun 12 2019 at 15:10, on Zulip):

Thank you @Nikita Popov ! Now I have to try and act on some of this :innocent:

eddyb (Jun 12 2019 at 15:13, on Zulip):

ftr the hint was i8* in a static

oli (Jun 12 2019 at 15:14, on Zulip):

yea, in https://github.com/rust-lang/rust/blob/c4797fa4f4a696b183b3aa1517ee22c78d0f5d7a/src/librustc_codegen_llvm/common.rs#L322 we could just return early instead of falling through to the cast

Jake Goulding (Jun 12 2019 at 15:28, on Zulip):

Y'all are the best. I'm gonna give this a try this evening

Jake Goulding (Jun 12 2019 at 15:28, on Zulip):

:heart:

eddyb (Jun 12 2019 at 16:21, on Zulip):

@oli you can't not obey the cast

eddyb (Jun 12 2019 at 16:22, on Zulip):

it's just that for one caller, no cast is actually needed

Jake Goulding (Jun 13 2019 at 00:32, on Zulip):

either check self.tcx.alloc_map.lock().get(alloc_id) for GlobalAlloc::Function

@eddyb an what would I do when I know it's a function? You mentioned:

no cast is actually needed

but I still need to do some cast, you said?

eddyb (Jun 13 2019 at 19:03, on Zulip):

@Jake Goulding no cast is needed when putting the pointer into a new allocation. but is needed for the other calls of that method

eddyb (Jun 13 2019 at 19:04, on Zulip):

like, one scalar_to_backend call doesn't need to do the cast. the one that always passes i8p

eddyb (Jun 13 2019 at 19:11, on Zulip):

could put Option around that &'ll Type argument, that'd be the simplest change I think?

Jake Goulding (Jun 13 2019 at 19:28, on Zulip):

@eddyb in librustc_codegen_llvm there's only one call to scalar_to_backend at all (there are other calls in librustc_codegen_ssa to a different function of the same name though)

Jake Goulding (Jun 13 2019 at 19:29, on Zulip):

Or am I getting confused by traits

Jake Goulding (Jun 13 2019 at 19:32, on Zulip):

Let's assume the latter. So the function is passed an Option; what do I do when it's None?

Jake Goulding (Jun 13 2019 at 19:34, on Zulip):

Ultimately, this code needs to have the llty:

let llval = unsafe { llvm::LLVMConstInBoundsGEP(
    self.const_bitcast(base_addr, self.type_i8p()),
    &self.const_usize(ptr.offset.bytes()),
    1,
) };
if layout.value != layout::Pointer {
    unsafe { llvm::LLVMConstPtrToInt(llval, llty) }
} else {
    self.const_bitcast(llval, llty)
}
Jake Goulding (Jun 13 2019 at 19:44, on Zulip):

For example, using your earlier comment about GlobalAlloc::Function, I had this scaffolding

Jake Goulding (Jun 13 2019 at 19:46, on Zulip):

Do you mean it's literally just self.const_bitcast(base_addr, self.type_i8p()), that isn't needed?

eddyb (Jun 13 2019 at 19:47, on Zulip):

@Jake Goulding there is only one method with that name, and 3-4 calls to it

eddyb (Jun 13 2019 at 19:47, on Zulip):

it's a trait, yes, and rustc_codegen_ssa code calls it

eddyb (Jun 13 2019 at 19:48, on Zulip):

what I mean is literally just skip this:

if layout.value != layout::Pointer {
    unsafe { llvm::LLVMConstPtrToInt(llval, llty) }
} else {
    self.const_bitcast(llval, llty)
}
Jake Goulding (Jun 13 2019 at 19:48, on Zulip):

yeah, I got confused by ripgrepping for fn whatever and seeing two "definitions" and forgetting that traits existed at all

eddyb (Jun 13 2019 at 19:48, on Zulip):

that's a flexible cast (because LLVM's constants cast constructors are broken)

eddyb (Jun 13 2019 at 19:48, on Zulip):

so instead of returning cast(llval, llty) you'd be returning just llval

Jake Goulding (Jun 13 2019 at 19:49, on Zulip):

But the LLVM assertion is coming from self.const_bitcast(base_addr, self.type_i8p()),

Jake Goulding (Jun 13 2019 at 19:49, on Zulip):

the lines before that if/else

eddyb (Jun 13 2019 at 19:49, on Zulip):

it's an assertion?!

eddyb (Jun 13 2019 at 19:49, on Zulip):

my bad

eddyb (Jun 13 2019 at 19:49, on Zulip):

okay I see, there are two casts you'd need to skip

eddyb (Jun 13 2019 at 19:50, on Zulip):

@Jake Goulding for that side, skip the cast & GEPi when ptr.offset.bytes() is 0

eddyb (Jun 13 2019 at 19:50, on Zulip):

since it's a noop then

Jake Goulding (Jun 13 2019 at 19:50, on Zulip):

Sorry, I thought I had pasted the original issue

eddyb (Jun 13 2019 at 19:50, on Zulip):

nah I just can't read even if you did :P

eddyb (Jun 13 2019 at 19:50, on Zulip):

anyway that will always be 0 for functions

eddyb (Jun 13 2019 at 19:51, on Zulip):

and you still need the Option thing to avoid the next cast, I just didn't have the whole solution :P

Jake Goulding (Jun 13 2019 at 19:53, on Zulip):

And just return... base_addr ?

eddyb (Jun 13 2019 at 19:56, on Zulip):

@Jake Goulding yupp!

eddyb (Jun 13 2019 at 19:56, on Zulip):

that's the address of the function, in your case

eddyb (Jun 13 2019 at 19:56, on Zulip):

@Jake Goulding it'd be easy to make it conditional changes of a llval variable, btw

eddyb (Jun 13 2019 at 19:56, on Zulip):

we do that in some places

Jake Goulding (Jun 13 2019 at 20:00, on Zulip):

You mean like

let mut llvar = foo;
if condition { llvar = thing(llvar) }
eddyb (Jun 13 2019 at 20:02, on Zulip):

llval but yes :P

eddyb (Jun 13 2019 at 20:02, on Zulip):

Zulip is acting up wow

Jake Goulding (Jun 14 2019 at 01:38, on Zulip):

Well I'll be. It compiled and my code compiled. Wonder if anything works.

Jake Goulding (Jun 15 2019 at 01:20, on Zulip):

@eddyb so, function pointers do appear to work on AVR, as to those stashed in a static; so thank you!

Jake Goulding (Jun 15 2019 at 01:47, on Zulip):

except that the optimizer might be ignoring all of the function pointers...

Jake Goulding (Jun 15 2019 at 01:49, on Zulip):

And it is.

Jake Goulding (Jun 15 2019 at 01:49, on Zulip):

Any idea how to prevent that optimization?

Jake Goulding (Jun 15 2019 at 01:53, on Zulip):
    #[inline(never)]
    fn gamma() {
        SuperSerial.write_str("From gamma\r\n");
        if let Some(f) = unsafe { MY_FN } {
            beta(f);
        }
    }

    static mut MY_FN: Option<fn(&str)> = None;

    unsafe { MY_FN = Some(alpha); }

Seems to do the trick.

Jake Goulding (Jun 15 2019 at 01:54, on Zulip):

OK, now the next step is to figure out why async is totally broken

centril (Jun 15 2019 at 01:54, on Zulip):

@Jake Goulding is this relying on #[inline(never)]?

Jake Goulding (Jun 15 2019 at 01:55, on Zulip):

@centril yes, it looks like at least one of the 3 is needed

centril (Jun 15 2019 at 01:55, on Zulip):

To my knowledge, #[inline(whatever)] has no operational guarantees and is strictly a hint

Jake Goulding (Jun 15 2019 at 01:57, on Zulip):

Oh, I don't actually care about if this uses a function pointer in the real code or not, just that it's possible for the compiler in the current state to actually correctly codegen function pointers and use them

Jake Goulding (Jun 15 2019 at 01:57, on Zulip):

I think that the inline helps because of the splitting of the Some and the if let

centril (Jun 15 2019 at 01:57, on Zulip):

ah

Jake Goulding (Jun 15 2019 at 01:58, on Zulip):

So theoretically, I could do some strange thing like `write_volatile(...), if read_volatile(...) { val = Some(...) }

Jake Goulding (Jun 15 2019 at 01:58, on Zulip):

and assume that would be opaque enough for the optimizer

Jake Goulding (Jun 15 2019 at 01:59, on Zulip):

anyway, off to bed, I'll spend another 10 minutes on this tomorrow :wink:

eddyb (Jun 16 2019 at 17:01, on Zulip):

except that the optimizer might be ignoring all of the function pointers...

hmm? I don't understand what that means or why your hack might work

eddyb (Jun 16 2019 at 17:01, on Zulip):

are you exporting that static?

Jake Goulding (Jun 16 2019 at 17:19, on Zulip):

My test was basically (I forget exactly)

static FOO: Option<fn()> = Some(whatever);
if let Some(f) = FOO { f() }

And the optimizer said "nope, not actually an Option cause it's always there, nice try"

eddyb (Jun 16 2019 at 17:20, on Zulip):

@Jake Goulding sorry but I don't know what you mean with regards to what the optimizer did :P

Jake Goulding (Jun 16 2019 at 17:21, on Zulip):

It never created a function pointer, it inlined whatever and removed the conditional from the if let

eddyb (Jun 16 2019 at 17:21, on Zulip):

@Jake Goulding also, yes, that Option is redundant, just like it would be in a const

Jake Goulding (Jun 16 2019 at 17:22, on Zulip):

thus my "test" for does this compiler handle function pointers never actually generated a function pointer

eddyb (Jun 16 2019 at 17:23, on Zulip):

if let Some(f) = *black_box(&FOO) { f() } should do

eddyb (Jun 16 2019 at 17:24, on Zulip):

(assuming you have access to that)

Jake Goulding (Jun 16 2019 at 17:24, on Zulip):

As I mentioned in passing, I got it to generate a function pointer by splitting the setting of the static and using the static in different (non-inlined) functions.

eddyb (Jun 16 2019 at 17:25, on Zulip):

if this is for a test in rust-lang/rust, I'd prefer the direct approach

Jake Goulding (Jun 16 2019 at 17:25, on Zulip):

Test... hmm.

eddyb (Jun 16 2019 at 17:25, on Zulip):

I thought this was a test?

Jake Goulding (Jun 16 2019 at 17:25, on Zulip):

I mean, it's me generating code and looking at the results of objdump

eddyb (Jun 16 2019 at 17:26, on Zulip):

keep in mind the original source of this is a manual vtable, so static FOO: fn(...) or even const FOO: &fn(...) is the closest to the original problem

Jake Goulding (Jun 16 2019 at 17:26, on Zulip):

I frankly have no idea how to actually write tests for this

Jake Goulding (Jun 16 2019 at 17:26, on Zulip):

I basically disassembled the function and looked for an icall instruction

eddyb (Jun 16 2019 at 17:26, on Zulip):

yeah I guess run-pass is out of the question :/

Jake Goulding (Jun 16 2019 at 17:27, on Zulip):

heh, the test suite is slow enough on those beefy computers. It would be "omegalulz" for AVR

Jake Goulding (Jun 16 2019 at 17:29, on Zulip):

I'd be happy to try and write some kind of rust-lang/rust test, but I don't know how to trigger an addrspace(1) function to start with...

eddyb (Jun 16 2019 at 17:29, on Zulip):

you could, I suppose, make a codegen test that passes --target... idk

eddyb (Jun 16 2019 at 17:29, on Zulip):

@nagisa might

nagisa (Jun 16 2019 at 18:54, on Zulip):

yes you can write target-specific codegen tests now

Jake Goulding (Jun 18 2019 at 02:12, on Zulip):

@eddyb could you help me verify that this LLVM IR for a simple generator looks right?

; ModuleID = 'blink.bpf1ew8c-cgu.0'
source_filename = "blink.bpf1ew8c-cgu.0"
target datalayout = "e-P1-p:16:8-i8:8-i16:8-i32:8-i64:8-f32:8-f64:8-n8-a:8"
target triple = "avr-unknown-unknown"

%"exercise_generator::{{closure}}" = type { [0 x i8], i32, [3 x i8] }

@0 = private unnamed_addr constant <{ [2 x i8] }> <{ [2 x i8] c"on" }>, align 1
@1 = private unnamed_addr constant <{ [3 x i8] }> <{ [3 x i8] c"off" }>, align 1
@2 = private unnamed_addr constant <{ [2 x i8] }> <{ [2 x i8] c"\0D\0A" }>, align 1


; blink::exercise_generator
; Function Attrs: minsize noinline noreturn nounwind optsize uwtable
define internal fastcc void @_ZN5blink18exercise_generator17h7a2821046021edbfE() unnamed_addr addrspace(1) #5 personality void () addrspace(1)* @rust_eh_personality !dbg !1319 {
bb2:
  %gen = alloca %"exercise_generator::{{closure}}", align 1
  %0 = getelementptr inbounds %"exercise_generator::{{closure}}", %"exercise_generator::{{closure}}"* %gen, i16 0, i32 0, i16 0, !dbg !1348
  %1 = bitcast %"exercise_generator::{{closure}}"* %gen to i32*, !dbg !1349
  store i32 0, i32* %1, align 1, !dbg !1349
  %.phi.trans.insert.i = getelementptr inbounds %"exercise_generator::{{closure}}", %"exercise_generator::{{closure}}"* %gen, i16 0, i32 0, i16 5
  %.phi.trans.insert15.i = bitcast i8* %.phi.trans.insert.i to i8**
  %2 = getelementptr inbounds %"exercise_generator::{{closure}}", %"exercise_generator::{{closure}}"* %gen, i16 0, i32 0, i16 4
  store i8 1, i8* %2, align 1, !dbg !1370
  store i8* %2, i8** %.phi.trans.insert15.i, align 1, !dbg !1371
  br label %bb3.i, !dbg !1373

bb3.i:                                            ; preds = %"_ZN5blink18exercise_generator28_$u7b$$u7b$closure$u7d$$u7d$17hdda39e828092551eE.exit", %bb2
; call blink::write_strln
  call fastcc addrspace(1) void @_ZN5blink11write_strln17h9d9f094c9a29470cE([0 x i8]* noalias nonnull readonly align 1 bitcast (<{ [2 x i8] }>* @0 to [0 x i8]*), i16 2), !dbg !1374
  br label %"_ZN5blink18exercise_generator28_$u7b$$u7b$closure$u7d$$u7d$17hdda39e828092551eE.exit"

bb7.i:                                            ; preds = %"_ZN5blink18exercise_generator28_$u7b$$u7b$closure$u7d$$u7d$17hdda39e828092551eE.exit"
; call blink::write_strln
  call fastcc addrspace(1) void @_ZN5blink11write_strln17h9d9f094c9a29470cE([0 x i8]* noalias nonnull readonly align 1 bitcast (<{ [3 x i8] }>* @1 to [0 x i8]*), i16 3), !dbg !1375
  br label %"_ZN5blink18exercise_generator28_$u7b$$u7b$closure$u7d$$u7d$17hdda39e828092551eE.exit"

"_ZN5blink18exercise_generator28_$u7b$$u7b$closure$u7d$$u7d$17hdda39e828092551eE.exit": ; preds = %bb3.i, %bb7.i
  %3 = load i8*, i8** %.phi.trans.insert15.i, align 1, !dbg !1376, !nonnull !14
  %4 = load i8, i8* %3, align 1, !dbg !1376, !range !1377
  %5 = xor i8 %4, 1, !dbg !1378
  store i8 %5, i8* %3, align 1, !dbg !1378
  store i32 3, i32* %1, align 1, !dbg !1379
  %.pre.i = load i8*, i8** %.phi.trans.insert15.i, align 1, !dbg !1380
  %.pre16.i = load i8, i8* %.pre.i, align 1, !dbg !1380, !range !1377
  %phitmp.i = icmp eq i8 %.pre16.i, 0, !dbg !1381
  br i1 %phitmp.i, label %bb7.i, label %bb3.i, !dbg !1373
}
Jake Goulding (Jun 18 2019 at 02:14, on Zulip):

It comes from this generator code. I want to make sure that the change about casting pointers you helped me with is unlikely to have any effect to this type of code.

eddyb (Jun 18 2019 at 14:01, on Zulip):

@Jake Goulding generators don't need function pointers at all so none of this should've changed

Jake Goulding (Jun 18 2019 at 14:01, on Zulip):

You checked my implementation of the hack to make sure it wasn't overly broad, yeah?

Jake Goulding (Jun 18 2019 at 14:01, on Zulip):

I was afraid you'd say that though.

Jake Goulding (Jun 18 2019 at 14:02, on Zulip):

line-by-line assembly and LLVM-IR de-/re-construction is so tiresome

eddyb (Jun 18 2019 at 14:03, on Zulip):

@Jake Goulding oh your change is not what I Was hoping for

Jake Goulding (Jun 18 2019 at 14:03, on Zulip):

Right.

Jake Goulding (Jun 18 2019 at 14:03, on Zulip):

It was the first version you suggested

eddyb (Jun 18 2019 at 14:03, on Zulip):

I expected something very different

eddyb (Jun 18 2019 at 14:03, on Zulip):

yeah okay

Jake Goulding (Jun 18 2019 at 14:03, on Zulip):

but then you went on the Option route

eddyb (Jun 18 2019 at 14:03, on Zulip):

this original thing could break stuff

eddyb (Jun 18 2019 at 14:03, on Zulip):

but I'm not sure

Jake Goulding (Jun 18 2019 at 14:04, on Zulip):

OK, so I should switch to the Option and pass in the none from the const allocation area

eddyb (Jun 18 2019 at 14:05, on Zulip):

yeah and independently of that, make the ConstInBoundsGEP stuff conditional on ptr.offset.bytes() != 0 (or ptr.offset != Size::ZERO? not sure)

Jake Goulding (Jun 18 2019 at 14:06, on Zulip):

Another roadblock I had was with this bit of code:

            Scalar::Raw { data, size } => {
                assert_eq!(size as u64, layout.value.size(self).bytes());
                let llval = self.const_uint_big(self.type_ix(bitsize), data);
                if layout.value == layout::Pointer {
                    unsafe { llvm::LLVMConstIntToPtr(llval, llty) }
                } else {
                    self.const_bitcast(llval, llty)
                }
            },

You suggested making llty the Option, so what should I do in that block?

eddyb (Jun 18 2019 at 14:06, on Zulip):

you have a llval prior to the cast

eddyb (Jun 18 2019 at 14:06, on Zulip):

which means you can just return that

eddyb (Jun 18 2019 at 14:07, on Zulip):

the if-else in there is just a cast. but weird because LLVM doesn't have a simple "cast this and figure out how" function for constant vals, despite having one for runtime casts

eddyb (Jun 18 2019 at 14:08, on Zulip):

this would only be tricky if you needed llty to create a value at all. but it's only used for a "type cast" (the bitwidth doesn't change)

Jake Goulding (Jun 18 2019 at 14:35, on Zulip):

Does this diff look closer to your intent?

Jake Goulding (Jun 18 2019 at 14:35, on Zulip):

it seemingly compiles that code (now building everything else...)

eddyb (Jun 18 2019 at 15:02, on Zulip):

I would use if let Some and mutate inside, but this seems okay

Jake Goulding (Jun 18 2019 at 15:06, on Zulip):

I can tweak that. This is a case where I really wish my RLS + emacs setup worked in the compiler tree. Figuring out what is what type is very slow.

eddyb (Jun 18 2019 at 15:07, on Zulip):

yeaaaaaaaah sadly I think it will take the death of save-analysis to make RLS viable for the compiler source

eddyb (Jun 18 2019 at 15:08, on Zulip):

you could ask me but that doesn't work when I'm not around :P

eddyb (Jun 18 2019 at 15:08, on Zulip):

one trick I rely on is double-click names so I can see all the places they occur

eddyb (Jun 18 2019 at 15:08, on Zulip):

but that doesn't get you that far sometimes

Jake Goulding (Jun 18 2019 at 15:25, on Zulip):

Sadly, this change doesn't seem to make my generator work correctly.

Last update: Nov 20 2019 at 02:40UTC